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:
2026-09-15 06:40:41 -07:00
co-authored by Hanzo Dev
commit 50aa91efd4
37 changed files with 10439 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.tsbuildinfo
.env*.local
LLM.md
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+27
View File
@@ -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
+58
View File
@@ -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.
+33
View File
@@ -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
View File
@@ -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>
+42
View File
@@ -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:"
}
}
+6530
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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
+287
View File
@@ -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>
)
}
+194
View File
@@ -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>
)
}
+172
View File
@@ -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
View File
@@ -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>
)
}
+113
View File
@@ -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>
)
}
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
+47
View File
@@ -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>
)
}
+91
View File
@@ -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>
)
}
+25
View File
@@ -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
View File
@@ -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>
)
}
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 */
}
}
+7
View File
@@ -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'
+20
View File
@@ -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"]
}
+87
View File
@@ -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 },
},
},
}