mirror of
https://github.com/hanzoai/social.git
synced 2026-10-17 11:18:21 +00:00
Hanzo Social — publish to every account an org owns
A social publishing surface on @hanzo/gui + @hanzo/ui, written against the platform's own /v1/social and /v1/integration. Six screens: the org roll-up, a composer, the posts in every state, a month calendar, the networks with their consent and their handles, and the delivery record. Two facts shape it. A post carries one channel, so the composer stores one post per network — which is also what makes writing separately for a single network free, since it is a different content on that row. Media is a list of addresses, so a video is ordinary content rather than a second kind of post, and each network's own rules are checked before the post is stored rather than discovered at a publish hours later. State is drawn in luminance on a palette with no hues, and the inversion is spent once per screen on the thing a person has to act on. Refusals are the platform's own sentence, read off the RFC 7807 detail the SDK's extractor drops — a 503 that names the missing credentials is the whole of what anyone can act on. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
.env*.local
|
||||
LLM.md
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Hanzo Social — the Vite SPA built once, served by hanzoai/spa.
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /build
|
||||
ENV CI=true PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
# Manifests first so a source edit re-runs the build and not the resolve.
|
||||
#
|
||||
# pnpm-workspace.yaml is REQUIRED even though this repo has no workspace
|
||||
# members: every dependency is `catalog:`, and the catalog lives in that file.
|
||||
# It also carries the overrides that keep ONE copy of the gui runtime — two are
|
||||
# two theme contexts, and every themed component then throws.
|
||||
#
|
||||
# FROZEN, and the lockfile ships. A resolver left free to drift builds a tree
|
||||
# nobody ran; frozen, the image gets the tree the gate passed against.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.json vite.config.ts index.html ./
|
||||
COPY src src
|
||||
|
||||
# `pnpm build` is `tsc --noEmit` && `vite build`, so the typecheck is part of
|
||||
# the image.
|
||||
#
|
||||
# `&&`, not `;`: with `;` the RUN carries the status of the LAST command and a
|
||||
# failed build would be masked. A vite build that emits nothing still exits 0,
|
||||
# which is what the file tests are for.
|
||||
RUN pnpm build \
|
||||
&& [ -s dist/index.html ] \
|
||||
&& [ -d dist/assets ]
|
||||
|
||||
# hanzoai/spa, not hanzoai/static, and not a webserver of our own: this is a
|
||||
# client-routed SPA, so it needs index.html served for every path.
|
||||
# hanzoai/static defaults to `Content-Security-Policy: default-src 'none'`,
|
||||
# which blocks the bundle the page loads and leaves a blank screen.
|
||||
# Defaults: PORT=3000, ROOT=/public.
|
||||
FROM ghcr.io/hanzoai/spa:1.4.11
|
||||
COPY --from=build /build/dist /public
|
||||
EXPOSE 3000
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, "control" means (i) the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution.
|
||||
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
|
||||
You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
You must retain, in the Source form of any Derivative Works that You distribute,
|
||||
all copyright, patent, trademark, and attribution notices from the Source form
|
||||
of the Work, excluding those notices that do not pertain to any part of the
|
||||
Derivative Works; and
|
||||
If the Work includes a "NOTICE" text file as part of its distribution, then any
|
||||
Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions.
|
||||
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
|
||||
6. Trademarks.
|
||||
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
|
||||
8. Limitation of Liability.
|
||||
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability.
|
||||
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets "[]" replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on
|
||||
the same "printed page" as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Copyright 2026 Hanzo AI, Inc
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hanzo AI, Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,27 @@
|
||||
Hanzo Social
|
||||
Copyright (c) 2026 Hanzo AI, Inc.
|
||||
|
||||
Licensed under either of Apache License, Version 2.0 (LICENSE-APACHE) or
|
||||
MIT license (LICENSE-MIT) at your option.
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally
|
||||
submitted for inclusion in this work by you, as defined in the Apache-2.0
|
||||
licence, shall be dual licensed as above, without any additional terms or
|
||||
conditions.
|
||||
|
||||
This is an independent implementation. It is written against the Hanzo
|
||||
platform's own published surface — /v1/social and /v1/integration, as
|
||||
documented at api.hanzo.ai/v1/openapi.json — and shares no source with any
|
||||
earlier social publishing tool. No code, markup, styles, or structure was
|
||||
taken from another project.
|
||||
|
||||
Bundled at build time, under their own licences:
|
||||
@hanzo/ui, @hanzo/gui MIT github.com/hanzoai/ui
|
||||
@hanzo/ai MIT github.com/hanzoai/ai
|
||||
@hanzo/iam MIT github.com/hanzoai/iam
|
||||
@hanzo/font MIT github.com/hanzoai/font
|
||||
lucide-react ISC github.com/lucide-icons/lucide
|
||||
react, react-dom MIT github.com/facebook/react
|
||||
react-router MIT github.com/remix-run/react-router
|
||||
react-hook-form MIT github.com/react-hook-form/react-hook-form
|
||||
next-themes MIT github.com/pacocoursey/next-themes
|
||||
@@ -0,0 +1,58 @@
|
||||
# Hanzo Social
|
||||
|
||||
Write once. Publish to every account your organization owns — now, or at a time
|
||||
you pick.
|
||||
|
||||
Seven networks: X, Facebook, Instagram, LinkedIn, TikTok, YouTube, Threads. Text,
|
||||
photos and video on every one of them that takes video, which is all seven.
|
||||
|
||||
## Run it
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:3300
|
||||
```
|
||||
|
||||
The port is pinned because it is half of a redirect: IAM returns the browser to
|
||||
the address the client registered, and that address carries the port.
|
||||
|
||||
`vite.config.ts` proxies `/v1` to `api.hanzo.ai` on both the dev server and the
|
||||
preview of a build, so a localhost origin reaches the gateway without failing a
|
||||
preflight. Point it elsewhere with `VITE_HANZO_API`.
|
||||
|
||||
## How it is put together
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `src/social.ts` | Every `/v1` address this app speaks, in one file |
|
||||
| `src/network.ts` | What each network accepts, and whether this post satisfies it |
|
||||
| `src/status.tsx` | How a state is drawn on a palette with no hues |
|
||||
| `src/client.tsx` | The platform client, and the organization it speaks for |
|
||||
| `src/page.tsx` | The heading, the signed-out gate, the way a refusal reads |
|
||||
| `src/post.tsx` | One post, as every pane shows it |
|
||||
|
||||
Six screens, one per address: Overview, Compose, Posts, Calendar, Accounts,
|
||||
Analytics. `src/routes.tsx` is the table, and the sidebar reads the same one.
|
||||
|
||||
## Two planes, not one
|
||||
|
||||
A network needs both before anything goes out, and the Accounts screen shows
|
||||
them side by side:
|
||||
|
||||
- **`/v1/integration`** — the organization's *credential* for a network. The
|
||||
OAuth consent a person gives once, sealed into KMS. `Connect` leads here.
|
||||
- **`/v1/social`** — the *accounts* a post fans out to, and the posts
|
||||
themselves.
|
||||
|
||||
No credential ever reaches this code. `GET /v1/integration/connectors/:id/token`
|
||||
is the platform's one custody exit and nothing here calls it: a browser has no
|
||||
use for a provider token, and a surface holding one would be a place to leak it
|
||||
from.
|
||||
|
||||
## What it does not do
|
||||
|
||||
The platform records no engagement — no views, likes or reach — and no endpoint
|
||||
serves any, so the Analytics screen measures delivery and says so. Bluesky is
|
||||
not here because the platform's channel vocabulary does not carry it. Media is
|
||||
given as an address rather than uploaded, because a presigned URL expires and a
|
||||
network fetches the media when the post goes out.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Hanzo CI/CD — social. All real config is here; .hanzo/workflows/cicd.yml is
|
||||
# triggers and an import of hanzoai/ci.
|
||||
|
||||
test:
|
||||
# Frozen, so the gate resolves the same tree the Dockerfile does. The lockfile
|
||||
# ships, and a stale one fails here rather than silently building something
|
||||
# else.
|
||||
- name: install
|
||||
run: |
|
||||
set -e
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Verbatim the build line the image runs, run before there is an image, so a
|
||||
# break arrives in a minute instead of after a full layer stack. `pnpm build`
|
||||
# is `tsc --noEmit` then `vite build`. A vite build that emits nothing still
|
||||
# exits 0, hence the file tests.
|
||||
- name: build
|
||||
run: |
|
||||
set -e
|
||||
pnpm build
|
||||
test -s dist/index.html
|
||||
test -d dist/assets
|
||||
|
||||
images:
|
||||
- name: social
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/social
|
||||
platforms: [linux/amd64]
|
||||
# No build_secrets, and none belong here. Every credential that names a
|
||||
# person is minted by hanzo.id at sign-in; every credential that names a
|
||||
# network is sealed in KMS and read only by the publisher, server-side.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Hanzo Social</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Write once. Publish to every account you own, now or on a schedule."
|
||||
/>
|
||||
<style>
|
||||
/* The two things only the host document can answer: how tall the app is,
|
||||
and that its one child is a flex item. @hanzo/ui's stylesheet resets and
|
||||
paints everything else. Without this the app grows to fit its content
|
||||
instead of filling the window, and every pane that scrolls inside it
|
||||
scrolls the page instead. */
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
/* Keep gui's theme class in step with next-themes'. One decision, two
|
||||
names: next-themes writes `light` on <html> and gui reads `t_light` /
|
||||
`t_dark`, so without this the account's choice moves one of them and the
|
||||
page stays painted the other way.
|
||||
|
||||
`was` is not an optimisation — a `classList.toggle` that changes nothing
|
||||
still writes the attribute, and an observer reacting to its own write is
|
||||
a loop. Comparing first makes the callback a no-op unless the theme
|
||||
moved. Inline and in <head>, because the first paint happens before any
|
||||
module runs. */
|
||||
;(function () {
|
||||
var e = document.documentElement, was
|
||||
function s() {
|
||||
var l = e.classList.contains('light')
|
||||
if (l === was) return
|
||||
was = l
|
||||
e.classList.toggle('t_light', l)
|
||||
e.classList.toggle('t_dark', !l)
|
||||
}
|
||||
s()
|
||||
new MutationObserver(s).observe(e, { attributes: true, attributeFilter: ['class'] })
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@hanzo/social",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"description": "Hanzo Social \u2014 write once, publish to every account you own, now or on a schedule.",
|
||||
"packageManager": "[email protected]",
|
||||
"engines": {
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "catalog:",
|
||||
"@hanzo/font": "catalog:",
|
||||
"@hanzo/gui": "catalog:",
|
||||
"@hanzo/iam": "catalog:",
|
||||
"@hanzo/ui": "catalog:",
|
||||
"@hanzogui/next-theme": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-native-web": "catalog:",
|
||||
"react-router": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/vite": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
Generated
+6530
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# What may run its own install script. Vite's bundler is the only one, and a
|
||||
# placeholder here is an install that stops to ask.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
# One version of a package in the tree, whatever asked for it. Two copies of a
|
||||
# React runtime are two contexts, and a component then looks for the one it was
|
||||
# not rendered under. @hanzo/ui pins the gui runtime a minor below @hanzo/gui,
|
||||
# and each pins its own dependencies exactly, so without these a second copy of
|
||||
# the runtime resolves beside the first and every themed component throws
|
||||
# "Missing theme."
|
||||
overrides:
|
||||
'@hanzo/gui': 8.3.5
|
||||
'@hanzogui/animations-css': 8.3.5
|
||||
'@hanzogui/config': 8.3.5
|
||||
'@hanzogui/core': 8.3.5
|
||||
'@hanzogui/lucide-icons-2': 8.3.5
|
||||
'@hanzogui/next-theme': 8.3.5
|
||||
'@hanzogui/telemetry': 8.3.5
|
||||
'@hanzogui/toast': 8.3.5
|
||||
'@hanzogui/web': 8.3.5
|
||||
'@react-native/normalize-colors': 0.87.1
|
||||
react-is: 19.2.8
|
||||
|
||||
# Latest of everything. An age gate holds this app behind its own release train,
|
||||
# which publishes hours before it is consumed.
|
||||
minimumReleaseAge: 0
|
||||
|
||||
catalog:
|
||||
'@hanzo/ai': 0.6.16
|
||||
'@hanzo/font': 1.9.3
|
||||
'@hanzo/gui': 8.3.5
|
||||
'@hanzo/iam': 0.21.18
|
||||
'@hanzo/ui': 8.27.12
|
||||
'@hanzo/vite': 0.1.4
|
||||
'@hanzogui/next-theme': 8.3.5
|
||||
'@types/node': 26.5.0
|
||||
'@types/react': 19.2.18
|
||||
'@types/react-dom': 19.2.7
|
||||
'@vitejs/plugin-react': ^6.1.1
|
||||
lucide-react: ^1.42.0
|
||||
next-themes: ^0.4.6
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8
|
||||
react-hook-form: ^7.87.0
|
||||
react-native-web: ^0.21.2
|
||||
react-router: ^8.3.1
|
||||
typescript: 7.0.2
|
||||
vite: ^8.2.2
|
||||
@@ -0,0 +1,287 @@
|
||||
// Every network, with the two separate things it needs before a post can go out.
|
||||
//
|
||||
// THE CONSENT is the org's credential for the network — the OAuth the person
|
||||
// gives once, sealed into KMS. `GET /v1/integration` reports whether this org
|
||||
// has it and whether this deployment could even ask for it;
|
||||
// `POST /v1/integration/:provider/connect` starts it and answers the network's
|
||||
// own consent URL.
|
||||
//
|
||||
// THE TARGETS are the handles a publish fans out to — `/v1/social/accounts`.
|
||||
//
|
||||
// They are separately true and the screen keeps them apart, because a network
|
||||
// with consent and no target publishes nothing, a network with a target and no
|
||||
// consent publishes nothing, and a screen that merged them could not say which
|
||||
// was missing.
|
||||
//
|
||||
// NO CREDENTIAL IS ON THIS SCREEN. The consent happens at the network's own
|
||||
// site; what comes back here is an account label and the scopes granted. The
|
||||
// token itself never leaves KMS.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
import { Act, Ready, Failed, Screen } from '~/page'
|
||||
import { Mark, accountTone } from '~/status'
|
||||
import { rule } from '~/network'
|
||||
import { since } from '~/time'
|
||||
import { useHttp } from '~/client'
|
||||
import {
|
||||
addAccount,
|
||||
connect,
|
||||
disconnect,
|
||||
dropAccount,
|
||||
useAccounts,
|
||||
useConnections,
|
||||
useNetworks,
|
||||
why,
|
||||
type Account,
|
||||
type Connection,
|
||||
type Network,
|
||||
} from '~/social'
|
||||
|
||||
/** The handles on one network, and the way to add another. */
|
||||
function Targets({
|
||||
provider,
|
||||
rows,
|
||||
again,
|
||||
}: {
|
||||
provider: string
|
||||
rows: Account[]
|
||||
again: () => void
|
||||
}) {
|
||||
const http = useHttp()
|
||||
const [handle, setHandle] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
|
||||
const run = async (go: () => Promise<unknown>) => {
|
||||
if (!http || busy) return
|
||||
setBusy(true)
|
||||
setFailed(null)
|
||||
try {
|
||||
await go()
|
||||
setHandle('')
|
||||
again()
|
||||
} catch (e) {
|
||||
setFailed(why(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
{rows.map((row) => (
|
||||
<XStack key={row.id} items="center" gap="$3">
|
||||
<Text fontSize="$2" color="$ink" flex={1} minW={0} numberOfLines={1}>
|
||||
{row.handle}
|
||||
</Text>
|
||||
<Mark of={accountTone(row.status)} says={row.status} />
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{since(row.createdAt)}
|
||||
</Text>
|
||||
<Act onPress={() => void run(() => dropAccount(http!, row.id))} disabled={busy}>
|
||||
Remove
|
||||
</Act>
|
||||
</XStack>
|
||||
))}
|
||||
<XStack gap="$2">
|
||||
<input
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' &&
|
||||
handle.trim() &&
|
||||
void run(() => addAccount(http!, { provider, handle: handle.trim() }))
|
||||
}
|
||||
placeholder="The handle on this network"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 10px',
|
||||
outline: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Act
|
||||
onPress={() => void run(() => addAccount(http!, { provider, handle: handle.trim() }))}
|
||||
disabled={busy || !handle.trim()}
|
||||
>
|
||||
{busy ? 'Saving…' : 'Add'}
|
||||
</Act>
|
||||
</XStack>
|
||||
{failed ? (
|
||||
<Text fontSize="$1" color="$ink" fontWeight="500">
|
||||
{failed}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One network: what this deployment can do, what this org has consented to,
|
||||
* and the handles a publish would reach. */
|
||||
function One({
|
||||
network,
|
||||
connection,
|
||||
rows,
|
||||
again,
|
||||
}: {
|
||||
network: Network
|
||||
connection: Connection | undefined
|
||||
rows: Account[]
|
||||
again: () => void
|
||||
}) {
|
||||
const http = useHttp()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
const it = rule(network.provider)
|
||||
const on = Boolean(connection?.connected)
|
||||
|
||||
const start = async () => {
|
||||
if (!http || busy) return
|
||||
setBusy(true)
|
||||
setFailed(null)
|
||||
try {
|
||||
const { authorizeUrl } = await connect(http, network.provider)
|
||||
// The network's own consent screen. A full navigation rather than a popup:
|
||||
// several of these refuse to render in a frame or a window opened by
|
||||
// script, and the return address is this app's own origin.
|
||||
if (authorizeUrl) window.location.assign(authorizeUrl)
|
||||
else setFailed('The platform did not answer a consent address for this network.')
|
||||
} catch (e) {
|
||||
setFailed(why(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
if (!http || busy) return
|
||||
setBusy(true)
|
||||
setFailed(null)
|
||||
try {
|
||||
await disconnect(http, network.provider)
|
||||
again()
|
||||
} catch (e) {
|
||||
setFailed(why(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$3" p="$4" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$3">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink" flex={1}>
|
||||
{it.name}
|
||||
</Text>
|
||||
<Mark of={on ? 'up' : 'quiet'} says={on ? 'connected' : 'not connected'} />
|
||||
</XStack>
|
||||
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$1" color="$soft">
|
||||
{connection?.description ?? 'This network is not in the connector catalog.'}
|
||||
</Text>
|
||||
{on && connection?.connection ? (
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{connection.connection.account || 'account connected'}
|
||||
{connection.connection.scopes.length
|
||||
? ` · ${connection.connection.scopes.join(', ')}`
|
||||
: ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
{on ? (
|
||||
<Act onPress={() => void stop()} disabled={busy}>
|
||||
{busy ? 'Disconnecting…' : 'Disconnect'}
|
||||
</Act>
|
||||
) : connection?.available ? (
|
||||
<Act onPress={() => void start()} disabled={busy} loud>
|
||||
{busy ? 'Opening…' : `Connect ${it.name}`}
|
||||
</Act>
|
||||
) : (
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
This deployment holds no app credentials for {it.name}, so there is
|
||||
nothing to consent to yet.
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
{/*
|
||||
The publish edge reads a second set of credentials — the social plane's
|
||||
own — and names the environment variables it is missing. Only the NAMES;
|
||||
a value never crosses this wire. It is for whoever runs the deployment,
|
||||
so it reads quiet rather than loud: the person on this screen cannot set
|
||||
an environment variable.
|
||||
*/}
|
||||
{network.missingCredentials?.length ? (
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
Publishing to {it.name} also needs {network.missingCredentials.join(', ')} set
|
||||
on the deployment.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<YStack gap="$2" pt="$2" borderTopWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" color="$soft">
|
||||
Where posts go — {rows.length} handle{rows.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
<Targets provider={network.provider} rows={rows} again={again} />
|
||||
</YStack>
|
||||
|
||||
{failed ? (
|
||||
<Text fontSize="$1" color="$ink" fontWeight="500">
|
||||
{failed}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function Accounts() {
|
||||
const http = useHttp()
|
||||
const networks = useNetworks(http)
|
||||
const accounts = useAccounts(http)
|
||||
const all = networks.it ?? []
|
||||
const connections = useConnections(
|
||||
http,
|
||||
all.map((n) => n.provider),
|
||||
)
|
||||
|
||||
const again = () => {
|
||||
accounts.again()
|
||||
connections.again()
|
||||
}
|
||||
|
||||
return (
|
||||
<Ready>
|
||||
<Screen
|
||||
title="Accounts"
|
||||
says="Give a network consent once, then say which handles a post goes to."
|
||||
>
|
||||
{networks.failed ? <Failed what="the networks" why={networks.failed} /> : null}
|
||||
{accounts.failed ? <Failed what="the accounts" why={accounts.failed} /> : null}
|
||||
{connections.failed ? <Failed what="the connections" why={connections.failed} /> : null}
|
||||
|
||||
<YStack gap="$3">
|
||||
{all.map((network) => (
|
||||
<One
|
||||
key={network.provider}
|
||||
network={network}
|
||||
connection={(connections.it ?? []).find((c) => c.id === network.provider)}
|
||||
rows={(accounts.it ?? []).filter((a) => a.provider === network.provider)}
|
||||
again={again}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</Screen>
|
||||
</Ready>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// How the publishing itself is going.
|
||||
//
|
||||
// WHAT THE PLATFORM RECORDS is whether a post went out, through which account,
|
||||
// what id the network gave it back, and why an attempt failed. It records NO
|
||||
// engagement — no impressions, no views, no likes, no reach — and no endpoint on
|
||||
// this surface serves any. So none is drawn. A tile reading "0 impressions"
|
||||
// would be a measurement of nothing, and a sparkline under it would be a
|
||||
// measurement of nothing over time.
|
||||
//
|
||||
// What IS here is the delivery record, which is the part that has an answer:
|
||||
// where posts stand per network, and every failure with the platform's own
|
||||
// reason for it.
|
||||
|
||||
import { Text, XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
import { Ready, Failed, Nothing, Screen } from '~/page'
|
||||
import { Mark, tone } from '~/status'
|
||||
import { rule } from '~/network'
|
||||
import { since } from '~/time'
|
||||
import { useHttp } from '~/client'
|
||||
import { usePosts, useSummary, type Post } from '~/social'
|
||||
|
||||
const STATES = ['published', 'scheduled', 'draft', 'failed'] as const
|
||||
|
||||
/** The delivery record for one network. */
|
||||
interface Tally {
|
||||
provider: string
|
||||
total: number
|
||||
published: number
|
||||
scheduled: number
|
||||
draft: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
function tally(posts: Post[]): Tally[] {
|
||||
const by = new Map<string, Tally>()
|
||||
for (const post of posts) {
|
||||
const row =
|
||||
by.get(post.channel) ??
|
||||
{ provider: post.channel, total: 0, published: 0, scheduled: 0, draft: 0, failed: 0 }
|
||||
row.total += 1
|
||||
if (post.status === 'published') row.published += 1
|
||||
else if (post.status === 'scheduled') row.scheduled += 1
|
||||
else if (post.status === 'failed') row.failed += 1
|
||||
else row.draft += 1
|
||||
by.set(post.channel, row)
|
||||
}
|
||||
return [...by.values()].sort((a, b) => b.total - a.total)
|
||||
}
|
||||
|
||||
/** A share of a whole, drawn in the ramp: the bar is the top of it, the track
|
||||
* is the surface under it. No hue, because a proportion has no category. */
|
||||
function Share({ of, out }: { of: number; out: number }) {
|
||||
const part = out > 0 ? Math.round((of / out) * 100) : 0
|
||||
return (
|
||||
<XStack items="center" gap="$2" flex={1} minW={120}>
|
||||
<YStack flex={1} height={4} rounded={999} bg="$panel" overflow="hidden">
|
||||
<YStack width={`${part}%`} height={4} bg="$ink" />
|
||||
</YStack>
|
||||
<Text fontSize="$1" color="$quiet" minW={36} text="right">
|
||||
{part}%
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function Analytics() {
|
||||
const http = useHttp()
|
||||
const summary = useSummary(http)
|
||||
const posts = usePosts(http)
|
||||
const rows = posts.it ?? []
|
||||
const byNetwork = tally(rows)
|
||||
const failures = rows.filter((p) => p.status === 'failed' || p.error)
|
||||
const landed = summary.it ? summary.it.published : 0
|
||||
const written = summary.it ? summary.it.posts : 0
|
||||
|
||||
return (
|
||||
<Ready>
|
||||
<Screen
|
||||
title="Analytics"
|
||||
says="What went out, where it went, and what stopped the rest."
|
||||
>
|
||||
{summary.failed ? <Failed what="the roll-up" why={summary.failed} /> : null}
|
||||
{posts.failed ? <Failed what="the posts" why={posts.failed} /> : null}
|
||||
|
||||
<YStack gap="$2" p="$4" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="baseline" gap="$2">
|
||||
<Text fontSize="$7" fontWeight="600" color="$ink">
|
||||
{written ? `${Math.round((landed / written) * 100)}%` : '—'}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$soft">
|
||||
of what was written has gone out
|
||||
</Text>
|
||||
</XStack>
|
||||
<Share of={landed} out={written} />
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{landed} published of {written} written · {summary.it?.scheduled ?? 0} still
|
||||
queued · {summary.it?.accounts ?? 0} accounts connected. These four counts are
|
||||
the platform's own, taken over every row this organization has.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
By network
|
||||
</Text>
|
||||
{byNetwork.length === 0 ? (
|
||||
<Nothing says="Nothing written yet, so there is nothing to measure." />
|
||||
) : (
|
||||
<YStack>
|
||||
{byNetwork.map((row) => (
|
||||
<YStack
|
||||
key={row.provider}
|
||||
gap="$2"
|
||||
py="$3"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<XStack items="center" gap="$3" flexWrap="wrap">
|
||||
<Text fontSize="$2" color="$ink" minW={110}>
|
||||
{rule(row.provider).name}
|
||||
</Text>
|
||||
<Share of={row.published} out={row.total} />
|
||||
<Text fontSize="$1" color="$quiet" minW={70} text="right">
|
||||
{row.total} post{row.total === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{/* A count of nothing is not a state. Zero reads `quiet`
|
||||
whatever the state would otherwise be, or a network with
|
||||
no failures draws the attention step five times. */}
|
||||
{STATES.map((state) => (
|
||||
<Mark
|
||||
key={state}
|
||||
of={row[state] ? tone(state) : 'quiet'}
|
||||
says={`${row[state]} ${state}`}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
Counted over the most recent {rows.length} post
|
||||
{rows.length === 1 ? '' : 's'} — the listing is a page, not the whole
|
||||
history, which is why the share above is taken from the roll-up instead.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
What stopped
|
||||
</Text>
|
||||
{failures.length === 0 ? (
|
||||
<Nothing says="Nothing has failed." />
|
||||
) : (
|
||||
<YStack>
|
||||
{failures.map((post) => (
|
||||
<YStack
|
||||
key={post.id}
|
||||
gap="$1"
|
||||
py="$3"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<XStack items="center" gap="$3">
|
||||
<Text fontSize="$2" color="$ink" flex={1} minW={0} numberOfLines={1}>
|
||||
{post.content || '(no words — media only)'}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{rule(post.channel).name} · {since(post.updatedAt)}
|
||||
</Text>
|
||||
</XStack>
|
||||
{/* The platform's own sentence, kept whole: it names the exact
|
||||
credentials a deployment is missing, which is the part
|
||||
anybody can act on. */}
|
||||
<Text fontSize="$1" color="$ink" fontWeight="500">
|
||||
{post.error || 'The platform recorded no reason.'}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
Engagement — views, likes, reach — is not recorded by this platform and
|
||||
nothing here estimates it.
|
||||
</Text>
|
||||
</Screen>
|
||||
</Ready>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// The month, and what is going out on each day of it.
|
||||
//
|
||||
// WHICH DAY A POST SITS ON depends on what the post is. A scheduled post has a
|
||||
// `scheduleAt` and sits on that day. Anything else has no time of its own — the
|
||||
// platform records no published-at — so it sits on `updatedAt`, which for a post
|
||||
// that has gone out is the moment it went. The distinction is stated on the
|
||||
// screen rather than smoothed over, because a calendar that quietly invents a
|
||||
// date for half its rows is a calendar nobody can plan against.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Box, Text, XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
import { Act, Ready, Failed, Screen } from '~/page'
|
||||
import { Full } from '~/post'
|
||||
import { Mark, tone } from '~/status'
|
||||
import { day, grid } from '~/time'
|
||||
import { rule } from '~/network'
|
||||
import { useHttp } from '~/client'
|
||||
import { usePosts, type Post } from '~/social'
|
||||
|
||||
const WEEK = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
/** The day a post belongs on. */
|
||||
const falls = (post: Post): string =>
|
||||
day(post.status === 'scheduled' && post.scheduleAt ? post.scheduleAt : post.updatedAt)
|
||||
|
||||
export function Calendar() {
|
||||
const http = useHttp()
|
||||
const posts = usePosts(http)
|
||||
const [month, setMonth] = useState(() => {
|
||||
const d = new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
})
|
||||
const [opened, setOpened] = useState<string | null>(null)
|
||||
|
||||
const days = grid(month.year, month.month)
|
||||
const byDay = new Map<string, Post[]>()
|
||||
for (const post of posts.it ?? []) {
|
||||
const key = falls(post)
|
||||
byDay.set(key, [...(byDay.get(key) ?? []), post])
|
||||
}
|
||||
const today = day(Math.floor(Date.now() / 1000))
|
||||
const open = (posts.it ?? []).find((p) => p.id === opened) ?? null
|
||||
|
||||
const move = (by: number) => {
|
||||
const d = new Date(month.year, month.month + by, 1)
|
||||
setMonth({ year: d.getFullYear(), month: d.getMonth() })
|
||||
setOpened(null)
|
||||
}
|
||||
|
||||
const named = new Date(month.year, month.month, 1).toLocaleString(undefined, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<Ready>
|
||||
<Screen
|
||||
title="Calendar"
|
||||
says="Scheduled posts sit on the day they are due; everything else on the day it last changed."
|
||||
beside={
|
||||
<XStack items="center" gap="$2">
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => move(-1)}
|
||||
aria-label="The month before"
|
||||
p="$2"
|
||||
rounded="$2"
|
||||
bg="transparent"
|
||||
borderWidth={0}
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<ChevronLeft size={15} aria-hidden />
|
||||
</Box>
|
||||
<Text fontSize="$2" color="$ink" minW={140} text="center">
|
||||
{named}
|
||||
</Text>
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => move(1)}
|
||||
aria-label="The month after"
|
||||
p="$2"
|
||||
rounded="$2"
|
||||
bg="transparent"
|
||||
borderWidth={0}
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<ChevronRight size={15} aria-hidden />
|
||||
</Box>
|
||||
<Act onPress={() => move(0)}>Today</Act>
|
||||
</XStack>
|
||||
}
|
||||
>
|
||||
{posts.failed ? <Failed what="the calendar" why={posts.failed} /> : null}
|
||||
|
||||
<YStack gap="$1">
|
||||
<div className="week">
|
||||
{WEEK.map((name) => (
|
||||
<Text key={name} fontSize="$1" color="$quiet" px="$1">
|
||||
{name}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
<div className="month">
|
||||
{days.map(({ key, date, inMonth }) => {
|
||||
const here = byDay.get(key) ?? []
|
||||
return (
|
||||
<YStack
|
||||
key={key}
|
||||
className="day"
|
||||
gap="$1"
|
||||
p="$1"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$2"
|
||||
bg={key === today ? '$hover' : 'transparent'}
|
||||
opacity={inMonth ? 1 : 0.4}
|
||||
>
|
||||
<Text fontSize="$1" color={key === today ? '$ink' : '$quiet'}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
{here.map((post) => (
|
||||
<Box
|
||||
key={post.id}
|
||||
render="button"
|
||||
onClick={() => setOpened(post.id === opened ? null : post.id)}
|
||||
aria-pressed={post.id === opened}
|
||||
width="100%"
|
||||
px="$1"
|
||||
py="$1"
|
||||
rounded="$2"
|
||||
bg={post.id === opened ? '$ink' : '$panel'}
|
||||
borderWidth={0}
|
||||
hoverStyle={{ bg: post.id === opened ? '$ink' : '$hover' }}
|
||||
>
|
||||
<YStack gap="$1" items="flex-start">
|
||||
<Text
|
||||
fontSize="$1"
|
||||
color={post.id === opened ? '$background' : '$soft'}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{rule(post.channel).name}
|
||||
</Text>
|
||||
{post.id === opened ? null : (
|
||||
<Mark of={tone(post.status)} says={post.status} />
|
||||
)}
|
||||
</YStack>
|
||||
</Box>
|
||||
))}
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</YStack>
|
||||
|
||||
{open ? (
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
Opened
|
||||
</Text>
|
||||
<Full post={open} again={posts.again} />
|
||||
</YStack>
|
||||
) : (
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
Pick a post to move it, send it, or delete it.
|
||||
</Text>
|
||||
)}
|
||||
</Screen>
|
||||
</Ready>
|
||||
)
|
||||
}
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
// Writing one thing and sending it to several networks.
|
||||
//
|
||||
// ONE POST PER NETWORK, and the screen says so rather than hiding it. A post on
|
||||
// this platform carries ONE channel — `POST /v1/social/posts` takes `channel`, a
|
||||
// single network — so picking four networks stores four posts. That is the
|
||||
// truthful shape, and it is also the useful one: each has its own state, its own
|
||||
// failure and its own id to publish, which is what a person wants when LinkedIn
|
||||
// takes it and TikTok refuses it.
|
||||
//
|
||||
// It is also what makes writing separately for one network free. The words for
|
||||
// X are just a different `content` on the X row; nothing has to be modelled to
|
||||
// support it, and a network that has not been taken aside simply carries the
|
||||
// shared words.
|
||||
//
|
||||
// MEDIA IS AN ADDRESS. A post carries `media` as a list of URLs, and there is no
|
||||
// upload on this surface — the platform mints presigned URLs that EXPIRE, and an
|
||||
// address that expires is not one a network can fetch from when the post goes
|
||||
// out hours later. So the composer takes addresses that are already public,
|
||||
// reads each one's kind off its extension, and shows it: a video is ordinary
|
||||
// content here, not a second kind of post.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Box, Text, XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
import { Act, Ready, Failed, Screen } from '~/page'
|
||||
import { Mark, tone } from '~/status'
|
||||
import { MEDIA, check, kind, rule } from '~/network'
|
||||
import { fromField, now, toField } from '~/time'
|
||||
import { useHttp } from '~/client'
|
||||
import { publish, useAccounts, useNetworks, why, write } from '~/social'
|
||||
|
||||
/** What came of sending, network by network. */
|
||||
interface Outcome {
|
||||
provider: string
|
||||
status: string
|
||||
says: string
|
||||
}
|
||||
|
||||
/** The shared tab. Not a network, so it cannot collide with a provider id. */
|
||||
const SHARED = ''
|
||||
|
||||
/** One media address: what it is, and a look at it. */
|
||||
function Item({ url, forget }: { url: string; forget: () => void }) {
|
||||
const is = kind(url)
|
||||
return (
|
||||
<YStack width={132} gap="$1" p="$2" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<Box className="media" rounded="$2" overflow="hidden">
|
||||
{is === 'video' ? (
|
||||
// `controls` and nothing else: no autoplay, no loop. A composer that
|
||||
// starts playing four videos at once is a composer nobody can think in.
|
||||
<video src={url} controls preload="metadata" />
|
||||
) : is === 'image' ? (
|
||||
<img src={url} alt="" loading="lazy" />
|
||||
) : null}
|
||||
</Box>
|
||||
<XStack items="center" gap="$1">
|
||||
<Text fontSize="$1" color={is === 'unknown' ? '$ink' : '$soft'} flex={1} numberOfLines={1}>
|
||||
{is === 'unknown' ? 'unknown kind' : is}
|
||||
</Text>
|
||||
<Box
|
||||
render="button"
|
||||
onClick={forget}
|
||||
aria-label="Remove this media"
|
||||
p="$1"
|
||||
rounded="$2"
|
||||
bg="transparent"
|
||||
borderWidth={0}
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Trash2 size={12} aria-hidden />
|
||||
</Box>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The writing surface. One shape, so the shared words and a network's own
|
||||
* words are visibly the same thing rather than two different editors. */
|
||||
function Words({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 8,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder: string
|
||||
rows?: number
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
outline: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Compose() {
|
||||
const http = useHttp()
|
||||
const networks = useNetworks(http)
|
||||
const accounts = useAccounts(http)
|
||||
|
||||
const [shared, setShared] = useState('')
|
||||
/** The networks written separately. A key present means this network has been
|
||||
* taken off the shared words, even when the words are momentarily the same —
|
||||
* which is why it is a map with an entry and not a comparison. */
|
||||
const [apart, setApart] = useState<Record<string, string>>({})
|
||||
const [media, setMedia] = useState<string[]>([])
|
||||
const [address, setAddress] = useState('')
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [tab, setTab] = useState<string>(SHARED)
|
||||
const [at, setAt] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [outcome, setOutcome] = useState<Outcome[] | null>(null)
|
||||
|
||||
const when = fromField(at)
|
||||
const all = networks.it ?? []
|
||||
|
||||
/** What this network will actually carry. */
|
||||
const words = (provider: string) => apart[provider] ?? shared
|
||||
|
||||
// A connected account is what a publish fans out to, so the composer counts
|
||||
// them per network — a picked network with none stores a post with nowhere
|
||||
// to go, and says so rather than letting it be discovered later.
|
||||
const targets = (provider: string) =>
|
||||
(accounts.it ?? []).filter((a) => a.provider === provider && a.status === 'connected').length
|
||||
|
||||
const complaints = Object.fromEntries(
|
||||
picked.map((p) => [p, check(p, words(p), media)]),
|
||||
) as Record<string, string[]>
|
||||
const blocked = picked.some((p) => (complaints[p] ?? []).length > 0)
|
||||
|
||||
const pick = (provider: string) => {
|
||||
const on = picked.includes(provider)
|
||||
setPicked(on ? picked.filter((p) => p !== provider) : [...picked, provider])
|
||||
// A network taken out of the post takes its own words with it. Leaving them
|
||||
// behind means picking it again silently restores words the person thought
|
||||
// they had discarded.
|
||||
if (on) {
|
||||
setApart(Object.fromEntries(Object.entries(apart).filter(([k]) => k !== provider)))
|
||||
if (tab === provider) setTab(SHARED)
|
||||
}
|
||||
}
|
||||
|
||||
const addMedia = () => {
|
||||
const url = address.trim()
|
||||
if (!url || media.includes(url) || media.length >= MEDIA) return
|
||||
setMedia([...media, url])
|
||||
setAddress('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Store one post per picked network, and — when the person asked for it to go
|
||||
* now — publish each.
|
||||
*
|
||||
* Sequential rather than parallel: each network's outcome is reported on its
|
||||
* own row, and a fan-out that raced would report them in whatever order the
|
||||
* gateway answered. Every outcome is recorded, refusals included, because the
|
||||
* platform's 503 names exactly which credentials this deployment is missing
|
||||
* and that sentence is the whole of what a person can act on.
|
||||
*/
|
||||
const send = async (go: boolean) => {
|
||||
if (!http || sending || picked.length === 0 || blocked) return
|
||||
setSending(true)
|
||||
setOutcome(null)
|
||||
const got: Outcome[] = []
|
||||
for (const provider of picked) {
|
||||
try {
|
||||
const post = await write(http, {
|
||||
content: words(provider),
|
||||
channel: provider,
|
||||
media,
|
||||
scheduleAt: go ? 0 : when,
|
||||
status: go || !when ? 'draft' : 'scheduled',
|
||||
})
|
||||
if (!go) {
|
||||
got.push({
|
||||
provider,
|
||||
status: post.status,
|
||||
says: post.status === 'scheduled' ? 'Scheduled.' : 'Saved as a draft.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
// A publish is addressed BY ID, so a create that answered no id has
|
||||
// nothing to publish — and building the address anyway sends
|
||||
// `/posts/undefined/publish`, which is a request to a route nobody
|
||||
// serves and an error that blames the wrong step.
|
||||
if (!post?.id) {
|
||||
got.push({
|
||||
provider,
|
||||
status: 'failed',
|
||||
says: 'The platform answered without a post id, so there is nothing to publish.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
const sent = await publish(http, post.id)
|
||||
got.push({
|
||||
provider,
|
||||
status: sent.status,
|
||||
says: sent.error || (sent.status === 'published' ? 'Published.' : sent.status),
|
||||
})
|
||||
} catch (e) {
|
||||
got.push({ provider, status: 'failed', says: why(e) })
|
||||
}
|
||||
}
|
||||
setOutcome(got)
|
||||
setSending(false)
|
||||
// The words stay. A fan-out where one network refused is one somebody is
|
||||
// about to retry, and clearing the box would take the post with it.
|
||||
}
|
||||
|
||||
const showing = tab && picked.includes(tab) ? tab : SHARED
|
||||
const it = showing ? rule(showing) : null
|
||||
const said = showing ? (complaints[showing] ?? []) : []
|
||||
const text = showing ? words(showing) : shared
|
||||
const cap = it ? it.text : picked.length ? Math.min(...picked.map((p) => rule(p).text)) : 0
|
||||
|
||||
return (
|
||||
<Ready>
|
||||
<Screen title="Compose" says="Write it once. It is stored as one post per network.">
|
||||
<XStack gap="$5" flexWrap="wrap" items="flex-start">
|
||||
<YStack flex={1} minW={320} gap="$3">
|
||||
{/* THE TABS ARE THE NETWORKS PICKED, plus the shared words they all
|
||||
start from. A network's tab is where it stops sharing them. */}
|
||||
{picked.length ? (
|
||||
<XStack gap="$1" flexWrap="wrap">
|
||||
<Act onPress={() => setTab(SHARED)} loud={showing === SHARED}>
|
||||
Everyone
|
||||
</Act>
|
||||
{picked.map((p) => (
|
||||
<Act key={p} onPress={() => setTab(p)} loud={showing === p}>
|
||||
{rule(p).name}
|
||||
{apart[p] !== undefined ? ' ·' : ''}
|
||||
</Act>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<YStack gap="$2">
|
||||
{showing && apart[showing] === undefined ? (
|
||||
<YStack gap="$2">
|
||||
<Words
|
||||
value={shared}
|
||||
onChange={setShared}
|
||||
placeholder="What is going out?"
|
||||
rows={6}
|
||||
/>
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$1" color="$quiet" flex={1}>
|
||||
{it!.name} is carrying the shared words.
|
||||
</Text>
|
||||
<Act onPress={() => setApart({ ...apart, [showing]: shared })}>
|
||||
Write separately for {it!.name}
|
||||
</Act>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : showing ? (
|
||||
<YStack gap="$2">
|
||||
<Words
|
||||
value={text}
|
||||
onChange={(v) => setApart({ ...apart, [showing]: v })}
|
||||
placeholder={`What is going out on ${it!.name}?`}
|
||||
rows={6}
|
||||
/>
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$1" color="$quiet" flex={1}>
|
||||
Only {it!.name} carries these words.
|
||||
</Text>
|
||||
<Act
|
||||
onPress={() =>
|
||||
setApart(
|
||||
Object.fromEntries(
|
||||
Object.entries(apart).filter(([k]) => k !== showing),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Back to the shared words
|
||||
</Act>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : (
|
||||
<Words value={shared} onChange={setShared} placeholder="What is going out?" />
|
||||
)}
|
||||
|
||||
<Text fontSize="$1" color={cap && text.length > cap ? '$ink' : '$quiet'}>
|
||||
{text.length}
|
||||
{cap ? ` of ${cap}` : ''} characters
|
||||
{cap && !showing ? ' — the tightest limit among the networks picked' : ''}
|
||||
</Text>
|
||||
|
||||
{said.length ? (
|
||||
// The one inversion in the writing column, and it is the same
|
||||
// thing the send control is: something to act on before anything
|
||||
// can go out.
|
||||
<YStack gap="$1" px="$3" py="$2" rounded="$3" bg="$ink">
|
||||
{said.map((line) => (
|
||||
<Text key={line} fontSize="$1" color="$background" fontWeight="500">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
Media
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addMedia()}
|
||||
placeholder="https://… a photo or a video"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
outline: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Act onPress={addMedia} disabled={!address.trim() || media.length >= MEDIA}>
|
||||
Add
|
||||
</Act>
|
||||
</XStack>
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
An address the network can fetch — up to {MEDIA}, shared by every
|
||||
network in this post. A video counts the same as a photo here;
|
||||
which networks will take it is on the right.
|
||||
</Text>
|
||||
{media.length ? (
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{media.map((url) => (
|
||||
<Item
|
||||
key={url}
|
||||
url={url}
|
||||
forget={() => setMedia(media.filter((m) => m !== url))}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
When
|
||||
</Text>
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={at}
|
||||
min={toField(now())}
|
||||
onChange={(e) => setAt(e.target.value)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
outline: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
fontSize: 13,
|
||||
colorScheme: 'dark light',
|
||||
}}
|
||||
/>
|
||||
{at ? (
|
||||
<Act onPress={() => setAt('')}>Clear</Act>
|
||||
) : (
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
Leave it empty to keep this as a draft.
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<Act
|
||||
onPress={() => void send(true)}
|
||||
disabled={sending || picked.length === 0 || blocked}
|
||||
loud
|
||||
>
|
||||
{sending ? 'Sending…' : 'Post now'}
|
||||
</Act>
|
||||
<Act
|
||||
onPress={() => void send(false)}
|
||||
disabled={sending || picked.length === 0 || blocked}
|
||||
>
|
||||
{when ? 'Schedule' : 'Save draft'}
|
||||
</Act>
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{picked.length === 0
|
||||
? 'Pick at least one network.'
|
||||
: `${picked.length} post${picked.length === 1 ? '' : 's'} will be stored.`}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{outcome ? (
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
What happened
|
||||
</Text>
|
||||
{outcome.map((one) => (
|
||||
<YStack
|
||||
key={one.provider}
|
||||
gap="$1"
|
||||
p="$3"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<XStack items="center" gap="$3">
|
||||
<Text fontSize="$2" color="$ink" flex={1}>
|
||||
{rule(one.provider).name}
|
||||
</Text>
|
||||
<Mark of={tone(one.status)} says={one.status} />
|
||||
</XStack>
|
||||
<Text fontSize="$1" color="$soft">
|
||||
{one.says}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<YStack width={320} shrink={0} gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
Networks
|
||||
</Text>
|
||||
{networks.failed ? <Failed what="the networks" why={networks.failed} /> : null}
|
||||
{all.map((n) => {
|
||||
const on = picked.includes(n.provider)
|
||||
const problems = on ? (complaints[n.provider] ?? []) : []
|
||||
const r = rule(n.provider)
|
||||
return (
|
||||
<YStack
|
||||
key={n.provider}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor={on ? '$ink' : '$borderColor'}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => pick(n.provider)}
|
||||
aria-pressed={on}
|
||||
width="100%"
|
||||
p="$3"
|
||||
bg="transparent"
|
||||
borderWidth={0}
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<YStack gap="$1" width="100%" items="flex-start">
|
||||
<XStack items="center" gap="$2" width="100%">
|
||||
<Text fontSize="$2" color={on ? '$ink' : '$soft'} flex={1} text="left">
|
||||
{r.name}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{targets(n.provider)} account
|
||||
{targets(n.provider) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$1" color="$quiet" text="left">
|
||||
{r.text} characters
|
||||
{r.needs === 'video'
|
||||
? ' · the video is the post'
|
||||
: r.needs === 'media'
|
||||
? ' · needs a photo or a video'
|
||||
: ''}
|
||||
{r.videos > 0 && !r.mix ? ' · video cannot carry photos' : ''}
|
||||
{r.videos === 0 ? ' · no video' : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Box>
|
||||
{problems.length ? (
|
||||
<YStack gap="$1" px="$3" py="$2" bg="$ink">
|
||||
{problems.map((line) => (
|
||||
<Text key={line} fontSize="$1" color="$background" fontWeight="500">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
{on && targets(n.provider) === 0 ? (
|
||||
<Text fontSize="$1" color="$soft" px="$3" py="$2">
|
||||
No connected account on {r.name} — the post will be stored with
|
||||
nowhere to go.
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Screen>
|
||||
</Ready>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// What this organization has, in one read of each thing there is to read.
|
||||
//
|
||||
// Three reads and no derived state: the counts come from the platform's own
|
||||
// roll-up rather than from counting a page of posts, because a page is bounded
|
||||
// and a count of a bounded page is a count of the page.
|
||||
|
||||
import { Text, XStack, YStack } from '@hanzo/ui'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { Ready, Failed, Nothing, Screen } from '~/page'
|
||||
import { Mark } from '~/status'
|
||||
import { rule } from '~/network'
|
||||
import { Row } from '~/post'
|
||||
import { useHttp } from '~/client'
|
||||
import { useNetworks, usePosts, useSummary } from '~/social'
|
||||
|
||||
/** One count, and what it counts. */
|
||||
function Count({ of, says }: { of: number | null; says: string }) {
|
||||
return (
|
||||
<YStack gap="$1" p="$3" rounded="$3" borderWidth={1} borderColor="$borderColor" flex={1} minW={0}>
|
||||
<Text fontSize="$7" fontWeight="600" color="$ink">
|
||||
{of ?? '—'}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$soft">
|
||||
{says}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const http = useHttp()
|
||||
const summary = useSummary(http)
|
||||
const networks = useNetworks(http)
|
||||
const posts = usePosts(http)
|
||||
|
||||
const ready = (networks.it ?? []).filter((n) => n.credentialsConfigured)
|
||||
const waiting = (networks.it ?? []).filter((n) => !n.credentialsConfigured)
|
||||
const recent = (posts.it ?? []).slice(0, 8)
|
||||
|
||||
return (
|
||||
<Ready>
|
||||
<Screen
|
||||
title="Overview"
|
||||
says="Everything this organization has connected, written and sent."
|
||||
>
|
||||
{summary.failed ? (
|
||||
<Failed what="the roll-up" why={summary.failed} />
|
||||
) : (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<Count of={summary.it?.posts ?? null} says="Posts" />
|
||||
<Count of={summary.it?.scheduled ?? null} says="Scheduled" />
|
||||
<Count of={summary.it?.published ?? null} says="Published" />
|
||||
<Count of={summary.it?.accounts ?? null} says="Accounts" />
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
Networks
|
||||
</Text>
|
||||
{networks.failed ? (
|
||||
<Failed what="the networks" why={networks.failed} />
|
||||
) : (
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$1" color="$soft">
|
||||
{ready.length} of {(networks.it ?? []).length} can publish from this deployment.
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{ready.map((n) => (
|
||||
<Mark key={n.provider} of="up" says={rule(n.provider).name} />
|
||||
))}
|
||||
{/*
|
||||
A network whose credentials this deployment does not hold reads
|
||||
`quiet`, not `act`: the missing item is an environment variable
|
||||
on the server, which the person reading this screen cannot
|
||||
supply. The Accounts screen names the variables for whoever can.
|
||||
*/}
|
||||
{waiting.map((n) => (
|
||||
<Mark key={n.provider} of="quiet" says={rule(n.provider).name} />
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2" flex={1} minH={0}>
|
||||
<XStack items="baseline" gap="$3">
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink" flex={1}>
|
||||
Lately
|
||||
</Text>
|
||||
<Link to="/posts" style={{ textDecoration: 'none' }}>
|
||||
<Text fontSize="$1" color="$soft">
|
||||
All posts
|
||||
</Text>
|
||||
</Link>
|
||||
</XStack>
|
||||
{posts.failed ? (
|
||||
<Failed what="the posts" why={posts.failed} />
|
||||
) : recent.length === 0 ? (
|
||||
<Nothing says="Nothing written yet. Compose one and it appears here." />
|
||||
) : (
|
||||
<YStack>
|
||||
{recent.map((post) => (
|
||||
<Row key={post.id} post={post} />
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
</Screen>
|
||||
</Ready>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Everything written, in whatever state it is in.
|
||||
//
|
||||
// ONE LIST, FILTERED — not a screen per state. Drafts, the queue and what has
|
||||
// gone out are the same rows with a different `status`, and the platform filters
|
||||
// on it (`GET /v1/social/posts?status=`), so three screens would be three reads
|
||||
// of one thing and three places for the row to be drawn differently.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
import { Act, Ready, Failed, Nothing, Screen } from '~/page'
|
||||
import { Full } from '~/post'
|
||||
import { useHttp } from '~/client'
|
||||
import { usePosts } from '~/social'
|
||||
|
||||
/** The states a person filters by. `publishing` is not here: it is a claim held
|
||||
* for the length of one attempt, not a state anything rests in. */
|
||||
const STATES = [
|
||||
{ status: '', label: 'Everything' },
|
||||
{ status: 'draft', label: 'Drafts' },
|
||||
{ status: 'scheduled', label: 'Queue' },
|
||||
{ status: 'published', label: 'Published' },
|
||||
{ status: 'failed', label: 'Failed' },
|
||||
] as const
|
||||
|
||||
export function Posts() {
|
||||
const http = useHttp()
|
||||
const [state, setState] = useState('')
|
||||
const posts = usePosts(http, state || undefined)
|
||||
const label = STATES.find((s) => s.status === state)?.label ?? 'Everything'
|
||||
|
||||
return (
|
||||
<Ready>
|
||||
<Screen title="Posts" says="Drafts, the queue, and everything that has gone out.">
|
||||
<YStack gap="$3" flex={1} minH={0}>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{STATES.map(({ status, label: name }) => (
|
||||
<Act key={status || 'all'} onPress={() => setState(status)} loud={state === status}>
|
||||
{name}
|
||||
</Act>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
{posts.failed ? (
|
||||
<Failed what="the posts" why={posts.failed} />
|
||||
) : (posts.it ?? []).length === 0 ? (
|
||||
<Nothing
|
||||
says={
|
||||
state
|
||||
? `Nothing in ${label.toLowerCase()}.`
|
||||
: 'Nothing written yet. Compose one and it appears here.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<YStack>
|
||||
{(posts.it ?? []).map((post) => (
|
||||
<Full key={post.id} post={post} again={posts.again} />
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
</Screen>
|
||||
</Ready>
|
||||
)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* WHERE THE PLATFORM IS, DERIVED ONCE.
|
||||
*
|
||||
* A FUNCTION, NOT A CONSTANT. A module-scope `const` is evaluated when the chunk
|
||||
* loads, which is before the page can say where it is. Called at use time it
|
||||
* answers for the document that is open.
|
||||
*
|
||||
* The local case is why this is not simply the absolute address. api.hanzo.ai
|
||||
* admits an origin by allowlist and by an https, portless DNS proof; a
|
||||
* localhost port satisfies neither, so a credentialed read to the absolute
|
||||
* address fails its preflight and every screen draws as though the org had
|
||||
* never connected an account. `vite.config.ts` proxies `/v1` for exactly this,
|
||||
* and same-origin is what lets the proxy do its job.
|
||||
*/
|
||||
|
||||
/** The gateway's origin, with no trailing slash: callers write `${api()}/v1/…`. */
|
||||
export function api(): string {
|
||||
const set = import.meta.env.VITE_HANZO_API
|
||||
if (set) return set.replace(/\/+$/, '')
|
||||
if (typeof window !== 'undefined') {
|
||||
const host = window.location.hostname
|
||||
// A host the gateway already admits can address it absolutely; anywhere
|
||||
// else asks its own origin and lets the proxy in front of it answer.
|
||||
if (host === 'hanzo.ai' || host.endsWith('.hanzo.ai')) return 'https://api.hanzo.ai'
|
||||
return window.location.origin
|
||||
}
|
||||
return 'https://api.hanzo.ai'
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity, likewise once. IAM issues the bearer every call above carries and
|
||||
* is not reached through the API host, so it is a second address rather than a
|
||||
* path on the first. It is not proxied and does not need to be — hanzo.id
|
||||
* admits localhost on authorize, token and userinfo.
|
||||
*/
|
||||
export function iam(): string {
|
||||
return (import.meta.env.VITE_HANZO_IAM || 'https://hanzo.id').replace(/\/+$/, '')
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { YStack } from '@hanzo/ui'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { RouterProvider } from 'react-router/dom'
|
||||
|
||||
import { router } from '~/routes'
|
||||
|
||||
/**
|
||||
* The application.
|
||||
*
|
||||
* `<Hanzo>` sits above this (main.tsx) and is the design system; identity and
|
||||
* the platform client sit BELOW, as the layout route every screen shares
|
||||
* (routes.tsx), because the callback screen needs the router.
|
||||
*
|
||||
* THE ROOM IS A FLEX COLUMN AND SAYS SO. `YStack`, not `Box`: `Box` renders
|
||||
* `display: block`, and a block parent makes `flex: 1` inert on every descendant
|
||||
* — the frame under it then sizes to its own bar and every pane measures zero
|
||||
* high, with no error anywhere.
|
||||
*
|
||||
* The document IS the app, so it states its size once and clips, and every pane
|
||||
* below scrolls inside it. `flex={1}` for the width and `100dvh` for the height
|
||||
* — `#root` is the flex parent, so width comes from growing into it and height
|
||||
* is the one thing only the document can answer. `dvh` rather than `vh` so a
|
||||
* phone's collapsing browser bar cannot crop the composer off.
|
||||
*/
|
||||
export const App = () => (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
<YStack flex={1} minW={0} bg="$background" style={{ height: '100dvh', overflow: 'hidden' }}>
|
||||
<RouterProvider router={router} />
|
||||
</YStack>
|
||||
</ThemeProvider>
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { Box, Text, YStack } from '@hanzo/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
/**
|
||||
* The return from Hanzo IAM.
|
||||
*
|
||||
* The SDK reads the authorization code and state from the URL and the PKCE
|
||||
* verifier from storage, exchanges the code, and stores the tokens. Nothing
|
||||
* here touches a credential; this screen only decides where the reader lands.
|
||||
*
|
||||
* A failed exchange is reported rather than swallowed: it is the most expensive
|
||||
* refusal in the whole flow, and it is invisible from the outside.
|
||||
*/
|
||||
export function Callback() {
|
||||
const go = useNavigate()
|
||||
const { handleCallback } = useIam()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
handleCallback()
|
||||
.then(() => {
|
||||
if (!cancelled) go('/', { replace: true })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Sign-in failed')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [handleCallback, go])
|
||||
|
||||
return (
|
||||
<Box flex={1} items="center" justify="center" bg="$background">
|
||||
<YStack gap="$2" items="center">
|
||||
<Text fontSize="$5" color="$ink">
|
||||
{error ?? 'Completing sign-in…'}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$soft">
|
||||
{error ? 'Try signing in again.' : 'One moment.'}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// The platform client, and the tenant it speaks for.
|
||||
//
|
||||
// ONE CLIENT, mounted once, so no screen carries a second. A second client is a
|
||||
// second credential and a second organization, and the two disagree the moment
|
||||
// either changes.
|
||||
//
|
||||
// SIGNED OUT THERE IS NO CLIENT. Every /v1/social route requires a validated
|
||||
// principal and answers 403 without one, so an anonymous client would exist only
|
||||
// to be refused — every screen would fill with a failure that is really just
|
||||
// "nobody is signed in". `useHttp()` answers null instead and each screen says
|
||||
// so once, in its own words.
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { AiProvider } from '@hanzo/ai/react'
|
||||
import { createAiClient, type HttpClient } from '@hanzo/ai'
|
||||
import { api } from '~/api'
|
||||
import { bearer, hasSession, org, orgs, work } from '~/token'
|
||||
|
||||
/** The tenant this browser works in, and the way to move to another. */
|
||||
export interface Tenant {
|
||||
/** The organization every read and write is scoped to, or null when the
|
||||
* account belongs to several and has not picked. */
|
||||
org: string | null
|
||||
/** Every organization the token carries. One is not a choice. */
|
||||
orgs: string[]
|
||||
choose: (org: string) => void
|
||||
}
|
||||
|
||||
const Who = createContext<Tenant>({ org: null, orgs: [], choose: () => {} })
|
||||
const Transport = createContext<HttpClient | null>(null)
|
||||
|
||||
/** The transport every read and write on this surface goes through, or null
|
||||
* when nobody is signed in. */
|
||||
export function useHttp(): HttpClient | null {
|
||||
return useContext(Transport)
|
||||
}
|
||||
|
||||
/** The tenant, and the switch. */
|
||||
export function useTenant(): Tenant {
|
||||
return useContext(Who)
|
||||
}
|
||||
|
||||
export function Client({ children }: { children: ReactNode }) {
|
||||
const { sdk, isAuthenticated } = useIam()
|
||||
// The selection is REACT STATE as well as a stored key. Storage events fire
|
||||
// in other tabs only, so a switch made here would move the key and leave this
|
||||
// tab's client scoped to the organization it was built with.
|
||||
const [picked, setPicked] = useState<string | null>(() => org())
|
||||
const mine = useMemo(() => orgs(), [isAuthenticated, picked])
|
||||
|
||||
const account = Boolean((isAuthenticated || hasSession()) && sdk)
|
||||
|
||||
const client = useMemo(
|
||||
() =>
|
||||
account && sdk
|
||||
? createAiClient({
|
||||
baseUrl: api(),
|
||||
headers: picked ? { 'X-Org-Id': picked } : {},
|
||||
auth: {
|
||||
...sdk,
|
||||
getValidAccessToken: async () => (await sdk.getValidAccessToken?.()) || bearer(),
|
||||
},
|
||||
})
|
||||
: null,
|
||||
[account, sdk, picked],
|
||||
)
|
||||
|
||||
const tenant = useMemo<Tenant>(
|
||||
() => ({
|
||||
org: picked,
|
||||
orgs: mine,
|
||||
choose: (pick) => {
|
||||
work(pick)
|
||||
setPicked(pick)
|
||||
},
|
||||
}),
|
||||
[picked, mine],
|
||||
)
|
||||
|
||||
// AiProvider as well as the transport context: @hanzo/ui's shared components
|
||||
// read the client from it, and a component that finds none renders its own
|
||||
// signed-out state rather than throwing.
|
||||
return (
|
||||
<Who.Provider value={tenant}>
|
||||
<Transport.Provider value={client ? client.http : null}>
|
||||
{client ? <AiProvider client={client}>{children}</AiProvider> : children}
|
||||
</Transport.Provider>
|
||||
</Who.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Signing in WITHOUT LOSING THE PAGE.
|
||||
*
|
||||
* A full-page redirect unloads the document, and with it the post somebody was
|
||||
* half way through writing. The popup keeps the document, so the draft is still
|
||||
* on screen when the account arrives. A browser that refuses the popup falls
|
||||
* back to the redirect, which is the only alternative there is.
|
||||
*
|
||||
* One function, so every Sign in on this surface behaves the same way.
|
||||
*/
|
||||
export interface Door {
|
||||
login: () => unknown
|
||||
loginPopup: () => Promise<unknown>
|
||||
}
|
||||
|
||||
/** True when the popup completed here; false when the browser was sent away. */
|
||||
export async function enter(door: Door): Promise<boolean> {
|
||||
try {
|
||||
await door.loginPopup()
|
||||
return true
|
||||
} catch {
|
||||
void door.login()
|
||||
return false
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
// The chrome: the column of screens on the left, and whichever one is open
|
||||
// beside it.
|
||||
//
|
||||
// The sidebar reads the SAME table the router does, so a screen is one entry in
|
||||
// `routes.tsx` and never two that drift.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Box, Text, XStack, YStack } from '@hanzo/ui'
|
||||
import { HanzoMark } from '@hanzo/ui/product'
|
||||
import { NavLink, Outlet } from 'react-router'
|
||||
|
||||
import { enter } from '~/enter'
|
||||
import { SCREENS } from '~/routes'
|
||||
import { useTenant } from '~/client'
|
||||
|
||||
/**
|
||||
* Which organization this browser works in.
|
||||
*
|
||||
* THE ORG IS THE TENANT. Every account and every post belongs to one: the social
|
||||
* API resolves it from the validated bearer and filters every query by it, so
|
||||
* switching here changes everything on every screen. A sole organization is not
|
||||
* a choice and is drawn as a name.
|
||||
*
|
||||
* The social API carries no scope finer than this. It has no project column and
|
||||
* its listings filter by state and network only, so an account or a post cannot
|
||||
* be filed under anything below the org — which is why there is no second
|
||||
* switch here pretending otherwise.
|
||||
*/
|
||||
function Tenant() {
|
||||
const { org, orgs, choose } = useTenant()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (orgs.length === 0) return null
|
||||
|
||||
if (orgs.length === 1) {
|
||||
return (
|
||||
<Text fontSize="$1" color="$quiet" px="$3" numberOfLines={1}>
|
||||
{orgs[0]}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$1">
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
width="100%"
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$2"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$2" color="$ink" flex={1} numberOfLines={1}>
|
||||
{org ?? 'Pick an organization'}
|
||||
</Text>
|
||||
<ChevronDown size={13} aria-hidden />
|
||||
</XStack>
|
||||
</Box>
|
||||
{open
|
||||
? orgs.map((one) => (
|
||||
<Box
|
||||
key={one}
|
||||
render="button"
|
||||
onClick={() => {
|
||||
choose(one)
|
||||
setOpen(false)
|
||||
}}
|
||||
width="100%"
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$2"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$2" color={one === org ? '$ink' : '$soft'} flex={1} numberOfLines={1}>
|
||||
{one}
|
||||
</Text>
|
||||
{one === org ? <Check size={12} aria-hidden /> : null}
|
||||
</XStack>
|
||||
</Box>
|
||||
))
|
||||
: null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who is signed in, and the way in or out. */
|
||||
function Account() {
|
||||
const door = useIam()
|
||||
const { user, isAuthenticated, logout } = door
|
||||
const name =
|
||||
(user as { displayName?: string; name?: string; email?: string } | null)?.displayName ||
|
||||
(user as { name?: string } | null)?.name ||
|
||||
(user as { email?: string } | null)?.email ||
|
||||
''
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => void enter(door)}
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Text fontSize="$2" color="$ink">
|
||||
Sign in
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$1" color="$quiet" px="$3" numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Box
|
||||
render="button"
|
||||
onClick={() => void logout()}
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$2"
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Text fontSize="$2" color="$soft">
|
||||
Sign out
|
||||
</Text>
|
||||
</Box>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function Frame() {
|
||||
return (
|
||||
<XStack className="shell" flex={1} minW={0} minH={0}>
|
||||
<YStack
|
||||
className="rail"
|
||||
width={232}
|
||||
shrink={0}
|
||||
minH={0}
|
||||
borderRightWidth={1}
|
||||
borderColor="$borderColor"
|
||||
py="$3"
|
||||
gap="$3"
|
||||
>
|
||||
<XStack items="center" gap="$2" px="$3">
|
||||
<HanzoMark size={18} />
|
||||
<Text fontSize="$3" fontWeight="500" color="$ink">
|
||||
Social
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<YStack className="ways" gap="$1" px="$2" flex={1} minH={0} overflow="scroll">
|
||||
{SCREENS.map(({ path, label }) => (
|
||||
// NavLink rather than a Box with a handler: a person who wants this
|
||||
// screen in another tab reaches for the middle button, and a button
|
||||
// calling navigate() has no address for them to reach.
|
||||
<NavLink key={path} to={path} end={path === '/'} style={{ textDecoration: 'none' }}>
|
||||
{({ isActive }) => (
|
||||
<Box
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$2"
|
||||
bg={isActive ? '$hover' : 'transparent'}
|
||||
hoverStyle={{ bg: '$hover' }}
|
||||
>
|
||||
<Text fontSize="$2" color={isActive ? '$ink' : '$soft'}>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<YStack className="whose" gap="$2" px="$1" shrink={0}>
|
||||
<Tenant />
|
||||
<Account />
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<YStack flex={1} minW={0} minH={0} overflow="scroll">
|
||||
<Outlet />
|
||||
</YStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Hanzo } from '@hanzo/ui'
|
||||
// THE FACES. Zen is authored in `@hanzo/font`, which ships the woff2 the
|
||||
// `@font-face` needs; `@hanzo/ui`'s theme names the family and nothing more, so
|
||||
// without this the `--font-sans` token resolves to a system fallback.
|
||||
import '@hanzo/font/css'
|
||||
// THE MATERIAL AND THE TOKENS. `theme.css` is @hanzo/design's sheet, the reset,
|
||||
// and the glass surfaces together.
|
||||
import '@hanzo/ui/theme.css'
|
||||
// THE RULES THE COMPONENTS ASSUME: the `min-width: 0` floor every Grid child
|
||||
// needs, stated once by the package rather than inline on each component.
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
// THIS APP'S OWN LAYOUT — the calendar grid and the media strip, which are the
|
||||
// two shapes no gui prop expresses.
|
||||
import '~/social.css'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { App } from '~/app'
|
||||
|
||||
/**
|
||||
* THE AMBIENT TELEMETRY CLIENT, OFF.
|
||||
*
|
||||
* `@hanzo/ui` depends on `@hanzogui/telemetry`, whose `track` builds a client
|
||||
* the first time any shared component reports an interaction — zero config,
|
||||
* pageviews on by default, and an absolute ingest address it carries itself.
|
||||
* With no key to send, the refusal is a console error on every visit.
|
||||
*
|
||||
* Module scope, not an effect: the ambient client is built on first use DURING
|
||||
* render, and this chunk is evaluated before any component runs.
|
||||
*/
|
||||
;(globalThis as { __HANZO_TELEMETRY__?: { enabled?: boolean } }).__HANZO_TELEMETRY__ = {
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* The mount.
|
||||
*
|
||||
* `<Hanzo>` IS the root: it mounts the gui provider with the config the
|
||||
* `$background` / `$ink` / `$borderColor` tokens resolve against. There is no
|
||||
* config file here to keep in step with the components — a second config is how
|
||||
* two surfaces of one product end up different sizes.
|
||||
*/
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Hanzo theme="dark">
|
||||
<App />
|
||||
</Hanzo>
|
||||
</StrictMode>,
|
||||
)
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
// What each network will accept, and whether this post satisfies it.
|
||||
//
|
||||
// WHICH NETWORKS EXIST IS NOT DECIDED HERE. `GET /v1/social/providers` answers
|
||||
// that, live, per deployment — so the composer draws the rows the platform
|
||||
// names and this file only says how each one composes. A network the platform
|
||||
// adds still works; it falls back to the platform's own caps until a rule for it
|
||||
// lands here.
|
||||
//
|
||||
// The rules themselves are each network's OWN published limits, as this app
|
||||
// understands them. No endpoint serves them, and none is claimed to: they are
|
||||
// checked in the browser BEFORE the post is stored so a person learns that a
|
||||
// caption is 40 characters too long while they can still cut it, rather than
|
||||
// from a publish that fails later. A network can change its own rules, and when
|
||||
// one does the correction is this table.
|
||||
//
|
||||
// Bluesky is not here because the platform does not carry it: the social API's
|
||||
// channel vocabulary is x, facebook, instagram, linkedin, tiktok, youtube and
|
||||
// threads, and a post created with any other channel is refused. A network this
|
||||
// app drew but could not post to would be a control wired to nothing.
|
||||
|
||||
/** The platform's own cap on how many media URLs one post may carry. */
|
||||
export const MEDIA = 10
|
||||
|
||||
/** What a network needs on a post before it will take it. */
|
||||
export type Needs = 'nothing' | 'media' | 'video'
|
||||
|
||||
export interface Rule {
|
||||
/** What to call the network on screen. */
|
||||
name: string
|
||||
/** The network's cap on the post's text, in characters. */
|
||||
text: number
|
||||
/** What the network requires beyond the text. */
|
||||
needs: Needs
|
||||
/** How many videos the network takes in one post. Zero means none. */
|
||||
videos: number
|
||||
/** How many images the network takes in one post. */
|
||||
images: number
|
||||
/** Whether video and photos may travel together. Several networks take both
|
||||
* kinds but not in the same post — the video becomes the whole post. */
|
||||
mix: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Each network's own limits.
|
||||
*
|
||||
* Every one of these takes video — which is why the composer treats video as
|
||||
* ordinary content rather than a second kind of post — and two of them take
|
||||
* nothing else: on TikTok and YouTube the video IS the post.
|
||||
*
|
||||
* `mix: false` is the rule that catches most people out. LinkedIn, TikTok and
|
||||
* YouTube all accept photos and all accept video, but never in one post: attach
|
||||
* a video and it becomes the whole post. A composer that let the two be
|
||||
* combined would produce a post the network refuses at the last moment.
|
||||
*/
|
||||
const RULES: Record<string, Rule> = {
|
||||
x: { name: 'X', text: 280, needs: 'nothing', videos: 1, images: 4, mix: true },
|
||||
facebook: { name: 'Facebook', text: 63206, needs: 'nothing', videos: 1, images: MEDIA, mix: true },
|
||||
instagram: { name: 'Instagram', text: 2200, needs: 'media', videos: MEDIA, images: MEDIA, mix: true },
|
||||
linkedin: { name: 'LinkedIn', text: 3000, needs: 'nothing', videos: 1, images: 9, mix: false },
|
||||
tiktok: { name: 'TikTok', text: 2200, needs: 'media', videos: 1, images: MEDIA, mix: false },
|
||||
youtube: { name: 'YouTube', text: 5000, needs: 'video', videos: 1, images: 0, mix: false },
|
||||
threads: { name: 'Threads', text: 500, needs: 'nothing', videos: 1, images: MEDIA, mix: true },
|
||||
}
|
||||
|
||||
/**
|
||||
* How this network composes.
|
||||
*
|
||||
* A network the table has not met falls back to the platform's own caps — 8192
|
||||
* characters and 10 media — because those are the only limits that are certainly
|
||||
* true of it. Guessing tighter would refuse a post the network would have taken.
|
||||
*/
|
||||
export function rule(provider: string): Rule {
|
||||
return (
|
||||
RULES[provider] ?? {
|
||||
name: provider,
|
||||
text: 8192,
|
||||
needs: 'nothing',
|
||||
videos: MEDIA,
|
||||
images: MEDIA,
|
||||
mix: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export type Kind = 'image' | 'video' | 'unknown'
|
||||
|
||||
const VIDEO = /\.(mp4|mov|m4v|webm|mkv|avi|mpe?g|3gp)(\?|#|$)/i
|
||||
const IMAGE = /\.(jpe?g|png|gif|webp|avif|heic|heif|bmp|tiff?)(\?|#|$)/i
|
||||
|
||||
/**
|
||||
* What a media URL points at.
|
||||
*
|
||||
* Read off the address, because that is all there is to read: the post carries
|
||||
* URLs and nothing fetches them here. An address that names neither is
|
||||
* `unknown`, and the composer says so rather than assuming — guessing "image"
|
||||
* is how a video ends up refused by a network that would have taken it, and
|
||||
* guessing "video" is how a YouTube post goes out with a JPEG.
|
||||
*/
|
||||
export function kind(url: string): Kind {
|
||||
const path = url.split(/[?#]/)[0] ?? ''
|
||||
if (VIDEO.test(path)) return 'video'
|
||||
if (IMAGE.test(path)) return 'image'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* What stops this post going to this network, in the person's words. Empty
|
||||
* means the network will take it.
|
||||
*
|
||||
* These are refusals the network itself would make. The composer will not send
|
||||
* a post that collects any — not because the platform would refuse it (it
|
||||
* stores what it is given) but because the refusal would otherwise arrive as a
|
||||
* failed publish hours later, with the draft already gone.
|
||||
*/
|
||||
export function check(provider: string, content: string, media: string[]): string[] {
|
||||
const it = rule(provider)
|
||||
const said: string[] = []
|
||||
const videos = media.filter((m) => kind(m) === 'video')
|
||||
const images = media.filter((m) => kind(m) === 'image')
|
||||
const strange = media.filter((m) => kind(m) === 'unknown')
|
||||
|
||||
if (!content.trim() && media.length === 0) said.push('There is nothing to post.')
|
||||
if (content.length > it.text)
|
||||
said.push(`${content.length - it.text} characters over ${it.name}'s limit of ${it.text}.`)
|
||||
if (media.length > MEDIA) said.push(`${MEDIA} media items at most.`)
|
||||
if (it.needs === 'media' && media.length === 0)
|
||||
said.push(`${it.name} needs a photo or a video.`)
|
||||
if (it.needs === 'video' && videos.length === 0)
|
||||
said.push(`${it.name} needs a video — the video is the post.`)
|
||||
if (videos.length > it.videos)
|
||||
said.push(
|
||||
it.videos === 0
|
||||
? `${it.name} does not take video.`
|
||||
: `${it.name} takes ${it.videos} video${it.videos === 1 ? '' : 's'}, not ${videos.length}.`,
|
||||
)
|
||||
if (!it.mix && videos.length > 0 && images.length > 0)
|
||||
said.push(`On ${it.name} a video is the whole post — it cannot carry photos as well.`)
|
||||
if (images.length > it.images)
|
||||
said.push(
|
||||
it.images === 0
|
||||
? `${it.name} does not take photos.`
|
||||
: `${it.name} takes ${it.images} photo${it.images === 1 ? '' : 's'}, not ${images.length}.`,
|
||||
)
|
||||
if (strange.length > 0)
|
||||
said.push(
|
||||
`The address ${strange[0]} does not say whether it is a photo or a video, so ${it.name}'s rules cannot be checked against it.`,
|
||||
)
|
||||
return said
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// The shapes every screen shares: the heading, the state where nobody is signed
|
||||
// in, and the way a refusal is reported.
|
||||
//
|
||||
// Six screens would otherwise grow six versions of each, and six versions of
|
||||
// "this did not come back" are six different products in one window.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useIam } from '@hanzo/iam/react'
|
||||
import { Box, Text, XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
import { enter } from '~/enter'
|
||||
import { useTenant } from '~/client'
|
||||
|
||||
/** One screen: a heading, a sentence saying what it is for, and the screen. */
|
||||
export function Screen({
|
||||
title,
|
||||
says,
|
||||
beside,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
says: string
|
||||
/** A control that belongs with the heading rather than in the body. */
|
||||
beside?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<YStack flex={1} minH={0} gap="$4" p="$5">
|
||||
<XStack items="flex-start" gap="$3" flexWrap="wrap">
|
||||
<YStack gap="$1" flex={1} minW={220}>
|
||||
<Text render="h1" fontSize="$6" fontWeight="600" color="$ink">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$soft">
|
||||
{says}
|
||||
</Text>
|
||||
</YStack>
|
||||
{beside}
|
||||
</XStack>
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A plain control. One shape, so every button on this surface is one button. */
|
||||
export function Act({
|
||||
onPress,
|
||||
children,
|
||||
disabled,
|
||||
loud,
|
||||
}: {
|
||||
onPress: () => void
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
/**
|
||||
* The one control on the screen a person came to press.
|
||||
*
|
||||
* INVERTED, which on a monochrome palette is the loudest thing there is — so
|
||||
* it is spent once per screen. A second loud control is two firsts, and then
|
||||
* neither reads as the thing to do.
|
||||
*/
|
||||
loud?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
render="button"
|
||||
onClick={disabled ? undefined : onPress}
|
||||
aria-disabled={disabled}
|
||||
px="$3"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor={loud ? '$ink' : '$borderColor'}
|
||||
bg={loud ? '$ink' : 'transparent'}
|
||||
opacity={disabled ? 0.4 : 1}
|
||||
hoverStyle={disabled ? {} : { bg: loud ? '$ink' : '$hover' }}
|
||||
>
|
||||
<Text fontSize="$2" color={loud ? '$background' : '$ink'} fontWeight={loud ? '500' : '400'}>
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why something did not come back, in the person's words.
|
||||
*
|
||||
* The platform's own sentence is kept — a 503 from the publish edge names the
|
||||
* exact credentials a deployment is missing, and that is the useful part —
|
||||
* prefixed with what was being read so the sentence has a subject.
|
||||
*/
|
||||
export function Failed({ what, why }: { what: string; why: string }) {
|
||||
return (
|
||||
<YStack gap="$1" p="$3" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" color="$ink">
|
||||
Could not load {what}.
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$soft">
|
||||
{why}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing here yet, and what would put something here. */
|
||||
export function Nothing({ says }: { says: string }) {
|
||||
return (
|
||||
<YStack items="center" justify="center" p="$6">
|
||||
<Text fontSize="$2" color="$quiet" text="center">
|
||||
{says}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen, once there is somebody to read for and a tenant to read in.
|
||||
*
|
||||
* Every /v1/social route refuses a caller with no validated principal, so a
|
||||
* signed-out surface has nothing to show and no useful request to make. It says
|
||||
* so once, here, instead of six screens each filling with the same refusal.
|
||||
*
|
||||
* An account that belongs to several organizations and has picked none is the
|
||||
* other half of the same state: the requests would go out unscoped and the
|
||||
* gateway would answer for whichever org the token's owner claim names, which is
|
||||
* nobody's to work in.
|
||||
*/
|
||||
export function Ready({ children }: { children: ReactNode }) {
|
||||
const door = useIam()
|
||||
const { org, orgs } = useTenant()
|
||||
const { isAuthenticated, isLoading } = door
|
||||
|
||||
if (isLoading) return <YStack flex={1} />
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<YStack flex={1} items="center" justify="center" gap="$3" p="$6">
|
||||
<Text render="h1" fontSize="$6" fontWeight="600" color="$ink">
|
||||
Hanzo Social
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$soft" text="center" maxW={420}>
|
||||
Write once, and publish to every account your organization owns — now,
|
||||
or at a time you pick.
|
||||
</Text>
|
||||
<Act onPress={() => void enter(door)} loud>
|
||||
Sign in
|
||||
</Act>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
if (!org && orgs.length > 1) {
|
||||
return (
|
||||
<YStack flex={1} items="center" justify="center" gap="$2" p="$6">
|
||||
<Text fontSize="$3" color="$ink">
|
||||
Pick an organization
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$soft" text="center" maxW={420}>
|
||||
Accounts and posts belong to one organization. Choose which one you are
|
||||
working in, at the bottom of the column on the left.
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
// ONE POST, AS EVERY PANE SHOWS IT.
|
||||
//
|
||||
// The overview lists it, the posts screen lists it with its controls, and the
|
||||
// calendar puts it on a day and then wants the same controls beside it. Three
|
||||
// copies of "what a post looks like" drift into three products, so the row and
|
||||
// the controls live here and each screen mounts them.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/ui'
|
||||
|
||||
import { Act } from '~/page'
|
||||
import { Mark, tone } from '~/status'
|
||||
import { kind, rule } from '~/network'
|
||||
import { at, fromField, since, toField } from '~/time'
|
||||
import { useHttp } from '~/client'
|
||||
import { drop, edit, publish, why, type Post } from '~/social'
|
||||
|
||||
/** The post's words, or an honest stand-in for a post that is only media. */
|
||||
export const said = (post: Post): string => post.content || '(no words — media only)'
|
||||
|
||||
/** What a post carries, as one line. */
|
||||
export function Carries({ post }: { post: Post }) {
|
||||
const videos = post.media.filter((m) => kind(m) === 'video').length
|
||||
const images = post.media.length - videos
|
||||
if (!post.media.length) return null
|
||||
return (
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{images ? `${images} photo${images === 1 ? '' : 's'}` : ''}
|
||||
{images && videos ? ' · ' : ''}
|
||||
{videos ? `${videos} video${videos === 1 ? '' : 's'}` : ''}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** One post, as a list reads it. */
|
||||
export function Row({ post }: { post: Post }) {
|
||||
return (
|
||||
<YStack gap="$2" py="$3" borderBottomWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$3">
|
||||
<Text fontSize="$2" color="$ink" flex={1} minW={0} numberOfLines={1}>
|
||||
{said(post)}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{rule(post.channel).name}
|
||||
</Text>
|
||||
<Mark of={tone(post.status)} says={post.status} />
|
||||
</XStack>
|
||||
{post.error ? (
|
||||
<Text fontSize="$1" color="$ink" fontWeight="500">
|
||||
{post.error}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{post.status === 'scheduled' && post.scheduleAt
|
||||
? `Due ${since(post.scheduleAt)}`
|
||||
: `Changed ${since(post.updatedAt)}`}
|
||||
</Text>
|
||||
<Carries post={post} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three things a person can do to a stored post, and nothing else.
|
||||
*
|
||||
* WHAT IS HERE IS WHAT THE PLATFORM SERVES: move it in time (`PUT`), send it now
|
||||
* (`POST …/publish`), forget it (`DELETE`). A post that has already gone out is
|
||||
* offered only the last of those — the platform has no unpublish, and a control
|
||||
* that pretended otherwise would be a lie about a post already on a network.
|
||||
*
|
||||
* A refusal is shown VERBATIM. The publish edge answers 503 naming the exact
|
||||
* credentials this deployment is missing, and paraphrasing that would throw away
|
||||
* the only part anyone can act on.
|
||||
*/
|
||||
export function Controls({ post, again }: { post: Post; again: () => void }) {
|
||||
const http = useHttp()
|
||||
const [busy, setBusy] = useState('')
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
const [when, setWhen] = useState(toField(post.scheduleAt))
|
||||
|
||||
const run = async (what: string, go: () => Promise<unknown>) => {
|
||||
if (!http || busy) return
|
||||
setBusy(what)
|
||||
setFailed(null)
|
||||
try {
|
||||
await go()
|
||||
again()
|
||||
} catch (e) {
|
||||
setFailed(why(e))
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const pending = post.status !== 'published'
|
||||
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
{pending ? (
|
||||
<>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={when}
|
||||
onChange={(e) => setWhen(e.target.value)}
|
||||
aria-label="When this post is due"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 10px',
|
||||
outline: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
fontSize: 12,
|
||||
colorScheme: 'dark light',
|
||||
}}
|
||||
/>
|
||||
<Act
|
||||
onPress={() =>
|
||||
void run('when', () =>
|
||||
edit(http!, post.id, {
|
||||
scheduleAt: fromField(when),
|
||||
status: fromField(when) ? 'scheduled' : 'draft',
|
||||
}),
|
||||
)
|
||||
}
|
||||
disabled={busy !== '' || toField(post.scheduleAt) === when}
|
||||
>
|
||||
{busy === 'when' ? 'Saving…' : fromField(when) ? 'Reschedule' : 'Unschedule'}
|
||||
</Act>
|
||||
{/*
|
||||
LOUD ONLY WHERE SOMETHING IS WRONG. Sending early is an option on
|
||||
a queued post and the thing to do on a failed one, and a list that
|
||||
inverts every row spends the loudest thing the palette has six
|
||||
times on one screen — after which the failure has nothing left to
|
||||
be louder than.
|
||||
*/}
|
||||
<Act
|
||||
onPress={() => void run('publish', () => publish(http!, post.id))}
|
||||
disabled={busy !== ''}
|
||||
loud={post.status === 'failed'}
|
||||
>
|
||||
{busy === 'publish' ? 'Sending…' : post.status === 'failed' ? 'Try again' : 'Publish now'}
|
||||
</Act>
|
||||
</>
|
||||
) : null}
|
||||
<Act onPress={() => void run('drop', () => drop(http!, post.id))} disabled={busy !== ''}>
|
||||
{busy === 'drop' ? 'Deleting…' : 'Delete'}
|
||||
</Act>
|
||||
</XStack>
|
||||
{failed || post.error ? (
|
||||
<Text fontSize="$1" color="$ink" fontWeight="500">
|
||||
{failed ?? post.error}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One post with its controls — the shape the posts screen and the calendar
|
||||
* both put on screen. */
|
||||
export function Full({ post, again }: { post: Post; again: () => void }) {
|
||||
return (
|
||||
<YStack gap="$2" py="$3" borderBottomWidth={1} borderColor="$borderColor">
|
||||
<XStack items="flex-start" gap="$3">
|
||||
<Text fontSize="$2" color="$ink" flex={1} minW={0} numberOfLines={3}>
|
||||
{said(post)}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{rule(post.channel).name}
|
||||
</Text>
|
||||
<Mark of={tone(post.status)} says={post.status} />
|
||||
</XStack>
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<Text fontSize="$1" color="$quiet">
|
||||
{post.status === 'scheduled' && post.scheduleAt
|
||||
? `Due ${at(post.scheduleAt)} · ${since(post.scheduleAt)}`
|
||||
: `Changed ${since(post.updatedAt)}`}
|
||||
{post.externalId ? ` · ${post.externalId}` : ''}
|
||||
</Text>
|
||||
<Carries post={post} />
|
||||
</XStack>
|
||||
<Controls post={post} again={again} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Who this browser is, and the client that speaks for them — the layout route
|
||||
// every screen sits under.
|
||||
//
|
||||
// A LAYOUT ROUTE rather than a wrapper around the router, because the callback
|
||||
// screen reads the session and needs the router above it while the session
|
||||
// needs to be above the screen. Mounting this as the route every screen sits
|
||||
// under satisfies both with one tree instead of two.
|
||||
|
||||
import { IamProvider, useIam } from '@hanzo/iam/react'
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet } from 'react-router'
|
||||
|
||||
import { Client } from '~/client'
|
||||
import { own, subject } from '~/token'
|
||||
|
||||
/**
|
||||
* WHERE THIS BROWSER SIGNS IN, derived once and read by everyone who needs it.
|
||||
*
|
||||
* The client is `hanzo-social`, this application's name under the estate's
|
||||
* `<org>-<app>` scheme, so the org is the name's first word rather than a
|
||||
* second table to forget to update. A code is bound to the client that asked
|
||||
* for it, so two derivations would be two clients and the exchange would be
|
||||
* refused outright.
|
||||
*/
|
||||
export function origin(): {
|
||||
serverUrl: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
organization: string
|
||||
} {
|
||||
const clientId = import.meta.env.VITE_HANZO_CLIENT_ID || 'hanzo-social'
|
||||
return {
|
||||
serverUrl: (import.meta.env.VITE_HANZO_IAM || 'https://hanzo.id').replace(/\/+$/, ''),
|
||||
clientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
organization: clientId.split('-')[0]!,
|
||||
}
|
||||
}
|
||||
|
||||
/** Local state follows the session: once the SDK has settled, whoever the
|
||||
* stored token names — or nobody — owns the `hanzo*` keys. */
|
||||
function Identity() {
|
||||
const { isLoading, isAuthenticated } = useIam()
|
||||
useEffect(() => {
|
||||
if (!isLoading) own(subject())
|
||||
}, [isLoading, isAuthenticated])
|
||||
return null
|
||||
}
|
||||
|
||||
export function Root() {
|
||||
// Before any surface reads storage: a browser another person left behind is
|
||||
// emptied of their organization, so nobody composes into a tenant that is not
|
||||
// theirs.
|
||||
own(subject())
|
||||
return (
|
||||
<IamProvider config={{ ...origin(), postLogoutRedirectUri: `${window.location.origin}/` }}>
|
||||
<Identity />
|
||||
<Client>
|
||||
<Outlet />
|
||||
</Client>
|
||||
</IamProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createBrowserRouter } from 'react-router'
|
||||
|
||||
import { Accounts } from '~/Accounts'
|
||||
import { Analytics } from '~/Analytics'
|
||||
import { Calendar } from '~/Calendar'
|
||||
import { Callback } from '~/callback'
|
||||
import { Compose } from '~/Compose'
|
||||
import { Frame } from '~/frame'
|
||||
import { Overview } from '~/Overview'
|
||||
import { Posts } from '~/Posts'
|
||||
import { Root } from '~/root'
|
||||
|
||||
/**
|
||||
* Every address this app answers, in one table — and the same table the sidebar
|
||||
* reads, so a screen is one entry rather than two that drift.
|
||||
*
|
||||
* `/auth/callback` is where the issuer returns a browser, and it is the address
|
||||
* `origin()` registers, so it is a route of its own and NOT under the frame:
|
||||
* the chrome would draw a sidebar around a screen whose whole job is to
|
||||
* redirect.
|
||||
*/
|
||||
export const SCREENS = [
|
||||
{ path: '/', label: 'Overview', element: <Overview /> },
|
||||
{ path: '/compose', label: 'Compose', element: <Compose /> },
|
||||
{ path: '/posts', label: 'Posts', element: <Posts /> },
|
||||
{ path: '/calendar', label: 'Calendar', element: <Calendar /> },
|
||||
{ path: '/accounts', label: 'Accounts', element: <Accounts /> },
|
||||
{ path: '/analytics', label: 'Analytics', element: <Analytics /> },
|
||||
] as const
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
element: <Root />,
|
||||
children: [
|
||||
{ path: '/auth/callback', element: <Callback /> },
|
||||
{
|
||||
element: <Frame />,
|
||||
children: [
|
||||
...SCREENS.map(({ path, element }) => ({ path, element })),
|
||||
// An address nobody publishes lands on the overview rather than on a
|
||||
// page about the address being wrong. There is one product here and
|
||||
// the overview is where a person can see all of it.
|
||||
{ path: '*', element: <Overview /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/* This app's own rules — the ones no package ships, because they are its layout
|
||||
rather than a library's components.
|
||||
|
||||
Everything else comes from packages. `@hanzo/ui/theme.css` is the design
|
||||
tokens, the reset and the material; `@hanzo/ui/styles/motion.css` is the rules
|
||||
its components assume; gui inserts the atomic rules for this app's own props
|
||||
at first render. Nothing here restates a token and nothing here is a utility —
|
||||
a rule belongs in this file only if it addresses a class this app writes.
|
||||
Two do. */
|
||||
|
||||
/* THE MONTH. A calendar is a seven-column grid with equal columns and rows that
|
||||
grow with what is on the day, which is the one shape a flex row cannot state:
|
||||
flex sizes children to their content, and a week of days sized to their posts
|
||||
is a week of unequal days. `minmax(0, 1fr)` rather than `1fr` so a long
|
||||
network name cannot push a column wider than its share. */
|
||||
.week,
|
||||
.month {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.day {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
/* On a narrow window the seven columns become one: a 40px-wide Tuesday holds
|
||||
nothing, and a month a thumb can scroll is a month a thumb can read. */
|
||||
@media (max-width: 860px) {
|
||||
.week {
|
||||
display: none;
|
||||
}
|
||||
.month {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
:root .day.day {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* THE LOOK AT A MEDIA ADDRESS. The composer frames a photo and a video in the
|
||||
same box so a strip of attachments reads as one row rather than as whatever
|
||||
sizes the files happen to be. `contain` rather than `cover`: this is a check
|
||||
that the right file is attached, and a crop can hide that it is not. */
|
||||
.media {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 76px;
|
||||
background: var(--panel, rgb(255 255 255 / 0.04));
|
||||
}
|
||||
|
||||
.media > img,
|
||||
.media > video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* THE SHELL, NARROW. Two columns need two columns' worth of room: a 232px rail
|
||||
beside a pane leaves a phone about 180px to read in, which is not a pane. So
|
||||
below the breakpoint the rail becomes a bar across the top — the ways across
|
||||
it, the account beside them — and the pane takes the whole width under it.
|
||||
|
||||
In CSS rather than in a breakpoint the frame carries, because this is the one
|
||||
fact the layout has that React does not need to know: a component that
|
||||
re-renders on a resize is a component that re-renders on a phone's address
|
||||
bar sliding away. */
|
||||
/* `:root` AND A DOUBLED CLASS IS NOT A TYPO. gui writes the rule for a prop —
|
||||
`width={232}` — as `:root ._width-232px`, and inserts it at first render,
|
||||
which is after this stylesheet. That selector weighs the same as `.rail.rail`,
|
||||
and equal weight is decided by source order, so gui wins. One step above it
|
||||
takes the prop back without `!important`, which would win against everything
|
||||
including whoever adjusts this next. */
|
||||
@media (max-width: 720px) {
|
||||
:root .shell.shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
:root .rail.rail {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
:root .ways.ways {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
:root .ways.ways > * {
|
||||
flex: none;
|
||||
}
|
||||
:root .whose.whose {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
// The platform, as this app speaks it. Every `/v1` address on this surface is
|
||||
// written here once and nowhere else, so a screen asks for posts rather than
|
||||
// building a path, and a route that moves is one edit.
|
||||
//
|
||||
// TWO PLANES, and they are not the same thing.
|
||||
//
|
||||
// /v1/social the org's channels and the posts it publishes to them.
|
||||
// An ACCOUNT here is a publish target — a network and a
|
||||
// handle. A POST is content on one channel, with a state.
|
||||
//
|
||||
// /v1/integration the org's CREDENTIAL for a network: the OAuth consent a
|
||||
// person gives once, sealed into KMS and never returned.
|
||||
// This is where `Connect` actually leads.
|
||||
//
|
||||
// A network needs both before anything goes out, and the Accounts screen shows
|
||||
// them side by side rather than folding one into the other — they are separately
|
||||
// true, and a screen that merged them would have to lie about which was missing.
|
||||
//
|
||||
// NO CREDENTIAL EVER REACHES THIS CODE. `GET /v1/integration/connectors/:id/token`
|
||||
// is the one custody exit the platform offers and nothing here calls it: a
|
||||
// browser has no use for a provider token, and a surface that held one would be
|
||||
// a place to leak it from. The publisher reads it server-side.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { HttpClient } from '@hanzo/ai'
|
||||
|
||||
// ── what the platform answers ────────────────────────────────────────────────
|
||||
|
||||
/** A publish target: one network, one handle. */
|
||||
export interface Account {
|
||||
id: string
|
||||
/** x, facebook, instagram, linkedin, tiktok, youtube or threads. */
|
||||
provider: string
|
||||
handle: string
|
||||
/** connected, disconnected or error. Only a connected account is a target. */
|
||||
status: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Content on one channel, and where it got to. */
|
||||
export interface Post {
|
||||
id: string
|
||||
content: string
|
||||
/** The network this post targets. A post has ONE. */
|
||||
channel: string
|
||||
/** draft, scheduled, published or failed. `publishing` is a transient claim a
|
||||
* reader sees only mid-attempt; it is never settable from here. */
|
||||
status: string
|
||||
/** Unix SECONDS, and meaningful only while the status is scheduled. */
|
||||
scheduleAt: number
|
||||
/** Media as addresses. Always an array. */
|
||||
media: string[]
|
||||
/** Set by the publish path, never by an update: which account it went through,
|
||||
* the id the network gave it back, and why the last attempt failed. */
|
||||
accountId?: string
|
||||
externalId?: string
|
||||
error?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One network's publish-readiness on THIS deployment. */
|
||||
export interface Network {
|
||||
provider: string
|
||||
/** Whether the deployment holds the network's OAuth app credentials. It says
|
||||
* nothing about whether this org has connected an account. */
|
||||
credentialsConfigured: boolean
|
||||
/** The environment variables still unset, by NAME. Never a value. */
|
||||
missingCredentials?: string[]
|
||||
}
|
||||
|
||||
/** The org's roll-up. Four counts, one read. */
|
||||
export interface Summary {
|
||||
accounts: number
|
||||
posts: number
|
||||
published: number
|
||||
scheduled: number
|
||||
}
|
||||
|
||||
/** A network's connector, and this org's standing with it. */
|
||||
export interface Connection {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
/** Whether the DEPLOYMENT holds this connector's app credentials. */
|
||||
available: boolean
|
||||
/** Whether THIS ORG has given consent. */
|
||||
connected: boolean
|
||||
connection?: {
|
||||
account: string
|
||||
externalId: string
|
||||
scopes: string[]
|
||||
connectedAt: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the platform refused, in ITS words.
|
||||
*
|
||||
* The platform answers RFC 7807: the actionable sentence is `detail` — "youtube
|
||||
* needs YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET (set as deployment env) plus a
|
||||
* connected account access token". The SDK's own extractor looks for `msg` and
|
||||
* `error`, finds neither, and falls back to a status-code phrase: "The service
|
||||
* is not running right now." That phrase is true and useless — it names nothing
|
||||
* anybody can supply.
|
||||
*
|
||||
* So the refusal is read off the error's BODY, which `APIError` carries whole,
|
||||
* and the SDK's message is the fallback rather than the answer. One function,
|
||||
* because a screen that reported a refusal any other way would report a
|
||||
* different product's refusal.
|
||||
*/
|
||||
export function why(e: unknown): string {
|
||||
const body = (e as { body?: unknown } | null)?.body
|
||||
if (body && typeof body === 'object') {
|
||||
const detail = (body as { detail?: unknown }).detail
|
||||
if (typeof detail === 'string' && detail.trim()) return detail
|
||||
}
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
// ── reading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Read<T> {
|
||||
/** Null until the first answer arrives. */
|
||||
it: T | null
|
||||
failed: string | null
|
||||
again: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One read, with its loading and its failure.
|
||||
*
|
||||
* Every screen wants the same three things and would otherwise grow its own
|
||||
* copy, and three copies of "did this come back yet" disagree at the edges. The
|
||||
* client is the dependency: it is rebuilt when the organization changes, so a
|
||||
* switch re-reads everything without a screen having to know that.
|
||||
*
|
||||
* `live` rather than an abort: an answer that arrives after the screen moved on
|
||||
* is discarded instead of setting state on a component that is gone.
|
||||
*/
|
||||
function useRead<T>(
|
||||
http: HttpClient | null,
|
||||
get: (http: HttpClient) => Promise<T>,
|
||||
deps: unknown[],
|
||||
): Read<T> {
|
||||
const [it, setIt] = useState<T | null>(null)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
const [turn, setTurn] = useState(0)
|
||||
|
||||
// The caller's `get` closes over its own arguments and is a new function every
|
||||
// render; the deps it was built from are what this actually depends on.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const fetchIt = useCallback(get, deps)
|
||||
|
||||
useEffect(() => {
|
||||
if (!http) return
|
||||
let live = true
|
||||
setFailed(null)
|
||||
fetchIt(http)
|
||||
.then((got) => live && setIt(got))
|
||||
.catch((e: unknown) => live && setFailed(why(e)))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [http, fetchIt, turn])
|
||||
|
||||
return { it, failed, again: () => setTurn((t) => t + 1) }
|
||||
}
|
||||
|
||||
/** The org's roll-up: GET /v1/social/summary. */
|
||||
export function useSummary(http: HttpClient | null): Read<Summary> {
|
||||
return useRead(http, (h) => h.json<Summary>({ path: '/v1/social/summary' }), [])
|
||||
}
|
||||
|
||||
/** Which networks this deployment can publish to: GET /v1/social/providers.
|
||||
* The live list, not a constant — it is per deployment. */
|
||||
export function useNetworks(http: HttpClient | null): Read<Network[]> {
|
||||
return useRead(http, (h) => h.collection<Network>('data', { path: '/v1/social/providers' }), [])
|
||||
}
|
||||
|
||||
/** The org's publish targets: GET /v1/social/accounts. */
|
||||
export function useAccounts(http: HttpClient | null): Read<Account[]> {
|
||||
return useRead(http, (h) => h.collection<Account>('data', { path: '/v1/social/accounts' }), [])
|
||||
}
|
||||
|
||||
/**
|
||||
* The org's posts: GET /v1/social/posts, newest-updated first.
|
||||
*
|
||||
* `status` narrows to one state — draft, scheduled, published or failed — and
|
||||
* the whole listing is one call, so a screen that wants several states reads
|
||||
* once and sorts here rather than making four requests.
|
||||
*/
|
||||
export function usePosts(http: HttpClient | null, status?: string): Read<Post[]> {
|
||||
return useRead(
|
||||
http,
|
||||
(h) =>
|
||||
h.collection<Post>('data', {
|
||||
path: '/v1/social/posts',
|
||||
query: status ? { status } : {},
|
||||
}),
|
||||
[status],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The org's credential standing, network by network: GET /v1/integration.
|
||||
*
|
||||
* The catalog carries every connector the platform knows — Slack, GitHub, the
|
||||
* rest — so it is narrowed to the networks the social API will actually publish
|
||||
* to. A card for a connector no post can name is a control wired to nothing.
|
||||
*/
|
||||
export function useConnections(
|
||||
http: HttpClient | null,
|
||||
networks: string[],
|
||||
): Read<Connection[]> {
|
||||
const only = networks.join(',')
|
||||
return useRead(
|
||||
http,
|
||||
async (h) => {
|
||||
const all = await h.collection<Connection>('providers', { path: '/v1/integration' })
|
||||
const want = new Set(only ? only.split(',') : [])
|
||||
return all.filter((one) => want.has(one.id))
|
||||
},
|
||||
[only],
|
||||
)
|
||||
}
|
||||
|
||||
// ── writing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** What a post is made of, as the composer holds it. */
|
||||
export interface Draft {
|
||||
content: string
|
||||
channel: string
|
||||
media: string[]
|
||||
/** Unix SECONDS. 0 is unscheduled. */
|
||||
scheduleAt: number
|
||||
/** draft or scheduled. A post is never CREATED published — publishing is what
|
||||
* the publish route and the scheduler do. */
|
||||
status: string
|
||||
}
|
||||
|
||||
/** Store a post: POST /v1/social/posts. A post scheduled for a time already
|
||||
* past is published immediately, and the row that comes back says so. */
|
||||
export function write(http: HttpClient, draft: Draft): Promise<Post> {
|
||||
return http.json<Post>({ method: 'POST', path: '/v1/social/posts', body: draft })
|
||||
}
|
||||
|
||||
/** Change a stored post: PUT /v1/social/posts/:id. */
|
||||
export function edit(http: HttpClient, id: string, draft: Partial<Draft>): Promise<Post> {
|
||||
return http.json<Post>({
|
||||
method: 'PUT',
|
||||
path: `/v1/social/posts/${encodeURIComponent(id)}`,
|
||||
body: draft,
|
||||
})
|
||||
}
|
||||
|
||||
/** Forget a post: DELETE /v1/social/posts/:id. */
|
||||
export function drop(http: HttpClient, id: string): Promise<unknown> {
|
||||
return http.json({ method: 'DELETE', path: `/v1/social/posts/${encodeURIComponent(id)}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send it now: POST /v1/social/posts/:id/publish.
|
||||
*
|
||||
* This is the one call on this surface that can answer 503, and the answer is
|
||||
* the useful part: no deployment carries the networks' OAuth app credentials
|
||||
* yet, so the platform reports exactly which are missing rather than reporting
|
||||
* a success that did not happen. The screens show that sentence verbatim.
|
||||
*/
|
||||
export function publish(http: HttpClient, id: string): Promise<Post> {
|
||||
return http.json<Post>({
|
||||
method: 'POST',
|
||||
path: `/v1/social/posts/${encodeURIComponent(id)}/publish`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Record a publish target: POST /v1/social/accounts. */
|
||||
export function addAccount(
|
||||
http: HttpClient,
|
||||
it: { provider: string; handle: string },
|
||||
): Promise<Account> {
|
||||
return http.json<Account>({ method: 'POST', path: '/v1/social/accounts', body: it })
|
||||
}
|
||||
|
||||
/** Change a target's handle or connection state: PUT /v1/social/accounts/:id. */
|
||||
export function editAccount(
|
||||
http: HttpClient,
|
||||
id: string,
|
||||
it: { handle?: string; status?: string },
|
||||
): Promise<Account> {
|
||||
return http.json<Account>({
|
||||
method: 'PUT',
|
||||
path: `/v1/social/accounts/${encodeURIComponent(id)}`,
|
||||
body: it,
|
||||
})
|
||||
}
|
||||
|
||||
/** Forget a target: DELETE /v1/social/accounts/:id. */
|
||||
export function dropAccount(http: HttpClient, id: string): Promise<unknown> {
|
||||
return http.json({ method: 'DELETE', path: `/v1/social/accounts/${encodeURIComponent(id)}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin the org's consent for one network: POST /v1/integration/:provider/connect.
|
||||
*
|
||||
* The body carries no `token` key, and that absence is what selects the
|
||||
* three-legged flow over sealing a credential directly — the platform answers
|
||||
* the network's own consent URL for the browser to follow. A deployment without
|
||||
* the network's app credentials answers 503 here, which is why the card offers
|
||||
* this only where `available` is true.
|
||||
*/
|
||||
export function connect(http: HttpClient, provider: string): Promise<{ authorizeUrl?: string }> {
|
||||
return http.json<{ authorizeUrl?: string }>({
|
||||
method: 'POST',
|
||||
path: `/v1/integration/${encodeURIComponent(provider)}/connect`,
|
||||
body: {},
|
||||
})
|
||||
}
|
||||
|
||||
/** Revoke and forget the org's credential: POST /v1/integration/:provider/disconnect. */
|
||||
export function disconnect(http: HttpClient, provider: string): Promise<{ disconnected: boolean }> {
|
||||
return http.json<{ disconnected: boolean }>({
|
||||
method: 'POST',
|
||||
path: `/v1/integration/${encodeURIComponent(provider)}/disconnect`,
|
||||
body: {},
|
||||
})
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
// How a state is drawn on a register that has no hues.
|
||||
//
|
||||
// This palette is monochrome. Nine screens each mapping a state to a colour of
|
||||
// its own — green for published, amber for scheduled, red for failed — resolve
|
||||
// to one grey here, and four hues rendering as one value is an indicator that
|
||||
// says nothing. So two things carry state, and both are things the ramp can
|
||||
// express.
|
||||
//
|
||||
// LUMINANCE CARRIES DEGREE. Settled is brightest, in-motion is the middle, at
|
||||
// rest is dimmest. That is the order a person scans a list in, and every one of
|
||||
// these indicators sits beside the state's own name — so the shade reinforces a
|
||||
// word rather than replacing one.
|
||||
//
|
||||
// INVERSION CARRIES ATTENTION, AND IT IS SPENT ONCE. `act` is a state a person
|
||||
// has something to do about, and it takes the loudest thing a monochrome palette
|
||||
// has: the top of the ramp as a fill, with the ground as the ink on it. That is
|
||||
// why a network this deployment holds no credentials for reads `quiet` and not
|
||||
// `act` — the missing item is a deployment's environment variable, which the
|
||||
// person reading the screen cannot supply. Inverting all seven networks would
|
||||
// spend the one loud thing on a row nobody can act on, and then a failed post
|
||||
// has nothing left to be louder than.
|
||||
|
||||
import { Text, XStack, YStack } from '@hanzo/ui'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type Tone = 'up' | 'moving' | 'quiet' | 'act'
|
||||
|
||||
/**
|
||||
* The four steps, and the three shapes each takes: `ink` for text, `panel` for
|
||||
* a filled block with a border, `dot` for the small round one.
|
||||
*
|
||||
* `as const` and no annotation, because gui types a colour prop as the union of
|
||||
* the theme's OWN token names rather than as a string — so the literals here are
|
||||
* checked against the theme at compile time, and a token that is not in it is a
|
||||
* type error rather than a colour that silently resolves to nothing.
|
||||
*/
|
||||
export const step = {
|
||||
/** Settled, and what you wanted. Published, connected, ready. */
|
||||
up: {
|
||||
ink: '$ink',
|
||||
panel: { bg: '$raised', borderColor: '$borderColor', color: '$ink' },
|
||||
dot: { bg: '$ink', borderColor: '$ink' },
|
||||
},
|
||||
/** On its way somewhere. Scheduled, publishing. */
|
||||
moving: {
|
||||
ink: '$soft',
|
||||
panel: { bg: '$panel', borderColor: '$borderColor', color: '$soft' },
|
||||
dot: { bg: '$soft', borderColor: '$soft' },
|
||||
},
|
||||
/** Not going out, and nothing is wrong with that. Draft, disconnected. */
|
||||
quiet: {
|
||||
ink: '$quiet',
|
||||
panel: { bg: 'transparent', borderColor: '$borderColor', color: '$quiet' },
|
||||
dot: { bg: 'transparent', borderColor: '$quiet' },
|
||||
},
|
||||
/**
|
||||
* Needs a person. Failed, errored.
|
||||
*
|
||||
* The inversion: the top of the ramp as the fill and the ground as the ink on
|
||||
* it. Inline text has no fill to invert, so `ink` is the brightest step plus
|
||||
* weight — which is the honest version of what a red that renders as grey was
|
||||
* doing.
|
||||
*/
|
||||
act: {
|
||||
ink: '$ink',
|
||||
panel: { bg: '$ink', borderColor: '$ink', color: '$background' },
|
||||
dot: { bg: '$ink', borderColor: '$ink' },
|
||||
},
|
||||
} as const
|
||||
|
||||
/** A post's state, as a step. The transient publishing claim is in motion; a
|
||||
* draft is at rest and fine; a failure is the one thing here a person can act on. */
|
||||
export function tone(status: string): Tone {
|
||||
if (status === 'published') return 'up'
|
||||
if (status === 'scheduled' || status === 'publishing') return 'moving'
|
||||
if (status === 'failed' || status === 'error') return 'act'
|
||||
return 'quiet'
|
||||
}
|
||||
|
||||
/** An account's state, as a step. A connected account is a publish target; a
|
||||
* disconnected one is simply not one; an errored one wants attention. */
|
||||
export function accountTone(status: string): Tone {
|
||||
if (status === 'connected') return 'up'
|
||||
if (status === 'error') return 'act'
|
||||
return 'quiet'
|
||||
}
|
||||
|
||||
/** The state, drawn: the dot and the word it reinforces. */
|
||||
export function Mark({ of, says }: { of: Tone; says: string }) {
|
||||
const it = step[of]
|
||||
return (
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack
|
||||
width={6}
|
||||
height={6}
|
||||
rounded={999}
|
||||
bg={it.dot.bg}
|
||||
borderWidth={1}
|
||||
borderColor={it.dot.borderColor}
|
||||
/>
|
||||
<Text fontSize="$1" color={it.ink} fontWeight={of === 'act' ? '500' : '400'}>
|
||||
{says}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A block that wants reading before the rest of the screen. Spent on the one
|
||||
* state a person has something to do about. */
|
||||
export function Callout({ of, children }: { of: Tone; children: ReactNode }) {
|
||||
const it = step[of]
|
||||
return (
|
||||
<YStack
|
||||
gap="$1"
|
||||
p="$3"
|
||||
rounded="$3"
|
||||
bg={it.panel.bg}
|
||||
borderWidth={1}
|
||||
borderColor={it.panel.borderColor}
|
||||
>
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// Time, in the two forms this app shows it.
|
||||
//
|
||||
// The platform counts in SECONDS — every timestamp on a post and an account is
|
||||
// unix seconds — and JavaScript counts in milliseconds. The conversion lives
|
||||
// here so no screen does it, because a screen that forgets is a screen that
|
||||
// draws 1970 and a screen that doubles it draws the year 57000.
|
||||
|
||||
/** Seconds since the epoch, as the platform counts. */
|
||||
export const now = (): number => Math.floor(Date.now() / 1000)
|
||||
|
||||
const DAY = 86400
|
||||
|
||||
/** A moment, written out: "12 Mar 2026, 14:30". Empty for an unset time. */
|
||||
export function at(seconds: number): string {
|
||||
if (!seconds) return ''
|
||||
return new Date(seconds * 1000).toLocaleString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The distance from now, in the coarsest unit that still says something: "in 3
|
||||
* hours", "2 days ago". A scheduled post's usefulness is how soon, not when, and
|
||||
* a person reading a queue is comparing rows rather than reading a clock.
|
||||
*/
|
||||
export function since(seconds: number): string {
|
||||
if (!seconds) return ''
|
||||
const gap = seconds - now()
|
||||
const ahead = gap > 0
|
||||
const size = Math.abs(gap)
|
||||
const [count, unit] =
|
||||
size < 60
|
||||
? [size, 'second']
|
||||
: size < 3600
|
||||
? [Math.round(size / 60), 'minute']
|
||||
: size < DAY
|
||||
? [Math.round(size / 3600), 'hour']
|
||||
: size < DAY * 30
|
||||
? [Math.round(size / DAY), 'day']
|
||||
: [Math.round(size / (DAY * 30)), 'month']
|
||||
const word = `${count} ${unit}${count === 1 ? '' : 's'}`
|
||||
return ahead ? `in ${word}` : `${word} ago`
|
||||
}
|
||||
|
||||
/** The local calendar day a moment falls on, as "2026-03-12" — the key a
|
||||
* calendar groups by. Local rather than UTC: a person schedules against the
|
||||
* clock on their wall, and grouping by UTC moves an evening post to tomorrow. */
|
||||
export function day(seconds: number): string {
|
||||
const d = new Date(seconds * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The days of one month's grid, Monday first, padded out to whole weeks.
|
||||
*
|
||||
* Whole weeks because the grid is seven wide and a ragged last row reads as
|
||||
* missing days rather than as the month ending. Each entry carries whether it
|
||||
* belongs to the month asked for, so the padding can be drawn dimmer than the
|
||||
* month itself.
|
||||
*/
|
||||
export function grid(year: number, month: number): { key: string; date: Date; inMonth: boolean }[] {
|
||||
const first = new Date(year, month, 1)
|
||||
// getDay() is Sunday-first; this app's week starts Monday, which is the one
|
||||
// the rest of the world schedules against.
|
||||
const lead = (first.getDay() + 6) % 7
|
||||
const start = new Date(year, month, 1 - lead)
|
||||
const out: { key: string; date: Date; inMonth: boolean }[] = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)
|
||||
out.push({ key: day(Math.floor(date.getTime() / 1000)), date, inMonth: date.getMonth() === month })
|
||||
// Six rows covers every month; five is enough for most, so the tail is
|
||||
// trimmed once the month is behind us and the row is entirely padding.
|
||||
if (i >= 34 && i % 7 === 6 && date.getMonth() !== month) {
|
||||
const week = out.slice(-7)
|
||||
if (week.every((d) => !d.inMonth)) out.length -= 7
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A moment as the value a `datetime-local` input holds, and back.
|
||||
*
|
||||
* The control speaks LOCAL wall-clock with no zone, so the two conversions are
|
||||
* asymmetric and both belong here: to the control, subtract the browser's offset
|
||||
* so the ISO string reads as the local time; from the control, `new Date` on a
|
||||
* zone-less string is already local.
|
||||
*/
|
||||
export function toField(seconds: number): string {
|
||||
if (!seconds) return ''
|
||||
const d = new Date(seconds * 1000)
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export function fromField(value: string): number {
|
||||
if (!value) return 0
|
||||
const ms = new Date(value).getTime()
|
||||
return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* WHERE THE SESSION IS KEPT, named once.
|
||||
*
|
||||
* The @hanzo/iam SDK owns these keys and publishes no reader a plain module can
|
||||
* call — `getSession()` needs `configureIam()`, and this app configures through
|
||||
* `IamProvider` instead. So the names are restated here, in ONE file, and
|
||||
* everything that needs the bearer asks this. Two derivations of one key name
|
||||
* never agree, and the disagreement is silent: a read of the wrong name answers
|
||||
* null, which is indistinguishable from nobody being signed in.
|
||||
*
|
||||
* Every access is guarded, because storage THROWS rather than answering null in
|
||||
* a browser that refuses it, and a caller reaching for the bearer is usually
|
||||
* about to make a request — one that should fail as unauthenticated, not as an
|
||||
* exception.
|
||||
*/
|
||||
|
||||
/** The SDK's own keys, `hanzo_iam_`-prefixed. The far side of a boundary. */
|
||||
const PREFIX = 'hanzo_iam_'
|
||||
const ACCESS = `${PREFIX}access_token`
|
||||
|
||||
/** Which organization this browser is working in. The SDK's own key, so a
|
||||
* choice made here is the choice every other Hanzo surface reads. */
|
||||
const CURRENT = `${PREFIX}current_org`
|
||||
|
||||
/** Who this browser's local state belongs to. */
|
||||
const WHO = 'hanzo:who'
|
||||
|
||||
/** The SDK's per-person selections. Everything else under its prefix is the
|
||||
* session's own machinery — tokens, the login in flight — and stays. */
|
||||
const CHOSEN = [CURRENT, `${PREFIX}current_project`]
|
||||
|
||||
/** What a change of person leaves in place. */
|
||||
const kept = (key: string): boolean =>
|
||||
key === WHO || (key.startsWith(PREFIX) && !CHOSEN.includes(key))
|
||||
|
||||
/** The access token, or null. */
|
||||
export function bearer(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return window.localStorage.getItem(ACCESS)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The claims the stored token carries, or null. */
|
||||
function claims(): Record<string, unknown> | null {
|
||||
const token = bearer()
|
||||
if (!token) return null
|
||||
try {
|
||||
const part = token.split('.')[1]
|
||||
if (!part) return null
|
||||
return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject the stored access token names, or undefined.
|
||||
*
|
||||
* Read off the claim rather than waited for from userinfo, which is what makes
|
||||
* `own` runnable BEFORE anything else reads storage: the token is in place from
|
||||
* the first line of the first script. Expiry is not consulted — a stale token
|
||||
* still says whose browser this is, and the SDK either refreshes it or clears
|
||||
* it, at which point the answer changes here too.
|
||||
*/
|
||||
export function subject(): string | undefined {
|
||||
const sub = claims()?.sub
|
||||
return typeof sub === 'string' && sub ? sub : undefined
|
||||
}
|
||||
|
||||
/** Whether this browser holds a session. */
|
||||
export function hasSession(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return Boolean(bearer() || window.localStorage.getItem(WHO))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind everything this app keeps in the browser to ONE person.
|
||||
*
|
||||
* The organization selection is written per browser and would otherwise be read
|
||||
* by whoever signs in next — which on this surface means composing into another
|
||||
* person's tenant. The subject is recorded beside it, and when a different
|
||||
* subject arrives — or nobody, which is a sign-out — the last person's keys go
|
||||
* before anything reads them. The SDK's `hanzo_iam_*` keys are the arriving
|
||||
* session itself and stay.
|
||||
*
|
||||
* Same subject, no work. Called where the providers mount, so a screen's first
|
||||
* read of storage already sees a browser that is the reader's.
|
||||
*/
|
||||
export function own(sub: string | undefined): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const store = window.localStorage
|
||||
if (store.getItem(WHO) === (sub ?? null)) return
|
||||
for (const key of Object.keys(store))
|
||||
if (key.startsWith('hanzo') && !kept(key)) store.removeItem(key)
|
||||
if (sub) store.setItem(WHO, sub)
|
||||
else store.removeItem(WHO)
|
||||
} catch {
|
||||
/* a browser that refuses storage kept nothing to remove */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The organizations the token says this person belongs to, home first.
|
||||
*
|
||||
* THIS IS THE TENANT LIST. Every account and every post on this surface belongs
|
||||
* to one of these and to nothing finer: the social API resolves the org from the
|
||||
* validated bearer and filters every query by it, so the org is the boundary a
|
||||
* person switches across.
|
||||
*
|
||||
* Empty for an account that has not made one: the SDK's own list falls back to
|
||||
* the `owner` claim, which is the application's org and nobody's to work in.
|
||||
*/
|
||||
export function orgs(): string[] {
|
||||
const set = claims()?.orgs
|
||||
const out: string[] = []
|
||||
for (const ref of Array.isArray(set) ? set : []) {
|
||||
const o = (ref as { org?: unknown } | null)?.org
|
||||
if (typeof o === 'string' && o && !out.includes(o)) out.push(o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The organization this browser works in: the stored selection, when it is one
|
||||
* of the token's. A sole organization is not a choice and is the answer from
|
||||
* the first render — waiting for a pick would build the client unscoped and
|
||||
* restart every read after first paint.
|
||||
*/
|
||||
export function org(): string | null {
|
||||
try {
|
||||
const mine = orgs()
|
||||
const chosen = window.localStorage.getItem(CURRENT)
|
||||
if (chosen && mine.includes(chosen)) return chosen
|
||||
return mine.length === 1 ? mine[0]! : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Record which organization this browser works in. Refuses one the token does
|
||||
* not carry — the gateway would refuse it too, and a header nobody honours is
|
||||
* worse than a refusal, because the screen then shows another tenant's empty. */
|
||||
export function work(pick: string): void {
|
||||
try {
|
||||
if (orgs().includes(pick)) window.localStorage.setItem(CURRENT, pick)
|
||||
} catch {
|
||||
/* a browser that refuses storage keeps the session's sole org */
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// A stylesheet is not a module with a type; these imports exist for their
|
||||
// effect. `@hanzo/font/css` ships the woff2 and the `@font-face` that names it,
|
||||
// and has no declaration of its own.
|
||||
declare module '@hanzo/font/css'
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "vite/client"],
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { hanzo } from '@hanzo/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
/**
|
||||
* The bundler. Resolving the Hanzo runtime — `~/…`, react-native-web, web-first
|
||||
* extensions, one copy of gui — comes from `hanzo()`, stated once for every
|
||||
* Hanzo app. What is left is this app's own.
|
||||
*
|
||||
* There is no CSS pipeline, and none is needed: @hanzo/ui ships one generated
|
||||
* stylesheet that `<Hanzo>` imports, and gui inserts the rules for props a
|
||||
* package could not have known about at first render.
|
||||
*/
|
||||
const API = 'https://api.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Same-origin in front of the gateway, on the dev server and on the preview of
|
||||
* a build. api.hanzo.ai admits an origin by allowlist and by an https, portless
|
||||
* DNS proof; a localhost port satisfies neither, so a credentialed read to the
|
||||
* absolute address fails its preflight and every screen draws as though the org
|
||||
* had never connected an account. One statement, both servers — a proxy that
|
||||
* exists only under `vite dev` means the built output is never exercised
|
||||
* against real data before it ships.
|
||||
*/
|
||||
const proxy = { '/v1': { target: API, changeOrigin: true } }
|
||||
|
||||
/**
|
||||
* The port is pinned because it is half of a redirect. IAM returns a browser to
|
||||
* the `redirect_uri` the client registered, and that URI carries the port — so a
|
||||
* port the dev server picked because 3300 was busy is a port the issuer refuses
|
||||
* to return to, and the sign-in ends on an error page instead of this app.
|
||||
*/
|
||||
const PORT = 3300
|
||||
|
||||
/**
|
||||
* A single-page app has one document and many addresses, so every address that
|
||||
* is not a file has to arrive at that document. `ghcr.io/hanzoai/spa` answers
|
||||
* index.html for any route; a static plane resolves an object key and otherwise
|
||||
* answers the site's own `404.html`, so emitting the document under that name
|
||||
* is the whole fix and every static host already implements it.
|
||||
*
|
||||
* The status stays 404: the browser runs the bundle regardless and the router
|
||||
* takes the address from there, while a crawler reads "not a page I publish",
|
||||
* which is true of every address the router invents.
|
||||
*/
|
||||
const spaFallback = {
|
||||
name: 'spa-fallback',
|
||||
async writeBundle(options: { dir?: string }) {
|
||||
const { copyFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const dir = options.dir ?? 'dist'
|
||||
await copyFile(join(dir, 'index.html'), join(dir, '404.html'))
|
||||
},
|
||||
}
|
||||
|
||||
const config = hanzo(
|
||||
{
|
||||
plugins: [react(), spaFallback],
|
||||
server: { port: PORT, allowedHosts: true, proxy },
|
||||
preview: { port: PORT, allowedHosts: true, proxy },
|
||||
},
|
||||
{
|
||||
root: import.meta.dirname,
|
||||
// ONE COPY OF THE THEME. `hanzo()` dedupes `@hanzo/gui` and `@hanzo/ui`, but
|
||||
// the theme and config contexts live a layer below them in `@hanzogui/core`.
|
||||
// A component rendered under one context reading the other throws
|
||||
// "Missing theme."
|
||||
dedupe: ['@hanzogui/core', '@hanzogui/web', '@hanzogui/portal', '@hanzogui/toast'],
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Dependency optimization is a SECOND resolution pass and inherits none of the
|
||||
* first, so it is told the same thing: on the web, a react-native package's
|
||||
* `.web.js` sibling comes first. Left out, the optimizer follows react-native
|
||||
* into its Flow source and the dev server dies at startup, while `vite build`
|
||||
* uses the resolver above and succeeds.
|
||||
*
|
||||
* The list is read back from the config rather than restated.
|
||||
*/
|
||||
export default {
|
||||
...config,
|
||||
optimizeDeps: {
|
||||
rollupOptions: {
|
||||
resolve: { extensions: (config.resolve as { extensions: string[] }).extensions },
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user