mirror of
https://github.com/versia-pub/docs.git
synced 2025-12-06 06:18:19 +01:00
feat: ✨ Initialize rewrite
This commit is contained in:
parent
47ce9bd9f8
commit
f39d34b769
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
61
.github/workflows/deploy.yml
vendored
61
.github/workflows/deploy.yml
vendored
|
|
@ -1,61 +0,0 @@
|
||||||
# Sample workflow for building and deploying a VitePress site to GitHub Pages
|
|
||||||
#
|
|
||||||
name: Deploy VitePress site to Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Runs on pushes targeting the `main` branch. Change this to `master` if you're
|
|
||||||
# using the `master` branch as the default branch.
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
|
||||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
|
||||||
concurrency:
|
|
||||||
group: pages
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Build job
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
|
||||||
# - uses: pnpm/action-setup@v2 # Uncomment this if you're using pnpm
|
|
||||||
- uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v4
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
- name: Build with VitePress
|
|
||||||
run: |
|
|
||||||
bun run docs:build
|
|
||||||
touch .vitepress/dist/.nojekyll
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: .vitepress/dist
|
|
||||||
|
|
||||||
# Deployment job
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Deploy
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
38
.gitignore
vendored
38
.gitignore
vendored
|
|
@ -1,3 +1,35 @@
|
||||||
node_modules
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
.vitepress/cache
|
|
||||||
.vitepress/dist
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import { defineConfig } from "vitepress";
|
|
||||||
|
|
||||||
// https://vitepress.dev/reference/site-config
|
|
||||||
export default defineConfig({
|
|
||||||
title: "Lysand Documentation",
|
|
||||||
description: "Documentation for Lysand, a new federated protocol",
|
|
||||||
vite: {
|
|
||||||
plugins: [tailwindcss()],
|
|
||||||
},
|
|
||||||
vue: {
|
|
||||||
template: {
|
|
||||||
compilerOptions: {
|
|
||||||
isCustomElement: (tag) => tag === "iconify-icon",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
srcDir: "docs",
|
|
||||||
themeConfig: {
|
|
||||||
// https://vitepress.dev/reference/default-theme-config
|
|
||||||
nav: [
|
|
||||||
{ text: "Home", link: "/" },
|
|
||||||
{ text: "Specification", link: "/spec" },
|
|
||||||
{ text: "Objects", link: "/objects" },
|
|
||||||
{ text: "Security", link: "/security/api" },
|
|
||||||
{ text: "Extensions", link: "/extensions" },
|
|
||||||
],
|
|
||||||
|
|
||||||
sidebar: [
|
|
||||||
{
|
|
||||||
text: "Specification",
|
|
||||||
items: [
|
|
||||||
{ text: "Spec", link: "/spec" },
|
|
||||||
{ text: "Objects", link: "/objects" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Structures",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
text: "Content Format",
|
|
||||||
link: "/structures/content-format",
|
|
||||||
},
|
|
||||||
{ text: "Custom Emoji", link: "/structures/custom-emoji" },
|
|
||||||
{ text: "Collection", link: "/structures/collection" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Groups",
|
|
||||||
items: [{ text: "Groups", link: "/groups" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Security",
|
|
||||||
items: [
|
|
||||||
{ text: "API", link: "/security/api" },
|
|
||||||
{ text: "Keys", link: "/security/keys" },
|
|
||||||
{ text: "Signing", link: "/security/signing" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Objects",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
text: "Publications",
|
|
||||||
link: "/objects/publications",
|
|
||||||
items: [
|
|
||||||
{ text: "Note", link: "/objects/note" },
|
|
||||||
{ text: "Patch", link: "/objects/patch" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Actors",
|
|
||||||
link: "/objects/actors",
|
|
||||||
items: [{ text: "User", link: "/objects/user" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Actions",
|
|
||||||
link: "/objects/actions",
|
|
||||||
items: [
|
|
||||||
{ text: "Like", link: "/objects/like" },
|
|
||||||
{ text: "Dislike", link: "/objects/dislike" },
|
|
||||||
{ text: "Follow", link: "/objects/follow" },
|
|
||||||
{
|
|
||||||
text: "FollowAccept",
|
|
||||||
link: "/objects/follow-accept",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "FollowReject",
|
|
||||||
link: "/objects/follow-reject",
|
|
||||||
},
|
|
||||||
{ text: "Announce", link: "/objects/announce" },
|
|
||||||
{ text: "Undo", link: "/objects/undo" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Server Metadata",
|
|
||||||
link: "/objects/server-metadata",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Federation",
|
|
||||||
items: [
|
|
||||||
{ text: "Endpoints", link: "/federation/endpoints" },
|
|
||||||
{
|
|
||||||
text: "User Discovery",
|
|
||||||
link: "/federation/user-discovery",
|
|
||||||
},
|
|
||||||
{ text: "Server Actors", link: "/federation/server-actor" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Extensions",
|
|
||||||
link: "/extensions",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
text: "Custom Emojis",
|
|
||||||
link: "/extensions/custom-emojis",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Microblogging",
|
|
||||||
link: "/extensions/microblogging",
|
|
||||||
},
|
|
||||||
{ text: "Reactions", link: "/extensions/reactions" },
|
|
||||||
{ text: "Polls", link: "/extensions/polls" },
|
|
||||||
{ text: "Is Cat", link: "/extensions/is-cat" },
|
|
||||||
{
|
|
||||||
text: "Server Endorsements",
|
|
||||||
link: "/extensions/server-endorsement",
|
|
||||||
},
|
|
||||||
{ text: "Events", link: "/extensions/events" },
|
|
||||||
{ text: "Reports", link: "/extensions/reports" },
|
|
||||||
{ text: "Migration", link: "/extensions/migration" },
|
|
||||||
{ text: "Vanity", link: "/extensions/vanity" },
|
|
||||||
{
|
|
||||||
text: "Interactivity",
|
|
||||||
link: "/extensions/interactivity",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
footer: {
|
|
||||||
message: "Released under the MIT License.",
|
|
||||||
copyright: "Copyright © 2023-present Gaspard Wierzbinski",
|
|
||||||
},
|
|
||||||
|
|
||||||
socialLinks: [
|
|
||||||
{ icon: "github", link: "https://github.com/lysand-org/" },
|
|
||||||
],
|
|
||||||
search: {
|
|
||||||
provider: "local",
|
|
||||||
},
|
|
||||||
editLink: {
|
|
||||||
pattern: "https://github.com/lysand-org/docs/edit/main/docs/:path",
|
|
||||||
},
|
|
||||||
externalLinkIcon: true,
|
|
||||||
logo: "https://cdn.lysand.org/logo.webp",
|
|
||||||
},
|
|
||||||
lastUpdated: true,
|
|
||||||
cleanUrls: true,
|
|
||||||
titleTemplate: ":title · Lysand Docs",
|
|
||||||
head: [["link", { rel: "icon", href: "/favicon.png", type: "image/png" }]],
|
|
||||||
lang: "en-US",
|
|
||||||
});
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import DefaultTheme from 'vitepress/theme'
|
|
||||||
import Banner from '../../components/Banner.vue';
|
|
||||||
import "iconify-icon";
|
|
||||||
|
|
||||||
const { Layout } = DefaultTheme
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Banner />
|
|
||||||
<Layout>
|
|
||||||
</Layout>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
}
|
|
||||||
:root {
|
|
||||||
/* --vp-home-hero-image-background-image: linear-gradient(
|
|
||||||
to top right,
|
|
||||||
rgb(249, 168, 212),
|
|
||||||
rgb(216, 180, 254),
|
|
||||||
rgb(129, 140, 248)
|
|
||||||
);
|
|
||||||
--vp-home-hero-image-filter: brightness(0.8) saturate(1.2); */
|
|
||||||
--vp-home-hero-name-color: rgb(249, 168, 212);
|
|
||||||
--vp-c-brand-1: rgb(249, 168, 212);
|
|
||||||
--vp-layout-top-height: var(--spacing-10);
|
|
||||||
--lysand-gradient: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgb(249, 168, 212),
|
|
||||||
rgb(216, 180, 254),
|
|
||||||
rgb(129, 140, 248)
|
|
||||||
);
|
|
||||||
--vp-color-primary: rgb(249, 168, 212);
|
|
||||||
--vp-color-secondary: rgb(216, 180, 254);
|
|
||||||
--vp-button-brand-bg: transparent;
|
|
||||||
--vp-c-bg-soft: rgb(250, 250, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--vp-c-bg: rgb(24, 24, 24);
|
|
||||||
--vp-c-bg-soft: rgb(32, 32, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPFeature {
|
|
||||||
border-radius: 0.3rem !important;
|
|
||||||
transition: all 0.2s ease-in-out !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPFeature:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
border-color: var(--vp-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPButton.medium {
|
|
||||||
border-radius: 0.3rem !important;
|
|
||||||
transition: all 0.2s ease-in-out !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPButton.medium:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPButton.brand {
|
|
||||||
background: var(--lysand-gradient);
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
|
||||||
.image-container {
|
|
||||||
width: 50% !important;
|
|
||||||
margin-right: 0.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import DefaultTheme from "vitepress/theme";
|
|
||||||
import Layout from "./Layout.vue";
|
|
||||||
import "./custom.css";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
extends: DefaultTheme,
|
|
||||||
Layout,
|
|
||||||
};
|
|
||||||
33
Dockerfile
33
Dockerfile
|
|
@ -1,33 +0,0 @@
|
||||||
FROM oven/bun:alpine as base
|
|
||||||
|
|
||||||
# Install dependencies into temp directory
|
|
||||||
# This will cache them and speed up future builds
|
|
||||||
FROM base AS install
|
|
||||||
RUN mkdir -p /temp/dev
|
|
||||||
COPY package.json bun.lockb /temp/dev/
|
|
||||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Install with --production (exclude devDependencies)
|
|
||||||
RUN mkdir -p /temp/prod
|
|
||||||
COPY package.json bun.lockb /temp/prod/
|
|
||||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
RUN cd /app && bun install
|
|
||||||
RUN cd /app && bun docs:build
|
|
||||||
|
|
||||||
FROM base AS final
|
|
||||||
|
|
||||||
COPY --from=builder /app/.vitepress/dist/ /app
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors "Gaspard Wierzbinski (https://cpluspatch.com)"
|
|
||||||
LABEL org.opencontainers.image.source "https://github.com/lysand-org/docs"
|
|
||||||
LABEL org.opencontainers.image.vendor "Lysand.org"
|
|
||||||
LABEL org.opencontainers.image.licenses "MIT"
|
|
||||||
LABEL org.opencontainers.image.title "Lysand Docs"
|
|
||||||
LABEL org.opencontainers.image.description "Documentation for Lysand"
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["bun", "docs:serve"]
|
|
||||||
21
LICENSE
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2019 Gaspard Wierzbinski
|
|
||||||
|
|
||||||
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.
|
|
||||||
129
LICENSE.md
Normal file
129
LICENSE.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Tailwind UI License
|
||||||
|
|
||||||
|
## Personal License
|
||||||
|
|
||||||
|
Tailwind Labs Inc. grants you an on-going, non-exclusive license to use the Components and Templates.
|
||||||
|
|
||||||
|
The license grants permission to **one individual** (the Licensee) to access and use the Components and Templates.
|
||||||
|
|
||||||
|
You **can**:
|
||||||
|
|
||||||
|
- Use the Components and Templates to create unlimited End Products.
|
||||||
|
- Modify the Components and Templates to create derivative components and templates. Those components and templates are subject to this license.
|
||||||
|
- Use the Components and Templates to create unlimited End Products for unlimited Clients.
|
||||||
|
- Use the Components and Templates to create End Products where the End Product is sold to End Users.
|
||||||
|
- Use the Components and Templates to create End Products that are open source and freely available to End Users.
|
||||||
|
|
||||||
|
You **cannot**:
|
||||||
|
|
||||||
|
- Use the Components and Templates to create End Products that are designed to allow an End User to build their own End Products using the Components and Templates or derivatives of the Components and Templates.
|
||||||
|
- Re-distribute the Components and Templates or derivatives of the Components and Templates separately from an End Product, neither in code or as design assets.
|
||||||
|
- Share your access to the Components and Templates with any other individuals.
|
||||||
|
- Use the Components and Templates to produce anything that may be deemed by Tailwind Labs Inc, in their sole and absolute discretion, to be competitive or in conflict with the business of Tailwind Labs Inc.
|
||||||
|
|
||||||
|
### Example usage
|
||||||
|
|
||||||
|
Examples of usage **allowed** by the license:
|
||||||
|
|
||||||
|
- Creating a personal website by yourself.
|
||||||
|
- Creating a website or web application for a client that will be owned by that client.
|
||||||
|
- Creating a commercial SaaS application (like an invoicing app for example) where end users have to pay a fee to use the application.
|
||||||
|
- Creating a commercial self-hosted web application that is sold to end users for a one-time fee.
|
||||||
|
- Creating a web application where the primary purpose is clearly not to simply re-distribute the components (like a conference organization app that uses the components for its UI for example) that is free and open source, where the source code is publicly available.
|
||||||
|
|
||||||
|
Examples of usage **not allowed** by the license:
|
||||||
|
|
||||||
|
- Creating a repository of your favorite Tailwind UI components or templates (or derivatives based on Tailwind UI components or templates) and publishing it publicly.
|
||||||
|
- Creating a React or Vue version of Tailwind UI and making it available either for sale or for free.
|
||||||
|
- Create a Figma or Sketch UI kit based on the Tailwind UI component designs.
|
||||||
|
- Creating a "website builder" project where end users can build their own websites using components or templates included with or derived from Tailwind UI.
|
||||||
|
- Creating a theme, template, or project starter kit using the components or templates and making it available either for sale or for free.
|
||||||
|
- Creating an admin panel tool (like [Laravel Nova](https://nova.laravel.com/) or [ActiveAdmin](https://activeadmin.info/)) that is made available either for sale or for free.
|
||||||
|
|
||||||
|
In simple terms, use Tailwind UI for anything you like as long as it doesn't compete with Tailwind UI.
|
||||||
|
|
||||||
|
### Personal License Definitions
|
||||||
|
|
||||||
|
Licensee is the individual who has purchased a Personal License.
|
||||||
|
|
||||||
|
Components and Templates are the source code and design assets made available to the Licensee after purchasing a Tailwind UI license.
|
||||||
|
|
||||||
|
End Product is any artifact produced that incorporates the Components or Templates or derivatives of the Components or Templates.
|
||||||
|
|
||||||
|
End User is a user of an End Product.
|
||||||
|
|
||||||
|
Client is an individual or entity receiving custom professional services directly from the Licensee, produced specifically for that individual or entity. Customers of software-as-a-service products are not considered clients for the purpose of this document.
|
||||||
|
|
||||||
|
## Team License
|
||||||
|
|
||||||
|
Tailwind Labs Inc. grants you an on-going, non-exclusive license to use the Components and Templates.
|
||||||
|
|
||||||
|
The license grants permission for **up to 25 Employees and Contractors of the Licensee** to access and use the Components and Templates.
|
||||||
|
|
||||||
|
You **can**:
|
||||||
|
|
||||||
|
- Use the Components and Templates to create unlimited End Products.
|
||||||
|
- Modify the Components and Templates to create derivative components and templates. Those components and templates are subject to this license.
|
||||||
|
- Use the Components and Templates to create unlimited End Products for unlimited Clients.
|
||||||
|
- Use the Components and Templates to create End Products where the End Product is sold to End Users.
|
||||||
|
- Use the Components and Templates to create End Products that are open source and freely available to End Users.
|
||||||
|
|
||||||
|
You **cannot**:
|
||||||
|
|
||||||
|
- Use the Components or Templates to create End Products that are designed to allow an End User to build their own End Products using the Components or Templates or derivatives of the Components or Templates.
|
||||||
|
- Re-distribute the Components or Templates or derivatives of the Components or Templates separately from an End Product.
|
||||||
|
- Use the Components or Templates to create End Products that are the property of any individual or entity other than the Licensee or Clients of the Licensee.
|
||||||
|
- Use the Components or Templates to produce anything that may be deemed by Tailwind Labs Inc, in their sole and absolute discretion, to be competitive or in conflict with the business of Tailwind Labs Inc.
|
||||||
|
|
||||||
|
### Example usage
|
||||||
|
|
||||||
|
Examples of usage **allowed** by the license:
|
||||||
|
|
||||||
|
- Creating a website for your company.
|
||||||
|
- Creating a website or web application for a client that will be owned by that client.
|
||||||
|
- Creating a commercial SaaS application (like an invoicing app for example) where end users have to pay a fee to use the application.
|
||||||
|
- Creating a commercial self-hosted web application that is sold to end users for a one-time fee.
|
||||||
|
- Creating a web application where the primary purpose is clearly not to simply re-distribute the components or templates (like a conference organization app that uses the components or a template for its UI for example) that is free and open source, where the source code is publicly available.
|
||||||
|
|
||||||
|
Examples of use **not allowed** by the license:
|
||||||
|
|
||||||
|
- Creating a repository of your favorite Tailwind UI components or template (or derivatives based on Tailwind UI components or templates) and publishing it publicly.
|
||||||
|
- Creating a React or Vue version of Tailwind UI and making it available either for sale or for free.
|
||||||
|
- Creating a "website builder" project where end users can build their own websites using components or templates included with or derived from Tailwind UI.
|
||||||
|
- Creating a theme or template using the components or templates and making it available either for sale or for free.
|
||||||
|
- Creating an admin panel tool (like [Laravel Nova](https://nova.laravel.com/) or [ActiveAdmin](https://activeadmin.info/)) that is made available either for sale or for free.
|
||||||
|
- Creating any End Product that is not the sole property of either your company or a client of your company. For example your employees/contractors can't use your company Tailwind UI license to build their own websites or side projects.
|
||||||
|
|
||||||
|
### Team License Definitions
|
||||||
|
|
||||||
|
Licensee is the business entity who has purchased a Team License.
|
||||||
|
|
||||||
|
Components and Templates are the source code and design assets made available to the Licensee after purchasing a Tailwind UI license.
|
||||||
|
|
||||||
|
End Product is any artifact produced that incorporates the Components or Templates or derivatives of the Components or Templates.
|
||||||
|
|
||||||
|
End User is a user of an End Product.
|
||||||
|
|
||||||
|
Employee is a full-time or part-time employee of the Licensee.
|
||||||
|
|
||||||
|
Contractor is an individual or business entity contracted to perform services for the Licensee.
|
||||||
|
|
||||||
|
Client is an individual or entity receiving custom professional services directly from the Licensee, produced specifically for that individual or entity. Customers of software-as-a-service products are not considered clients for the purpose of this document.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
If you are found to be in violation of the license, access to your Tailwind UI account will be terminated, and a refund may be issued at our discretion. When license violation is blatant and malicious (such as intentionally redistributing the Components or Templates through private warez channels), no refund will be issued.
|
||||||
|
|
||||||
|
The copyright of the Components and Templates is owned by Tailwind Labs Inc. You are granted only the permissions described in this license; all other rights are reserved. Tailwind Labs Inc. reserves the right to pursue legal remedies for any unauthorized use of the Components or Templates outside the scope of this license.
|
||||||
|
|
||||||
|
## Liability
|
||||||
|
|
||||||
|
Tailwind Labs Inc.’s liability to you for costs, damages, or other losses arising from your use of the Components or Templates — including third-party claims against you — is limited to a refund of your license fee. Tailwind Labs Inc. may not be held liable for any consequential damages related to your use of the Components or Templates.
|
||||||
|
|
||||||
|
This Agreement is governed by the laws of the Province of Ontario and the applicable laws of Canada. Legal proceedings related to this Agreement may only be brought in the courts of Ontario. You agree to service of process at the e-mail address on your original order.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Unsure which license you need, or unsure if your use case is covered by our licenses?
|
||||||
|
|
||||||
|
Email us at [support@tailwindui.com](mailto:support@tailwindui.com) with your questions.
|
||||||
46
README.md
46
README.md
|
|
@ -1,12 +1,46 @@
|
||||||
# Lysand Docs
|
# Protocol
|
||||||
|
|
||||||
## Contributing
|
Protocol is a [Tailwind UI](https://tailwindui.com) site template built using [Tailwind CSS](https://tailwindcss.com) and [Next.js](https://nextjs.org).
|
||||||
|
|
||||||
This site is built with [VitePress](https://github.com/vuejs/vitepress), and its content is written in Markdown format located in `docs`. For simple edits, you can directly edit the file on GitHub and generate a Pull Request.
|
## Getting started
|
||||||
|
|
||||||
For local development, [bun](https://bun.sh/) is preferred as package manager and runtime:
|
To get started with this template, first install the npm dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun i
|
npm install
|
||||||
bun docs:dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Next, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, open [http://localhost:3000](http://localhost:3000) in your browser to view the website.
|
||||||
|
|
||||||
|
## Customizing
|
||||||
|
|
||||||
|
You can start editing this template by modifying the files in the `/src` folder. The site will auto-update as you edit these files.
|
||||||
|
|
||||||
|
## Global search
|
||||||
|
|
||||||
|
This template includes a global search that's powered by the [FlexSearch](https://github.com/nextapps-de/flexsearch) library. It's available by clicking the search input or by using the `⌘K` shortcut.
|
||||||
|
|
||||||
|
This feature requires no configuration, and works out of the box by automatically scanning your documentation pages to build its index. You can adjust the search parameters by editing the `/src/mdx/search.mjs` file.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This site template is a commercial product and is licensed under the [Tailwind UI license](https://tailwindui.com/license).
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
To learn more about the technologies used in this site template, see the following resources:
|
||||||
|
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/docs) - the official Tailwind CSS documentation
|
||||||
|
- [Next.js](https://nextjs.org/docs) - the official Next.js documentation
|
||||||
|
- [Headless UI](https://headlessui.dev) - the official Headless UI documentation
|
||||||
|
- [Framer Motion](https://www.framer.com/docs/) - the official Framer Motion documentation
|
||||||
|
- [MDX](https://mdxjs.com/) - the official MDX documentation
|
||||||
|
- [Algolia Autocomplete](https://www.algolia.com/doc/ui-libraries/autocomplete/introduction/what-is-autocomplete/) - the official Algolia Autocomplete documentation
|
||||||
|
- [FlexSearch](https://github.com/nextapps-de/flexsearch) - the official FlexSearch documentation
|
||||||
|
- [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction) - the official Zustand documentation
|
||||||
|
|
|
||||||
363
app/attachments/page.mdx
Normal file
363
app/attachments/page.mdx
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Attachments',
|
||||||
|
description:
|
||||||
|
'On this page, we’ll dive into the different attachment endpoints you can use to manage attachments programmatically.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attachments
|
||||||
|
|
||||||
|
Attachments are how you share things in Protocol — they allow you to send all sorts of files to your contacts and groups. On this page, we'll dive into the different attachment endpoints you can use to manage attachments programmatically. We'll look at how to query, upload, update, and delete attachments. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
## The attachment model
|
||||||
|
|
||||||
|
The attachment model contains all the information about the files you send to your contacts and groups, including the name, type, and size.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="id" type="string">
|
||||||
|
Unique identifier for the attachment.
|
||||||
|
</Property>
|
||||||
|
<Property name="message_id" type="string">
|
||||||
|
Unique identifier for the message associated with the attachment.
|
||||||
|
</Property>
|
||||||
|
<Property name="filename" type="string">
|
||||||
|
The filename for the attachment.
|
||||||
|
</Property>
|
||||||
|
<Property name="file_url" type="string">
|
||||||
|
The URL for the attached file.
|
||||||
|
</Property>
|
||||||
|
<Property name="file_type" type="string">
|
||||||
|
The MIME type of the attached file.
|
||||||
|
</Property>
|
||||||
|
<Property name="file_size" type="integer">
|
||||||
|
The file size of the attachment in bytes.
|
||||||
|
</Property>
|
||||||
|
<Property name="created_at" type="timestamp">
|
||||||
|
Timestamp of when the attachment was created.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List all attachments {{ tag: 'GET', label: '/v1/attachments' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a paginated list of all your attachments (in a conversation if a conversation id is provided). By default, a maximum of ten attachments are shown per page.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="conversation_id" type="string">
|
||||||
|
Limit to attachments from a given conversation.
|
||||||
|
</Property>
|
||||||
|
<Property name="limit" type="integer">
|
||||||
|
Limit the number of attachments returned.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/attachments">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -G https://api.protocol.chat/v1/attachments \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d conversation_id="xgQQXg3hrtjh7AvZ" \
|
||||||
|
-d limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.attachments.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.attachments.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->attachments->list();
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"has_more": false,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "Nc6yKKMpcxiiFxp6",
|
||||||
|
"message_id": "LoPsJaMcPBuFNjg1",
|
||||||
|
"filename": "Invoice_room_service__Plaza_Hotel.pdf",
|
||||||
|
"file_url": "https://assets.protocol.chat/attachments/Invoice_room_service__Plaza_Hotel.pdf",
|
||||||
|
"file_type": "application/pdf",
|
||||||
|
"file_size": 21352,
|
||||||
|
"created_at": 692233200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hSIhXBhNe8X1d8Et"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create an attachment {{ tag: 'POST', label: '/v1/attachments' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to upload a new attachment to a conversation. See the code examples for how to send the file to the Protocol API.
|
||||||
|
|
||||||
|
### Required attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="file" type="string">
|
||||||
|
The file you want to add as an attachment.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="POST" label="/v1/attachments">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/attachments \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-F file="../Invoice_room_service__Plaza_Hotel.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.attachments.create({ file })
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.attachments.create(file=file)
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->attachments->create([
|
||||||
|
'file' => $file,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "Nc6yKKMpcxiiFxp6",
|
||||||
|
"message_id": "LoPsJaMcPBuFNjg1",
|
||||||
|
"filename": "Invoice_room_service__Plaza_Hotel.pdf",
|
||||||
|
"file_url": "https://assets.protocol.chat/attachments/Invoice_room_service__Plaza_Hotel.pdf",
|
||||||
|
"file_type": "application/pdf",
|
||||||
|
"file_size": 21352,
|
||||||
|
"created_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieve an attachment {{ tag: 'GET', label: '/v1/attachments/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve an attachment by providing the attachment id. Refer to [the list](#the-attachment-model) at the top of this page to see which properties are included with attachment objects.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/attachments/Nc6yKKMpcxiiFxp6">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/attachments/Nc6yKKMpcxiiFxp6 \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.attachments.get('Nc6yKKMpcxiiFxp6')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.attachments.get("Nc6yKKMpcxiiFxp6")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->attachments->get('Nc6yKKMpcxiiFxp6');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "Nc6yKKMpcxiiFxp6",
|
||||||
|
"message_id": "LoPsJaMcPBuFNjg1",
|
||||||
|
"filename": "Invoice_room_service__Plaza_Hotel.pdf",
|
||||||
|
"file_url": "https://assets.protocol.chat/attachments/Invoice_room_service__Plaza_Hotel.pdf",
|
||||||
|
"file_type": "application/pdf",
|
||||||
|
"file_size": 21352,
|
||||||
|
"created_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update an attachment {{ tag: 'PUT', label: '/v1/attachments/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to perform an update on an attachment. Currently, the only supported type of update is changing the filename.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="filename" type="string">
|
||||||
|
The new filename for the attachment.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="PUT" label="/v1/attachments/Nc6yKKMpcxiiFxp6">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X PUT https://api.protocol.chat/v1/attachments/Nc6yKKMpcxiiFxp6 \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d filename="Invoice_room_service__Plaza_Hotel_updated.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.attachments.update('Nc6yKKMpcxiiFxp6', {
|
||||||
|
filename: 'Invoice_room_service__Plaza_Hotel_updated.pdf',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.attachments.update("Nc6yKKMpcxiiFxp6", filename="Invoice_room_service__Plaza_Hotel_updated.pdf")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->attachments->update('Nc6yKKMpcxiiFxp6', [
|
||||||
|
'filename' => 'Invoice_room_service__Plaza_Hotel_updated.pdf',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "Nc6yKKMpcxiiFxp6",
|
||||||
|
"message_id": "LoPsJaMcPBuFNjg1",
|
||||||
|
"filename": "Invoice_room_service__Plaza_Hotel.pdf",
|
||||||
|
"file_url": "https://assets.protocol.chat/attachments/Invoice_room_service__Plaza_Hotel_updated.pdf",
|
||||||
|
"file_type": "application/pdf",
|
||||||
|
"file_size": 21352,
|
||||||
|
"created_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete an attachment {{ tag: 'DELETE', label: '/v1/attachments/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to delete attachments. Note: This will permanently delete the file.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="DELETE" label="/v1/attachments/Nc6yKKMpcxiiFxp6">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X DELETE https://api.protocol.chat/v1/attachments/Nc6yKKMpcxiiFxp6 \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.attachments.delete('Nc6yKKMpcxiiFxp6')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.attachments.delete("Nc6yKKMpcxiiFxp6")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->attachments->delete('Nc6yKKMpcxiiFxp6');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
41
app/authentication/page.mdx
Normal file
41
app/authentication/page.mdx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Authentication',
|
||||||
|
description:
|
||||||
|
'In this guide, we’ll look at how authentication works. Protocol offers two ways to authenticate your API requests: Basic authentication and OAuth2 with a token.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
|
||||||
|
You'll need to authenticate your requests to access any of the endpoints in the Protocol API. In this guide, we'll look at how authentication works. Protocol offers two ways to authenticate your API requests: Basic authentication and OAuth2 with a token — OAuth2 is the recommended way. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
## Basic authentication
|
||||||
|
|
||||||
|
With basic authentication, you use your username and password to authenticate your HTTP requests. Unless you have a very good reason, you probably shouldn't use basic auth. Here's how to authenticate using cURL:
|
||||||
|
|
||||||
|
```bash {{ title: 'Example request with basic auth' }}
|
||||||
|
curl https://api.protocol.chat/v1/conversations \
|
||||||
|
-u username:password
|
||||||
|
```
|
||||||
|
|
||||||
|
Please don't commit your Protocol password to GitHub!
|
||||||
|
|
||||||
|
## OAuth2 with bearer token
|
||||||
|
|
||||||
|
The recommended way to authenticate with the Protocol API is by using OAuth2. When establishing a connection using OAuth2, you will need your access token — you will find it in the [Protocol dashboard](#) under API settings. Here's how to add the token to the request header using cURL:
|
||||||
|
|
||||||
|
```bash {{ title: 'Example request with bearer token' }}
|
||||||
|
curl https://api.protocol.chat/v1/conversations \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Always keep your token safe and reset it if you suspect it has been compromised.
|
||||||
|
|
||||||
|
## Using an SDK
|
||||||
|
|
||||||
|
If you use one of our official SDKs, you won't have to worry about any of the above — fetch your access token from the [Protocol dashboard](#) under API settings, and the client library will take care of the rest. All the client libraries use OAuth2 behind the scenes.
|
||||||
|
|
||||||
|
<div className="not-prose">
|
||||||
|
<Button href="/sdks" variant="text" arrow="right">
|
||||||
|
<>Check out our list of first-party SDKs</>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
394
app/contacts/page.mdx
Normal file
394
app/contacts/page.mdx
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Contacts',
|
||||||
|
description:
|
||||||
|
'On this page, we’ll dive into the different contact endpoints you can use to manage contacts programmatically.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Contacts
|
||||||
|
|
||||||
|
As the name suggests, contacts are a core part of Protocol — the very reason Protocol exists is so you can have secure conversations with your contacts. On this page, we'll dive into the different contact endpoints you can use to manage contacts programmatically. We'll look at how to query, create, update, and delete contacts. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
## The contact model
|
||||||
|
|
||||||
|
The contact model contains all the information about your contacts, such as their username, avatar, and phone number. It also contains a reference to the conversation between you and the contact and information about when they were last active on Protocol.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="id" type="string">
|
||||||
|
Unique identifier for the contact.
|
||||||
|
</Property>
|
||||||
|
<Property name="username" type="string">
|
||||||
|
The username for the contact.
|
||||||
|
</Property>
|
||||||
|
<Property name="phone_number" type="string">
|
||||||
|
The phone number for the contact.
|
||||||
|
</Property>
|
||||||
|
<Property name="avatar_url" type="string">
|
||||||
|
The avatar image URL for the contact.
|
||||||
|
</Property>
|
||||||
|
<Property name="display_name" type="string">
|
||||||
|
The contact display name in the contact list. By default, this is just the
|
||||||
|
username.
|
||||||
|
</Property>
|
||||||
|
<Property name="conversation_id" type="string">
|
||||||
|
Unique identifier for the conversation associated with the contact.
|
||||||
|
</Property>
|
||||||
|
<Property name="last_active_at" type="timestamp">
|
||||||
|
Timestamp of when the contact was last active on the platform.
|
||||||
|
</Property>
|
||||||
|
<Property name="created_at" type="timestamp">
|
||||||
|
Timestamp of when the contact was created.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List all contacts {{ tag: 'GET', label: '/v1/contacts' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a paginated list of all your contacts. By default, a maximum of ten contacts are shown per page.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="limit" type="integer">
|
||||||
|
Limit the number of contacts returned.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/contacts">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -G https://api.protocol.chat/v1/contacts \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d active=true \
|
||||||
|
-d limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.contacts.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.contacts.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->contacts->list();
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"has_more": false,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "FrankMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||||
|
"display_name": null,
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hSIhXBhNe8X1d8Et"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create a contact {{ tag: 'POST', label: '/v1/contacts' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to add a new contact to your contact list in Protocol. To add a contact, you must provide their Protocol username and phone number.
|
||||||
|
|
||||||
|
### Required attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="username" type="string">
|
||||||
|
The username for the contact.
|
||||||
|
</Property>
|
||||||
|
<Property name="phone_number" type="string">
|
||||||
|
The phone number for the contact.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="avatar_url" type="string">
|
||||||
|
The avatar image URL for the contact.
|
||||||
|
</Property>
|
||||||
|
<Property name="display_name" type="string">
|
||||||
|
The contact display name in the contact list. By default, this is just the username.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="POST" label="/v1/contacts">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/contacts \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d username="FrankMcCallister" \
|
||||||
|
-d phone_number="1-800-759-3000" \
|
||||||
|
-d avatar_url="https://assets.protocol.chat/avatars/frank.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.contacts.create({
|
||||||
|
username: 'FrankMcCallister',
|
||||||
|
phone_number: '1-800-759-3000',
|
||||||
|
avatar_url: 'https://assets.protocol.chat/avatars/frank.jpg',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.contacts.create(
|
||||||
|
username="FrankMcCallister",
|
||||||
|
phone_number="1-800-759-3000",
|
||||||
|
avatar_url="https://assets.protocol.chat/avatars/frank.jpg",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->contacts->create([
|
||||||
|
'username' => 'FrankMcCallister',
|
||||||
|
'phone_number' => '1-800-759-3000',
|
||||||
|
'avatar_url' => 'https://assets.protocol.chat/avatars/frank.jpg',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "FrankMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||||
|
"display_name": null,
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"last_active_at": null,
|
||||||
|
"created_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieve a contact {{ tag: 'GET', label: '/v1/contacts/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a contact by providing their Protocol id. Refer to [the list](#the-contact-model) at the top of this page to see which properties are included with contact objects.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/contacts/WAz8eIbvDR60rouK">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/contacts/WAz8eIbvDR60rouK \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.contacts.get('WAz8eIbvDR60rouK')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.contacts.get("WAz8eIbvDR60rouK")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->contacts->get('WAz8eIbvDR60rouK');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "FrankMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||||
|
"display_name": null,
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update a contact {{ tag: 'PUT', label: '/v1/contacts/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to perform an update on a contact. Currently, the only attribute that can be updated on contacts is the `display_name` attribute which controls how a contact appears in your contact list in Protocol.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="display_name" type="string">
|
||||||
|
The contact display name in the contact list. By default, this is just the username.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="PUT" label="/v1/contacts/WAz8eIbvDR60rouK">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X PUT https://api.protocol.chat/v1/contacts/WAz8eIbvDR60rouK \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d display_name="UncleFrank"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.contacts.update('WAz8eIbvDR60rouK', {
|
||||||
|
display_name: 'UncleFrank',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.contacts.update("WAz8eIbvDR60rouK", display_name="UncleFrank")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->contacts->update('WAz8eIbvDR60rouK', [
|
||||||
|
'display_name' => 'UncleFrank',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "FrankMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||||
|
"display_name": "UncleFrank",
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete a contact {{ tag: 'DELETE', label: '/v1/contacts/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to delete contacts from your contact list in Protocol. Note: This will also delete your conversation with the given contact.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="DELETE" label="/v1/contacts/WAz8eIbvDR60rouK">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X DELETE https://api.protocol.chat/v1/contacts/WAz8eIbvDR60rouK \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.contacts.delete('WAz8eIbvDR60rouK')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.contacts.delete("WAz8eIbvDR60rouK")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->contacts->delete('WAz8eIbvDR60rouK');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
407
app/conversations/page.mdx
Normal file
407
app/conversations/page.mdx
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Conversations',
|
||||||
|
description:
|
||||||
|
'On this page, we’ll dive into the different conversation endpoints you can use to manage conversations programmatically.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Conversations
|
||||||
|
|
||||||
|
Conversations are an essential part of Protocol — they are the containers for the messages between you, your contacts, and groups. On this page, we’ll dive into the different conversation endpoints you can use to manage conversations programmatically. We'll look at how to query, create, update, and delete conversations. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
## The conversation model
|
||||||
|
|
||||||
|
The conversation model contains all the information about the conversations between you and your contacts. In addition, conversations can also be group-based with more than one contact, they can have a pinned message, and they can be muted.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="id" type="string">
|
||||||
|
Unique identifier for the conversation.
|
||||||
|
</Property>
|
||||||
|
<Property name="contact_id" type="string">
|
||||||
|
Unique identifier for the other contact in the conversation.
|
||||||
|
</Property>
|
||||||
|
<Property name="group_id" type="string">
|
||||||
|
Unique identifier for the group that the conversation belongs to.
|
||||||
|
</Property>
|
||||||
|
<Property name="pinned_message_id" type="string">
|
||||||
|
Unique identifier for the pinned message.
|
||||||
|
</Property>
|
||||||
|
<Property name="is_pinned" type="boolean">
|
||||||
|
Whether or not the conversation has been pinned.
|
||||||
|
</Property>
|
||||||
|
<Property name="is_muted" type="boolean">
|
||||||
|
Whether or not the conversation has been muted.
|
||||||
|
</Property>
|
||||||
|
<Property name="last_active_at" type="timestamp">
|
||||||
|
Timestamp of when the conversation was last active.
|
||||||
|
</Property>
|
||||||
|
<Property name="last_opened_at" type="timestamp">
|
||||||
|
Timestamp of when the conversation was last opened by the authenticated
|
||||||
|
user.
|
||||||
|
</Property>
|
||||||
|
<Property name="created_at" type="timestamp">
|
||||||
|
Timestamp of when the conversation was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="archived_at" type="timestamp">
|
||||||
|
Timestamp of when the conversation was archived.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List all conversations {{ tag: 'GET', label: '/v1/conversations' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a paginated list of all your conversations. By default, a maximum of ten conversations are shown per page.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="limit" type="integer">
|
||||||
|
Limit the number of conversations returned.
|
||||||
|
</Property>
|
||||||
|
<Property name="muted" type="boolean">
|
||||||
|
Only show conversations that are muted when set to `true`.
|
||||||
|
</Property>
|
||||||
|
<Property name="archived" type="boolean">
|
||||||
|
Only show conversations that are archived when set to `true`.
|
||||||
|
</Property>
|
||||||
|
<Property name="pinned" type="boolean">
|
||||||
|
Only show conversations that are pinned when set to `true`.
|
||||||
|
</Property>
|
||||||
|
<Property name="group_id" type="string">
|
||||||
|
Only show conversations for the specified group.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/conversations">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -G https://api.protocol.chat/v1/conversations \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.conversations.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.conversations.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->conversations->list();
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"has_more": false,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact_id": "WAz8eIbvDR60rouK",
|
||||||
|
"group_id": null,
|
||||||
|
"pinned_message_id": null,
|
||||||
|
"is_pinned": false,
|
||||||
|
"is_muted": false,
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"last_opened_at": 705103200,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hSIhXBhNe8X1d8Et"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create a conversation {{ tag: 'POST', label: '/v1/conversations' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to add a new conversation between you and a contact or group. A contact or group id is required to create a conversation.
|
||||||
|
|
||||||
|
### Required attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="contact_id" type="string">
|
||||||
|
Unique identifier for the other contact in the conversation.
|
||||||
|
</Property>
|
||||||
|
<Property name="group_id" type="string">
|
||||||
|
Unique identifier for the group that the conversation belongs to.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="POST" label="/v1/conversations">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/conversations \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d 'contact_id'="WAz8eIbvDR60rouK"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.conversations.create({
|
||||||
|
contact_id: 'WAz8eIbvDR60rouK',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.conversations.create(contact_id="WAz8eIbvDR60rouK")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->conversations->create([
|
||||||
|
'contact_id' => 'WAz8eIbvDR60rouK',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact_id": "WAz8eIbvDR60rouK",
|
||||||
|
"group_id": null,
|
||||||
|
"pinned_message_id": null,
|
||||||
|
"is_pinned": false,
|
||||||
|
"is_muted": false,
|
||||||
|
"last_active_at": null,
|
||||||
|
"last_opened_at": null,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieve a conversation {{ tag: 'GET', label: '/v1/conversations/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a conversation by providing the conversation id. Refer to [the list](#the-conversation-model) at the top of this page to see which properties are included with conversation objects.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/conversations/xgQQXg3hrtjh7AvZ">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/conversations/xgQQXg3hrtjh7AvZ \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.conversations.get('xgQQXg3hrtjh7AvZ')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.conversations.get("xgQQXg3hrtjh7AvZ")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->conversations->get('xgQQXg3hrtjh7AvZ');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact_id": "WAz8eIbvDR60rouK",
|
||||||
|
"group_id": null,
|
||||||
|
"pinned_message_id": null,
|
||||||
|
"is_pinned": false,
|
||||||
|
"is_muted": false,
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"last_opened_at": 705103200,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update a conversation {{ tag: 'PUT', label: '/v1/conversations/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to perform an update on a conversation. Examples of updates are pinning a message, muting or archiving the conversation, or pinning the conversation itself.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="pinned_message_id" type="string">
|
||||||
|
Unique identifier for the pinned message.
|
||||||
|
</Property>
|
||||||
|
<Property name="is_pinned" type="boolean">
|
||||||
|
Whether or not the conversation has been pinned.
|
||||||
|
</Property>
|
||||||
|
<Property name="is_muted" type="boolean">
|
||||||
|
Whether or not the conversation has been muted.
|
||||||
|
</Property>
|
||||||
|
<Property name="archived_at" type="timestamp">
|
||||||
|
Timestamp of when the conversation was archived.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="PUT" label="/v1/conversations/xgQQXg3hrtjh7AvZ">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X PUT https://api.protocol.chat/v1/conversations/xgQQXg3hrtjh7AvZ \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d 'is_muted'=true
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.conversations.update('xgQQXg3hrtjh7AvZ', {
|
||||||
|
is_muted: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.conversations.update("xgQQXg3hrtjh7AvZ", is_muted=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->conversations->update('xgQQXg3hrtjh7AvZ', [
|
||||||
|
'is_muted' => true,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact_id": "WAz8eIbvDR60rouK",
|
||||||
|
"group_id": null,
|
||||||
|
"pinned_message_id": null,
|
||||||
|
"is_pinned": false,
|
||||||
|
"is_muted": true,
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"last_opened_at": 705103200,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete a conversation {{ tag: 'DELETE', label: '/v1/conversations/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to delete your conversations in Protocol. Note: This will permanently delete the conversation and all its messages — archive it instead if you want to be able to restore it later.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="DELETE" label="/v1/conversations/xgQQXg3hrtjh7AvZ">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X DELETE https://api.protocol.chat/v1/conversations/xgQQXg3hrtjh7AvZ \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.conversations.delete('xgQQXg3hrtjh7AvZ')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.conversations.delete("xgQQXg3hrtjh7AvZ")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->conversations->delete('xgQQXg3hrtjh7AvZ');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
70
app/errors/page.mdx
Normal file
70
app/errors/page.mdx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Errors',
|
||||||
|
description:
|
||||||
|
'In this guide, we will talk about what happens when something goes wrong while you work with the API.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
|
||||||
|
In this guide, we will talk about what happens when something goes wrong while you work with the API. Mistakes happen, and mostly they will be yours, not ours. Let's look at some status codes and error types you might encounter. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
You can tell if your request was successful by checking the status code when receiving an API response. If a response comes back unsuccessful, you can use the error type and error message to figure out what has gone wrong and do some rudimentary debugging (before contacting support).
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Before reaching out to support with an error, please be aware that 99% of all
|
||||||
|
reported errors are, in fact, user errors. Therefore, please carefully check
|
||||||
|
your code before contacting Protocol support.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status codes
|
||||||
|
|
||||||
|
Here is a list of the different categories of status codes returned by the Protocol API. Use these to understand if a request was successful.
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="2xx">
|
||||||
|
A 2xx status code indicates a successful response.
|
||||||
|
</Property>
|
||||||
|
<Property name="4xx">
|
||||||
|
A 4xx status code indicates a client error — this means it's a _you_
|
||||||
|
problem.
|
||||||
|
</Property>
|
||||||
|
<Property name="5xx">
|
||||||
|
A 5xx status code indicates a server error — you won't be seeing these.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error types
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
Whenever a request is unsuccessful, the Protocol API will return an error response with an error type and message. You can use this information to understand better what has gone wrong and how to fix it. Most of the error messages are pretty helpful and actionable.
|
||||||
|
|
||||||
|
Here is a list of the two error types supported by the Protocol API — use these to understand what you have done wrong.
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="api_error">
|
||||||
|
This means that we made an error, which is highly speculative and unlikely.
|
||||||
|
</Property>
|
||||||
|
<Property name="invalid_request">
|
||||||
|
This means that you made an error, which is much more likely.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
```bash {{ title: "Error response" }}
|
||||||
|
{
|
||||||
|
"type": "api_error",
|
||||||
|
"message": "No way this is happening!?",
|
||||||
|
"documentation_url": "https://protocol.chat/docs/errors/api_error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
448
app/groups/page.mdx
Normal file
448
app/groups/page.mdx
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Groups',
|
||||||
|
description:
|
||||||
|
'On this page, we’ll dive into the different group endpoints you can use to manage groups programmatically.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Groups
|
||||||
|
|
||||||
|
Groups are where communities live in Protocol — they are a collection of contacts you're talking to all at once. On this page, we'll dive into the different group endpoints you can use to manage groups programmatically. We'll look at how to query, create, update, and delete groups. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
## The group model
|
||||||
|
|
||||||
|
The group model contains all the information about your groups, including what contacts are in the group and the group's name, description, and avatar.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="id" type="string">
|
||||||
|
Unique identifier for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="name" type="string">
|
||||||
|
The name for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="description" type="string">
|
||||||
|
The description for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="avatar_url" type="string">
|
||||||
|
The avatar image URL for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="conversation_id" type="string">
|
||||||
|
Unique identifier for the conversation that belongs to the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="contacts" type="array">
|
||||||
|
An array of contact objects that are members of the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="created_at" type="timestamp">
|
||||||
|
Timestamp of when the group was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="archived_at" type="timestamp">
|
||||||
|
Timestamp of when the group was archived.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List all groups {{ tag: 'GET', label: '/v1/groups' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a paginated list of all your groups. By default, a maximum of ten groups are shown per page.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="limit" type="integer">
|
||||||
|
Limit the number of groups returned.
|
||||||
|
</Property>
|
||||||
|
<Property name="archived" type="boolean">
|
||||||
|
Only show groups that are archived when set to `true`.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/groups">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -G https://api.protocol.chat/v1/groups \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.groups.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.groups.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->groups->list();
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"has_more": false,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "l7cGNIBKZiNJ6wqF",
|
||||||
|
"name": "Plaza Hotel",
|
||||||
|
"description": "World-renowned.",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/plazahotel.jpg",
|
||||||
|
"conversation_id": "ZYjVAbCE9g5XRlra",
|
||||||
|
"contacts": [
|
||||||
|
{
|
||||||
|
"username": "Hector"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Cedric"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Hester"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Cliff"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hSIhXBhNe8X1d8Et"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create a group {{ tag: 'POST', label: '/v1/groups' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to create a new group conversation between you and a group of your Protocol contacts.
|
||||||
|
|
||||||
|
### Required attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="name" type="string">
|
||||||
|
The name for the group.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="description" type="string">
|
||||||
|
The description for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="avatar_url" type="string">
|
||||||
|
The avatar image URL for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="contacts" type="array">
|
||||||
|
An array of contact objects that are members of the group.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="POST" label="/v1/groups">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/groups \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d name="Plaza Hotel"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.groups.create({
|
||||||
|
name: 'Plaza Hotel',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.groups.create(name="Plaza Hotel")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->groups->create([
|
||||||
|
'name' => 'Plaza Hotel',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "l7cGNIBKZiNJ6wqF",
|
||||||
|
"name": "Plaza Hotel",
|
||||||
|
"description": null,
|
||||||
|
"avatar_url": null,
|
||||||
|
"conversation_id": "ZYjVAbCE9g5XRlra",
|
||||||
|
"contacts": [],
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieve a group {{ tag: 'GET', label: '/v1/groups/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a group by providing the group id. Refer to [the list](#the-group-model) at the top of this page to see which properties are included with group objects.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/groups/L7cGNIBKZiNJ6wqF">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/groups/L7cGNIBKZiNJ6wqF \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.groups.get('L7cGNIBKZiNJ6wqF')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.groups.get("L7cGNIBKZiNJ6wqF")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->groups->get('L7cGNIBKZiNJ6wqF');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "l7cGNIBKZiNJ6wqF",
|
||||||
|
"name": "Plaza Hotel",
|
||||||
|
"description": "World-renowned.",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/plazahotel.jpg",
|
||||||
|
"conversation_id": "ZYjVAbCE9g5XRlra",
|
||||||
|
"contacts": [
|
||||||
|
{
|
||||||
|
"username": "Hector"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Cedric"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Hester"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Cliff"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update a group {{ tag: 'PUT', label: '/v1/groups/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to perform an update on a group. Examples of updates are changing the name, description, and avatar or adding and removing contacts from the group.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="name" type="string">
|
||||||
|
The new name for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="description" type="string">
|
||||||
|
The new description for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="avatar_url" type="string">
|
||||||
|
The new avatar image URL for the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="contacts" type="array">
|
||||||
|
An array of contact objects that are members of the group.
|
||||||
|
</Property>
|
||||||
|
<Property name="archived_at" type="timestamp">
|
||||||
|
Timestamp of when the group was archived.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="PUT" label="/v1/groups/L7cGNIBKZiNJ6wqF">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X PUT https://api.protocol.chat/v1/groups/L7cGNIBKZiNJ6wqF \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d description="The finest in New York."
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.groups.update('L7cGNIBKZiNJ6wqF', {
|
||||||
|
description: 'The finest in New York.',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.groups.update("L7cGNIBKZiNJ6wqF", description="The finest in New York.")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->groups->update('L7cGNIBKZiNJ6wqF', [
|
||||||
|
'description' => 'The finest in New York.',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "l7cGNIBKZiNJ6wqF",
|
||||||
|
"name": "Plaza Hotel",
|
||||||
|
"description": "The finest in New York.",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/plazahotel.jpg",
|
||||||
|
"conversation_id": "ZYjVAbCE9g5XRlra",
|
||||||
|
"contacts": [
|
||||||
|
{
|
||||||
|
"username": "Hector"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Cedric"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Hester"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "Cliff"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": 692233200,
|
||||||
|
"archived_at": null
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete a group {{ tag: 'DELETE', label: '/v1/groups/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to delete groups. Note: This will permanently delete the group, including the messages — archive it instead if you want to be able to restore it later.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="DELETE" label="/v1/groups/L7cGNIBKZiNJ6wqF">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X DELETE https://api.protocol.chat/v1/groups/L7cGNIBKZiNJ6wqF \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.groups.delete('L7cGNIBKZiNJ6wqF')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.groups.delete("L7cGNIBKZiNJ6wqF")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->groups->delete('L7cGNIBKZiNJ6wqF');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
43
app/layout.tsx
Normal file
43
app/layout.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import glob from "fast-glob";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import type { Section } from "../components/SectionProvider";
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
|
import "@/styles/tailwind.css";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: "%s - Protocol API Reference",
|
||||||
|
default: "Protocol API Reference",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const pages = await glob("**/*.mdx", { cwd: "app" });
|
||||||
|
const allSectionsEntries = (await Promise.all(
|
||||||
|
pages.map(async (filename) => [
|
||||||
|
`/${filename.replace(/(^|\/)page\.mdx$/, "")}`,
|
||||||
|
(await import(`./${filename}`)).sections,
|
||||||
|
]),
|
||||||
|
)) as [string, Section[]][];
|
||||||
|
const allSections = Object.fromEntries(allSectionsEntries);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en" className="h-full" suppressHydrationWarning={true}>
|
||||||
|
<body className="flex min-h-full bg-white antialiased dark:bg-zinc-900">
|
||||||
|
<Providers>
|
||||||
|
<div className="w-full">
|
||||||
|
<Layout allSections={allSections}>{children}</Layout>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
441
app/messages/page.mdx
Normal file
441
app/messages/page.mdx
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Messages',
|
||||||
|
description:
|
||||||
|
'On this page, we’ll dive into the different message endpoints you can use to manage messages programmatically.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Messages
|
||||||
|
|
||||||
|
Messages are what conversations are made of in Protocol — they are the basic building blocks of your conversations with your Protocol contacts. On this page, we'll dive into the different message endpoints you can use to manage messages programmatically. We'll look at how to query, send, update, and delete messages. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
## The message model
|
||||||
|
|
||||||
|
The message model contains all the information about the messages and attachments you send to your contacts and groups, including how your contacts have reacted to them.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="id" type="string">
|
||||||
|
Unique identifier for the message.
|
||||||
|
</Property>
|
||||||
|
<Property name="conversation_id" type="string">
|
||||||
|
Unique identifier for the conversation the message belongs to.
|
||||||
|
</Property>
|
||||||
|
<Property name="contact" type="object">
|
||||||
|
The contact object for the contact who sent the message.
|
||||||
|
</Property>
|
||||||
|
<Property name="message" type="string">
|
||||||
|
The message content.
|
||||||
|
</Property>
|
||||||
|
<Property name="reactions" type="array">
|
||||||
|
An array of reaction objects associated with the message.
|
||||||
|
</Property>
|
||||||
|
<Property name="attachments" type="array">
|
||||||
|
An array of attachment objects associated with the message.
|
||||||
|
</Property>
|
||||||
|
<Property name="read_at" type="timestamp">
|
||||||
|
Timestamp of when the message was read.
|
||||||
|
</Property>
|
||||||
|
<Property name="created_at" type="timestamp">
|
||||||
|
Timestamp of when the message was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="updated_at" type="timestamp">
|
||||||
|
Timestamp of when the message was last updated.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List all messages {{ tag: 'GET', label: '/v1/messages' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a paginated list of all your messages (in a conversation if a conversation id is provided). By default, a maximum of ten messages are shown per page.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="conversation_id" type="string">
|
||||||
|
Limit to messages from a given conversation.
|
||||||
|
</Property>
|
||||||
|
<Property name="limit" type="integer">
|
||||||
|
Limit the number of messages returned.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/messages">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -G https://api.protocol.chat/v1/messages \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d conversation_id=xgQQXg3hrtjh7AvZ \
|
||||||
|
-d limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.messages.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.messages.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->messages->list();
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"has_more": false,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "SIuAFUNKdSYHZF2w",
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact": {
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "KevinMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/buzzboy.jpg",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
},
|
||||||
|
"message": "It’s a nice night for a neck injury.",
|
||||||
|
"reactions": [],
|
||||||
|
"attachments": [],
|
||||||
|
"read_at": 705103200,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"updated_at": 692233200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hSIhXBhNe8X1d8Et",
|
||||||
|
// ..
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Send a message {{ tag: 'POST', label: '/v1/messages' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to send a new message to one of your conversations.
|
||||||
|
|
||||||
|
### Required attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="conversation_id" type="string">
|
||||||
|
Unique identifier for the conversation the message belongs to.
|
||||||
|
</Property>
|
||||||
|
<Property name="message" type="string">
|
||||||
|
The message content.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="attachments" type="array">
|
||||||
|
An array of attachment objects associated with the message.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="POST" label="/v1/messages">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/messages \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d conversation_id="xgQQXg3hrtjh7AvZ" \
|
||||||
|
-d message="You’re what the French call ‘les incompetents.’"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.messages.send({
|
||||||
|
conversation_id: 'xgQQXg3hrtjh7AvZ',
|
||||||
|
message: 'You’re what the French call ‘les incompetents.’',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.messages.send(
|
||||||
|
conversation_id="xgQQXg3hrtjh7AvZ",
|
||||||
|
message="You’re what the French call ‘les incompetents.’",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->messages->send([
|
||||||
|
'conversation_id' => 'xgQQXg3hrtjh7AvZ',
|
||||||
|
'message' => 'You’re what the French call ‘les incompetents.’',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "gWqY86BMFRiH5o11",
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact": {
|
||||||
|
"id": "inEIRvzjC6YLMX3o",
|
||||||
|
"username": "LinnieMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/linnie.jpg",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
},
|
||||||
|
"message": "You’re what the French call ‘les incompetents.’",
|
||||||
|
"reactions": [],
|
||||||
|
"attachments": [],
|
||||||
|
"read_at": null,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"updated_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieve a message {{ tag: 'GET', label: '/v1/messages/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to retrieve a message by providing the message id. Refer to [the list](#the-message-model) at the top of this page to see which properties are included with message objects.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="GET" label="/v1/messages/SIuAFUNKdSYHZF2w">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl https://api.protocol.chat/v1/messages/SIuAFUNKdSYHZF2w \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.messages.get('SIuAFUNKdSYHZF2w')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.messages.get("SIuAFUNKdSYHZF2w")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->messages->get('SIuAFUNKdSYHZF2w');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "SIuAFUNKdSYHZF2w",
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact": {
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "KevinMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/kevin.jpg",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
},
|
||||||
|
"message": "I’m traveling with my dad. He’s at a meeting. I hate meetings.",
|
||||||
|
"reactions": [],
|
||||||
|
"attachments": [],
|
||||||
|
"read_at": 705103200,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"updated_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update a message {{ tag: 'PUT', label: '/v1/messages/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to perform an update on a message. Examples of updates are adding a reaction, editing the message, or adding an attachment.
|
||||||
|
|
||||||
|
### Optional attributes
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="message" type="string">
|
||||||
|
The message content.
|
||||||
|
</Property>
|
||||||
|
<Property name="reactions" type="array">
|
||||||
|
An array of reaction objects associated with the message.
|
||||||
|
</Property>
|
||||||
|
<Property name="attachments" type="array">
|
||||||
|
An array of attachment objects associated with the message.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="PUT" label="/v1/messages/SIuAFUNKdSYHZF2w">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X PUT https://api.protocol.chat/v1/messages/SIuAFUNKdSYHZF2w \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d reactions[red_angry_face][]="KateMcCallister"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.messages.update('SIuAFUNKdSYHZF2w', {
|
||||||
|
reactions: {
|
||||||
|
red_angry_face: ['KateMcCallister']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.messages.update("SIuAFUNKdSYHZF2w",
|
||||||
|
reactions={"red_angry_face": ["KateMcCallister"]})
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->messages->update('SIuAFUNKdSYHZF2w', [
|
||||||
|
'reactions' => [
|
||||||
|
'red_angry_face' => ['KateMcCallister'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
```json {{ title: 'Response' }}
|
||||||
|
{
|
||||||
|
"id": "SIuAFUNKdSYHZF2w",
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact": {
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "KevinMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/buzzboy.jpg",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
},
|
||||||
|
"message": "I'm not apologizing. I'd rather kiss a toilet seat.",
|
||||||
|
"reactions": [
|
||||||
|
{
|
||||||
|
"red_angry_face": [
|
||||||
|
"KateMcCallister"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": [],
|
||||||
|
"read_at": 705103200,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"updated_at": 692233200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete a message {{ tag: 'DELETE', label: '/v1/messages/:id' }}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
This endpoint allows you to delete messages from your conversations. Note: This will permanently delete the message.
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
<CodeGroup title="Request" tag="DELETE" label="/v1/messages/SIuAFUNKdSYHZF2w">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -X DELETE https://api.protocol.chat/v1/messages/SIuAFUNKdSYHZF2w \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.messages.delete('SIuAFUNKdSYHZF2w')
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.messages.delete("SIuAFUNKdSYHZF2w")
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->messages->delete('SIuAFUNKdSYHZF2w');
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
24
app/not-found.tsx
Normal file
24
app/not-found.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
import { HeroPattern } from "../components/HeroPattern";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroPattern />
|
||||||
|
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
404
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">
|
||||||
|
Page not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||||
|
Sorry, we couldn’t find the page you’re looking for.
|
||||||
|
</p>
|
||||||
|
<Button href="/" arrow="right" className="mt-8">
|
||||||
|
Back to docs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
app/page.mdx
Normal file
43
app/page.mdx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Guides } from '@/components/Guides'
|
||||||
|
import { Resources } from '@/components/Resources'
|
||||||
|
import { HeroPattern } from '@/components/HeroPattern'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'API Documentation',
|
||||||
|
description:
|
||||||
|
'Learn everything there is to know about the Protocol API and integrate Protocol into your product.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sections = [
|
||||||
|
{ title: 'Guides', id: 'guides' },
|
||||||
|
{ title: 'Resources', id: 'resources' },
|
||||||
|
]
|
||||||
|
|
||||||
|
<HeroPattern />
|
||||||
|
|
||||||
|
# API Documentation
|
||||||
|
|
||||||
|
Use the Protocol API to access contacts, conversations, group messages, and more and seamlessly integrate your product into the workflows of dozens of devoted Protocol users. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
<div className="not-prose mb-16 mt-6 flex gap-3">
|
||||||
|
<Button href="/quickstart" arrow="right">
|
||||||
|
<>Quickstart</>
|
||||||
|
</Button>
|
||||||
|
<Button href="/sdks" variant="outline">
|
||||||
|
<>Explore SDKs</>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Getting started {{ anchor: false }}
|
||||||
|
|
||||||
|
To get started, create a new application in your [developer settings](#), then read about how to make requests for the resources you need to access using our HTTP APIs or dedicated client SDKs. When your integration is ready to go live, publish it to our [integrations directory](#) to reach the Protocol community. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
<div className="not-prose">
|
||||||
|
<Button href="/sdks" variant="text" arrow="right">
|
||||||
|
<>Get your API key</>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Guides />
|
||||||
|
|
||||||
|
<Resources />
|
||||||
63
app/pagination/page.mdx
Normal file
63
app/pagination/page.mdx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Pagination',
|
||||||
|
description:
|
||||||
|
'In this guide, we will look at how to work with paginated responses when querying the Protocol API',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
|
||||||
|
In this guide, we will look at how to work with paginated responses when querying the Protocol API. By default, all responses limit results to ten. However, you can go as high as 100 by adding a `limit` parameter to your requests. If you are using one of the official Protocol API client libraries, you don't need to worry about pagination, as it's all being taken care of behind the scenes. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
When an API response returns a list of objects, no matter the amount, pagination is supported. In paginated responses, objects are nested in a `data` attribute and have a `has_more` attribute that indicates whether you have reached the end of the last page. You can use the `starting_after` and `endding_before` query parameters to browse pages.
|
||||||
|
|
||||||
|
## Example using cursors
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
In this example, we request the page that starts after the conversation with id `s4WycXedwhQrEFuM`. As a result, we get a list of three conversations and can tell by the `has_more` attribute that we have reached the end of the resultset.
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="starting_after" type="string">
|
||||||
|
The last ID on the page you're currently on when you want to fetch the next page.
|
||||||
|
</Property>
|
||||||
|
<Property name="ending_before" type="string">
|
||||||
|
The first ID on the page you're currently on when you want to fetch the previous page.
|
||||||
|
</Property>
|
||||||
|
<Property name="limit" type="integer">
|
||||||
|
Limit the number of items returned.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
```bash {{ title: 'Manual pagination using cURL' }}
|
||||||
|
curl -G https://api.protocol.chat/v1/conversations \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d starting_after="s4WycXedwhQrEFuM" \
|
||||||
|
-d limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```json {{ title: 'Paginated response' }}
|
||||||
|
{
|
||||||
|
"has_more": false,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hSIhXBhNe8X1d8Et"
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fbwYwpi9C2ybt6Yb"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
37
app/providers.tsx
Normal file
37
app/providers.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider, useTheme } from "next-themes";
|
||||||
|
import { type ReactNode, useEffect } from "react";
|
||||||
|
|
||||||
|
function ThemeWatcher() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
function onMediaChange() {
|
||||||
|
const systemTheme = media.matches ? "dark" : "light";
|
||||||
|
if (resolvedTheme === systemTheme) {
|
||||||
|
setTheme("system");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMediaChange();
|
||||||
|
media.addEventListener("change", onMediaChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener("change", onMediaChange);
|
||||||
|
};
|
||||||
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="class" disableTransitionOnChange={true}>
|
||||||
|
<ThemeWatcher />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
app/quickstart/page.mdx
Normal file
98
app/quickstart/page.mdx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Quickstart',
|
||||||
|
description:
|
||||||
|
'This guide will get you all set up and ready to use the Protocol API. We’ll cover how to get started an API client and how to make your first API request.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Quickstart
|
||||||
|
|
||||||
|
This guide will get you all set up and ready to use the Protocol API. We'll cover how to get started using one of our API clients and how to make your first API request. We'll also look at where to go next to find all the information you need to take full advantage of our powerful REST API. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Before you can make requests to the Protocol API, you will need to grab your
|
||||||
|
API key from your dashboard. You find it under [Settings » API](#).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Choose your client
|
||||||
|
|
||||||
|
Before making your first API request, you need to pick which API client you will use. In addition to good ol' cURL HTTP requests, Protocol offers clients for JavaScript, Python, and PHP. In the following example, you can see how to install each client.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
# cURL is most likely already installed on your machine
|
||||||
|
curl --version
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash {{ language: 'js' }}
|
||||||
|
# Install the Protocol JavaScript SDK
|
||||||
|
npm install @example/protocol-api --save
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash {{ language: 'python' }}
|
||||||
|
# Install the Protocol Python SDK
|
||||||
|
pip install protocol_api
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash {{ language: 'php' }}
|
||||||
|
# Install the Protocol PHP SDK
|
||||||
|
composer require protocol/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<div className="not-prose">
|
||||||
|
<Button href="/sdks" variant="text" arrow="right">
|
||||||
|
<>Check out our list of first-party SDKs</>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Making your first API request
|
||||||
|
|
||||||
|
After picking your preferred client, you are ready to make your first call to the Protocol API. Below, you can see how to send a GET request to the Conversations endpoint to get a list of all your conversations. In the cURL example, results are limited to ten conversations, the default page length for each client.
|
||||||
|
|
||||||
|
<CodeGroup tag="GET" label="/v1/conversations">
|
||||||
|
|
||||||
|
```bash {{ title: 'cURL' }}
|
||||||
|
curl -G https://api.protocol.chat/v1/conversations \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import ApiClient from '@example/protocol-api'
|
||||||
|
|
||||||
|
const client = new ApiClient(token)
|
||||||
|
|
||||||
|
await client.conversations.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from protocol_api import ApiClient
|
||||||
|
|
||||||
|
client = ApiClient(token)
|
||||||
|
|
||||||
|
client.conversations.list()
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = new \Protocol\ApiClient($token);
|
||||||
|
|
||||||
|
$client->conversations->list();
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<div className="not-prose">
|
||||||
|
<Button href="/conversations" variant="text" arrow="right">
|
||||||
|
<>Read the docs for the Conversations endpoint</>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## What's next?
|
||||||
|
|
||||||
|
Great, you're now set up with an API client and have made your first request to the API. Here are a few links that might be handy as you venture further into the Protocol API:
|
||||||
|
|
||||||
|
- [Grab your API key from the Protocol dashboard](#)
|
||||||
|
- [Check out the Conversations endpoint](/conversations)
|
||||||
|
- [Learn about the different error messages in Protocol](/errors)
|
||||||
17
app/sdks/page.mdx
Normal file
17
app/sdks/page.mdx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Libraries } from '@/components/Libraries'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Protocol SDKs',
|
||||||
|
description:
|
||||||
|
'Protocol offers fine-tuned JavaScript, Ruby, PHP, Python, and Go libraries to make your life easier and give you the best experience when consuming the API.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sections = [
|
||||||
|
{ title: 'Official libraries', id: 'official-libraries' },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Protocol SDKs
|
||||||
|
|
||||||
|
The recommended way to interact with the Protocol API is by using one of our official SDKs. Today, Protocol offers fine-tuned JavaScript, Ruby, PHP, Python, and Go libraries to make your life easier and give you the best experience when consuming the API. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
<Libraries />
|
||||||
172
app/webhooks/page.mdx
Normal file
172
app/webhooks/page.mdx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Webhooks',
|
||||||
|
description:
|
||||||
|
'In this guide, we will look at how to register and consume webhooks to integrate your app with Protocol.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Webhooks
|
||||||
|
|
||||||
|
In this guide, we will look at how to register and consume webhooks to integrate your app with Protocol. With webhooks, your app can know when something happens in Protocol, such as someone sending a message or adding a contact. {{ className: 'lead' }}
|
||||||
|
|
||||||
|
## Registering webhooks
|
||||||
|
|
||||||
|
To register a new webhook, you need to have a URL in your app that Protocol can call. You can configure a new webhook from the Protocol dashboard under [API settings](#). Give your webhook a name, pick the [events](#event-types) you want to listen for, and add your URL.
|
||||||
|
|
||||||
|
Now, whenever something of interest happens in your app, a webhook is fired off by Protocol. In the next section, we'll look at how to consume webhooks.
|
||||||
|
|
||||||
|
## Consuming webhooks
|
||||||
|
|
||||||
|
When your app receives a webhook request from Protocol, check the `type` attribute to see what event caused it. The first part of the event type will tell you the payload type, e.g., a conversation, message, etc.
|
||||||
|
|
||||||
|
```json {{ title: 'Example webhook payload' }}
|
||||||
|
{
|
||||||
|
"id": "a056V7R7NmNRjl70",
|
||||||
|
"type": "conversation.updated",
|
||||||
|
"payload": {
|
||||||
|
"id": "WAz8eIbvDR60rouK"
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the example above, a conversation was `updated`, and the payload type is a `conversation`.
|
||||||
|
|
||||||
|
<div className="not-prose">
|
||||||
|
<Button href="#event-types" variant="text" arrow="right">
|
||||||
|
<>See all event types</>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event types
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<Property name="contact.created">
|
||||||
|
A new contact was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="contact.updated">
|
||||||
|
An existing contact was updated.
|
||||||
|
</Property>
|
||||||
|
<Property name="contact.deleted">
|
||||||
|
A contact was successfully deleted.
|
||||||
|
</Property>
|
||||||
|
<Property name="conversation.created">
|
||||||
|
A new conversation was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="conversation.updated">
|
||||||
|
An existing conversation was updated.
|
||||||
|
</Property>
|
||||||
|
<Property name="conversation.deleted">
|
||||||
|
A conversation was successfully deleted.
|
||||||
|
</Property>
|
||||||
|
<Property name="message.created">
|
||||||
|
A new message was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="message.updated">
|
||||||
|
An existing message was updated.
|
||||||
|
</Property>
|
||||||
|
<Property name="message.deleted">
|
||||||
|
A message was successfully deleted.
|
||||||
|
</Property>
|
||||||
|
<Property name="group.created">
|
||||||
|
A new group was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="group.updated">
|
||||||
|
An existing group was updated.
|
||||||
|
</Property>
|
||||||
|
<Property name="group.deleted">
|
||||||
|
A group was successfully deleted.
|
||||||
|
</Property>
|
||||||
|
<Property name="attachment.created">
|
||||||
|
A new attachment was created.
|
||||||
|
</Property>
|
||||||
|
<Property name="attachment.updated">
|
||||||
|
An existing attachment was updated.
|
||||||
|
</Property>
|
||||||
|
<Property name="attachment.deleted">
|
||||||
|
An attachment was successfully deleted.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
<Col sticky>
|
||||||
|
|
||||||
|
```json {{ 'title': 'Example payload' }}
|
||||||
|
{
|
||||||
|
"id": "a056V7R7NmNRjl70",
|
||||||
|
"type": "message.updated",
|
||||||
|
"payload": {
|
||||||
|
"id": "SIuAFUNKdSYHZF2w",
|
||||||
|
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||||
|
"contact": {
|
||||||
|
"id": "WAz8eIbvDR60rouK",
|
||||||
|
"username": "KevinMcCallister",
|
||||||
|
"phone_number": "1-800-759-3000",
|
||||||
|
"avatar_url": "https://assets.protocol.chat/avatars/kevin.jpg",
|
||||||
|
"last_active_at": 705103200,
|
||||||
|
"created_at": 692233200
|
||||||
|
},
|
||||||
|
"message": "I’m traveling with my dad. He’s at a meeting. I hate meetings.",
|
||||||
|
"reactions": [],
|
||||||
|
"attachments": [],
|
||||||
|
"read_at": 705103200,
|
||||||
|
"created_at": 692233200,
|
||||||
|
"updated_at": 692233200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
To know for sure that a webhook was, in fact, sent by Protocol instead of a malicious actor, you can verify the request signature. Each webhook request contains a header named `x-protocol-signature`, and you can verify this signature by using your secret webhook key. The signature is an HMAC hash of the request payload hashed using your secret key. Here is an example of how to verify the signature in your app:
|
||||||
|
|
||||||
|
<CodeGroup title="Verifying a request">
|
||||||
|
|
||||||
|
```js
|
||||||
|
const signature = req.headers['x-protocol-signature']
|
||||||
|
const hash = crypto.createHmac('sha256', secret).update(payload).digest('hex')
|
||||||
|
|
||||||
|
if (hash === signature) {
|
||||||
|
// Request is verified
|
||||||
|
} else {
|
||||||
|
// Request could not be verified
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import request
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
signature = request.headers.get("x-protocol-signature")
|
||||||
|
hash = hmac.new(bytes(secret, "ascii"), bytes(payload, "ascii"), hashlib.sha256)
|
||||||
|
|
||||||
|
if hash.hexdigest() == signature:
|
||||||
|
# Request is verified
|
||||||
|
else:
|
||||||
|
# Request could not be verified
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$signature = $request['headers']['x-protocol-signature'];
|
||||||
|
$hash = hash_hmac('sha256', $payload, $secret);
|
||||||
|
|
||||||
|
if (hash_equals($hash, $signature)) {
|
||||||
|
// Request is verified
|
||||||
|
} else {
|
||||||
|
// Request could not be verified
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
If your generated signature matches the `x-protocol-signature` header, you can be sure that the request was truly coming from Protocol. It's essential to keep your secret webhook key safe — otherwise, you can no longer be sure that a given webhook was sent by Protocol. Don't commit your secret webhook key to GitHub!
|
||||||
87
biome.json
87
biome.json
|
|
@ -1,20 +1,91 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"ignore": ["node_modules", "dist", "cache"]
|
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"all": true,
|
||||||
},
|
"correctness": {
|
||||||
"ignore": ["node_modules", "dist", "cache"]
|
"noNodejsModules": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noExcessiveCognitiveComplexity": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off",
|
||||||
|
"noParameterProperties": "off",
|
||||||
|
"noNamespaceImport": "off",
|
||||||
|
"useFilenamingConvention": "off",
|
||||||
|
"useNamingConvention": {
|
||||||
|
"level": "warn",
|
||||||
|
"options": {
|
||||||
|
"requireAscii": false,
|
||||||
|
"strictCase": false,
|
||||||
|
"conventions": [
|
||||||
|
{
|
||||||
|
"selector": {
|
||||||
|
"kind": "typeProperty"
|
||||||
|
},
|
||||||
|
"formats": [
|
||||||
|
"camelCase",
|
||||||
|
"CONSTANT_CASE",
|
||||||
|
"PascalCase",
|
||||||
|
"snake_case"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": {
|
||||||
|
"kind": "objectLiteralProperty",
|
||||||
|
"scope": "any"
|
||||||
|
},
|
||||||
|
"formats": [
|
||||||
|
"camelCase",
|
||||||
|
"CONSTANT_CASE",
|
||||||
|
"PascalCase",
|
||||||
|
"snake_case"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": {
|
||||||
|
"kind": "classMethod",
|
||||||
|
"scope": "any"
|
||||||
|
},
|
||||||
|
"formats": ["camelCase", "PascalCase"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": {
|
||||||
|
"kind": "functionParameter",
|
||||||
|
"scope": "any"
|
||||||
|
},
|
||||||
|
"formats": ["snake_case", "camelCase"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"noDuplicateElseIf": "warn",
|
||||||
|
"noDuplicateJsonKeys": "warn",
|
||||||
|
"noEvolvingTypes": "warn",
|
||||||
|
"noYodaExpression": "warn",
|
||||||
|
"useConsistentBuiltinInstantiation": "warn",
|
||||||
|
"useErrorMessage": "warn",
|
||||||
|
"useImportExtensions": "off",
|
||||||
|
"useThrowNewError": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4
|
||||||
"ignore": ["node_modules", "dist", "cache"]
|
},
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["Bun"]
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": ["node_modules", ".next", ".output"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const currentNews = {
|
|
||||||
id: "lysand3",
|
|
||||||
title: "Lysand 3.0",
|
|
||||||
description: "Lysand 3.0 is now available!",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="fixed inset-x-0 top-0 flex items-center h-10 gap-x-6 overflow-hidden bg-black px-4 py-2.5 sm:px-3.5 sm:before:flex-1 z-50">
|
|
||||||
<div class="flex flex-wrap justify-center gap-x-4 gap-y-2 w-full">
|
|
||||||
<p class="text-sm text-gray-50">
|
|
||||||
<strong class="font-semibold">{{
|
|
||||||
currentNews.title
|
|
||||||
}}</strong> • {{ currentNews.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
82
components/Button.tsx
Normal file
82
components/Button.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
function ArrowIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m11.5 6.5 3 3.5m0 0-3 3.5m3-3.5h-9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
primary:
|
||||||
|
"rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-1 dark:ring-inset dark:ring-emerald-400/20 dark:hover:bg-emerald-400/10 dark:hover:text-emerald-300 dark:hover:ring-emerald-300",
|
||||||
|
secondary:
|
||||||
|
"rounded-full bg-zinc-100 py-1 px-3 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800/40 dark:text-zinc-400 dark:ring-1 dark:ring-inset dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300",
|
||||||
|
filled: "rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-emerald-500 dark:text-white dark:hover:bg-emerald-400",
|
||||||
|
outline:
|
||||||
|
"rounded-full py-1 px-3 text-zinc-700 ring-1 ring-inset ring-zinc-900/10 hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-white/5 dark:hover:text-white",
|
||||||
|
text: "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ButtonProps = {
|
||||||
|
variant?: keyof typeof variantStyles;
|
||||||
|
arrow?: "left" | "right";
|
||||||
|
} & (
|
||||||
|
| ComponentPropsWithoutRef<typeof Link>
|
||||||
|
| (ComponentPropsWithoutRef<"button"> & { href?: undefined })
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
arrow,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
className = clsx(
|
||||||
|
"inline-flex gap-0.5 justify-center overflow-hidden text-sm font-medium transition",
|
||||||
|
variantStyles[variant],
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
|
||||||
|
const arrowIcon = (
|
||||||
|
<ArrowIcon
|
||||||
|
className={clsx(
|
||||||
|
"mt-0.5 h-5 w-5",
|
||||||
|
variant === "text" && "relative top-px",
|
||||||
|
arrow === "left" && "-ml-1 rotate-180",
|
||||||
|
arrow === "right" && "-mr-1",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
{arrow === "left" && arrowIcon}
|
||||||
|
{children}
|
||||||
|
{arrow === "right" && arrowIcon}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof props.href === "undefined") {
|
||||||
|
return (
|
||||||
|
<button className={className} {...props}>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={className} {...props}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
components/Code.tsx
Normal file
393
components/Code.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ReactNode,
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { Tag } from "./Tag";
|
||||||
|
|
||||||
|
const languageNames: Record<string, string> = {
|
||||||
|
js: "JavaScript",
|
||||||
|
ts: "TypeScript",
|
||||||
|
javascript: "JavaScript",
|
||||||
|
typescript: "TypeScript",
|
||||||
|
php: "PHP",
|
||||||
|
python: "Python",
|
||||||
|
ruby: "Ruby",
|
||||||
|
go: "Go",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPanelTitle({
|
||||||
|
title,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
language?: string;
|
||||||
|
}) {
|
||||||
|
if (title) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
if (language && language in languageNames) {
|
||||||
|
return languageNames[language];
|
||||||
|
}
|
||||||
|
return "Code";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClipboardIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
d="M5.5 13.5v-5a2 2 0 0 1 2-2l.447-.894A2 2 0 0 1 9.737 4.5h.527a2 2 0 0 1 1.789 1.106l.447.894a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12.5 6.5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m5 0-.447-.894a2 2 0 0 0-1.79-1.106h-.527a2 2 0 0 0-1.789 1.106L7.5 6.5m5 0-1 1h-3l-1-1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ code }: { code: string }) {
|
||||||
|
const [copyCount, setCopyCount] = useState(0);
|
||||||
|
const copied = copyCount > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (copyCount > 0) {
|
||||||
|
const timeout = setTimeout(() => setCopyCount(0), 1000);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [copyCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx(
|
||||||
|
"group/button absolute right-4 top-3.5 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100",
|
||||||
|
copied
|
||||||
|
? "bg-emerald-400/10 ring-1 ring-inset ring-emerald-400/20"
|
||||||
|
: "bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
window.navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopyCount((count) => count + 1);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden={copied}
|
||||||
|
className={clsx(
|
||||||
|
"pointer-events-none flex items-center gap-0.5 text-zinc-400 transition duration-300",
|
||||||
|
copied && "-translate-y-1.5 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardIcon className="h-5 w-5 fill-zinc-500/20 stroke-zinc-500 transition-colors group-hover/button:stroke-zinc-400" />
|
||||||
|
Copy
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden={!copied}
|
||||||
|
className={clsx(
|
||||||
|
"pointer-events-none absolute inset-0 flex items-center justify-center text-emerald-400 transition duration-300",
|
||||||
|
!copied && "translate-y-1.5 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Copied!
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) {
|
||||||
|
if (!(tag || label)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center gap-2 border-y border-b-white/7.5 border-t-transparent bg-white/2.5 bg-zinc-900 px-4 dark:border-b-white/5 dark:bg-white/1">
|
||||||
|
{tag && (
|
||||||
|
<div className="dark flex">
|
||||||
|
<Tag variant="small">{tag}</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tag && label && (
|
||||||
|
<span className="h-0.5 w-0.5 rounded-full bg-zinc-500" />
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodePanel({
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
label,
|
||||||
|
code,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
tag?: string;
|
||||||
|
label?: string;
|
||||||
|
code?: string;
|
||||||
|
}) {
|
||||||
|
const child = Children.only(children);
|
||||||
|
|
||||||
|
if (isValidElement(child)) {
|
||||||
|
tag = child.props.tag ?? tag;
|
||||||
|
label = child.props.label ?? label;
|
||||||
|
code = child.props.code ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error(
|
||||||
|
"`CodePanel` requires a `code` prop, or a child with a `code` prop.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group dark:bg-white/2.5">
|
||||||
|
<CodePanelHeader tag={tag} label={label} />
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="overflow-x-auto p-4 text-xs text-white">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
<CopyButton code={code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeGroupHeader({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
selectedIndex,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
selectedIndex: number;
|
||||||
|
}) {
|
||||||
|
const hasTabs = Children.count(children) > 1;
|
||||||
|
|
||||||
|
if (!(title || hasTabs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(theme(spacing.12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent">
|
||||||
|
{title && (
|
||||||
|
<h3 className="mr-auto pt-3 text-xs font-semibold text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{hasTabs && (
|
||||||
|
<TabList className="-mb-px flex gap-4 text-xs font-medium">
|
||||||
|
{Children.map(children, (child, childIndex) => (
|
||||||
|
<Tab
|
||||||
|
className={clsx(
|
||||||
|
"border-b py-3 transition ui-not-focus-visible:outline-none",
|
||||||
|
childIndex === selectedIndex
|
||||||
|
? "border-emerald-500 text-emerald-400"
|
||||||
|
: "border-transparent text-zinc-400 hover:text-zinc-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getPanelTitle(
|
||||||
|
isValidElement(child) ? child.props : {},
|
||||||
|
)}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeGroupPanels({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<typeof CodePanel>) {
|
||||||
|
const hasTabs = Children.count(children) > 1;
|
||||||
|
|
||||||
|
if (hasTabs) {
|
||||||
|
return (
|
||||||
|
<TabPanels>
|
||||||
|
{Children.map(children, (child) => (
|
||||||
|
<TabPanel>
|
||||||
|
<CodePanel {...props}>{child}</CodePanel>
|
||||||
|
</TabPanel>
|
||||||
|
))}
|
||||||
|
</TabPanels>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CodePanel {...props}>{children}</CodePanel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePreventLayoutShift() {
|
||||||
|
const positionRef = useRef<HTMLElement>(null);
|
||||||
|
const rafRef = useRef<number>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typeof rafRef.current !== "undefined") {
|
||||||
|
window.cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
positionRef,
|
||||||
|
preventLayoutShift(callback: () => void) {
|
||||||
|
if (!positionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTop = positionRef.current.getBoundingClientRect().top;
|
||||||
|
|
||||||
|
callback();
|
||||||
|
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
const newTop =
|
||||||
|
positionRef.current?.getBoundingClientRect().top ??
|
||||||
|
initialTop;
|
||||||
|
window.scrollBy(0, newTop - initialTop);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usePreferredLanguageStore = create<{
|
||||||
|
preferredLanguages: string[];
|
||||||
|
addPreferredLanguage: (language: string) => void;
|
||||||
|
}>()((set) => ({
|
||||||
|
preferredLanguages: [],
|
||||||
|
addPreferredLanguage: (language) =>
|
||||||
|
set((state) => ({
|
||||||
|
preferredLanguages: [
|
||||||
|
...state.preferredLanguages.filter(
|
||||||
|
(preferredLanguage) => preferredLanguage !== language,
|
||||||
|
),
|
||||||
|
language,
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function useTabGroupProps(availableLanguages: string[]) {
|
||||||
|
const { preferredLanguages, addPreferredLanguage } =
|
||||||
|
usePreferredLanguageStore();
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const activeLanguage = [...availableLanguages].sort(
|
||||||
|
(a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a),
|
||||||
|
)[0];
|
||||||
|
const languageIndex = availableLanguages.indexOf(activeLanguage);
|
||||||
|
const newSelectedIndex =
|
||||||
|
languageIndex === -1 ? selectedIndex : languageIndex;
|
||||||
|
if (newSelectedIndex !== selectedIndex) {
|
||||||
|
setSelectedIndex(newSelectedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { positionRef, preventLayoutShift } = usePreventLayoutShift();
|
||||||
|
|
||||||
|
return {
|
||||||
|
as: "div" as const,
|
||||||
|
ref: positionRef,
|
||||||
|
selectedIndex,
|
||||||
|
onChange: (newSelectedIndex: number) => {
|
||||||
|
preventLayoutShift(() =>
|
||||||
|
addPreferredLanguage(availableLanguages[newSelectedIndex]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeGroupContext = createContext(false);
|
||||||
|
|
||||||
|
export function CodeGroup({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<typeof CodeGroupPanels> & { title: string }) {
|
||||||
|
const languages =
|
||||||
|
Children.map(children, (child) =>
|
||||||
|
getPanelTitle(isValidElement(child) ? child.props : {}),
|
||||||
|
) ?? [];
|
||||||
|
const tabGroupProps = useTabGroupProps(languages);
|
||||||
|
const hasTabs = Children.count(children) > 1;
|
||||||
|
|
||||||
|
const containerClassName =
|
||||||
|
"my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10";
|
||||||
|
const header = (
|
||||||
|
<CodeGroupHeader
|
||||||
|
title={title}
|
||||||
|
selectedIndex={tabGroupProps.selectedIndex}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CodeGroupHeader>
|
||||||
|
);
|
||||||
|
const panels = <CodeGroupPanels {...props}>{children}</CodeGroupPanels>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeGroupContext.Provider value={true}>
|
||||||
|
{hasTabs ? (
|
||||||
|
<TabGroup {...tabGroupProps} className={containerClassName}>
|
||||||
|
<div className="not-prose">
|
||||||
|
{header}
|
||||||
|
{panels}
|
||||||
|
</div>
|
||||||
|
</TabGroup>
|
||||||
|
) : (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<div className="not-prose">
|
||||||
|
{header}
|
||||||
|
{panels}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CodeGroupContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Code({ children, ...props }: ComponentPropsWithoutRef<"code">) {
|
||||||
|
const isGrouped = useContext(CodeGroupContext);
|
||||||
|
|
||||||
|
if (isGrouped) {
|
||||||
|
if (typeof children !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
"`Code` children must be a string when nested inside a `CodeGroup`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation>
|
||||||
|
// biome-ignore lint/style/useNamingConvention: <explanation>
|
||||||
|
<code {...props} dangerouslySetInnerHTML={{ __html: children }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <code {...props}>{children}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pre({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<typeof CodeGroup>) {
|
||||||
|
const isGrouped = useContext(CodeGroupContext);
|
||||||
|
|
||||||
|
if (isGrouped) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CodeGroup {...props}>{children}</CodeGroup>;
|
||||||
|
}
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="mt-12">
|
|
||||||
<div class="max-w-3xl">
|
|
||||||
<h1>Made by developers</h1>
|
|
||||||
<p>
|
|
||||||
Lysand is designed and maintained by the developers of the Lysand Server, which uses Lysand for
|
|
||||||
federation. This community could include you! Check out our <a
|
|
||||||
href="https://github.com/lysand-org/lysand">Git repository</a> to see how you can contribute.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="!mt-8 grid items-start gap-x-6 gap-y-6 sm:mt-16 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 lg:gap-x-8">
|
|
||||||
<div v-for="feature in features" :key="feature.name"
|
|
||||||
class="flex flex-row h-32 p-5 items-center gap-x-4 bg-[var(--vp-c-bg-soft)] shadow rounded duration-200 hover:ring-2 hover:scale-[101%] ring-[var(--vp-color-primary)]">
|
|
||||||
<div class="aspect-square flex items-center justify-center overflow-hidden rounded shrink-0 h-full">
|
|
||||||
<iconify-icon :icon="feature.icon" class="text-[var(--vp-color-primary)] text-5xl" />
|
|
||||||
</div>
|
|
||||||
<div class="text-pretty">
|
|
||||||
<h3 class="!text-base font-medium !mt-0">{{ feature.name }}</h3>
|
|
||||||
<p class="!mt-1 !mb-0 !text-sm">{{ feature.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
name: "JSON-based APIs",
|
|
||||||
description: "Simple JSON objects are used to represent all data.",
|
|
||||||
icon: "bx:bx-code-alt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MIT Licensed",
|
|
||||||
description:
|
|
||||||
"Lysand is licensed under the MIT License, which allows you to use it for any purpose.",
|
|
||||||
icon: "bx:bx-shield",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Built-in namespaced extensions",
|
|
||||||
description:
|
|
||||||
"Extensions for common use cases are built-in, such as custom emojis and reactions",
|
|
||||||
icon: "bx:bx-extension",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Easy to implement",
|
|
||||||
description:
|
|
||||||
"Lysand is designed to be easy to implement in any language.",
|
|
||||||
icon: "bx:bx-code-block",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Secure by default",
|
|
||||||
description:
|
|
||||||
"All requests are signed using advanced cryptographic algorithms.",
|
|
||||||
icon: "bx:bx-shield-alt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No Mastodon Situation",
|
|
||||||
description:
|
|
||||||
"Standardization is heavy and designed to break vendor lock-in.",
|
|
||||||
icon: "bx:bx-code-curly",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "In-Depth Security Docs",
|
|
||||||
description:
|
|
||||||
"Docs provide lots of information on how to program a secure server.",
|
|
||||||
icon: "bx:bx-shield-x",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "TypeScript Types",
|
|
||||||
description: "TypeScript types are provided for all objects.",
|
|
||||||
icon: "bx:bx-code",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
110
components/Feedback.tsx
Normal file
110
components/Feedback.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Transition } from "@headlessui/react";
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ElementRef,
|
||||||
|
type FormEvent,
|
||||||
|
forwardRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
function CheckIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<circle cx="10" cy="10" r="10" strokeWidth="0" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
d="m6.75 10.813 2.438 2.437c1.218-4.469 4.062-6.5 4.062-6.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackButton(
|
||||||
|
props: Omit<ComponentPropsWithoutRef<"button">, "type" | "className">,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-3 text-sm font-medium text-zinc-600 transition hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeedbackForm = forwardRef<
|
||||||
|
ElementRef<"form">,
|
||||||
|
Pick<ComponentPropsWithoutRef<"form">, "onSubmit">
|
||||||
|
>(function FeedbackForm({ onSubmit }, ref) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={ref}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="absolute inset-0 flex items-center justify-center gap-6 md:justify-start"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Was this page helpful?
|
||||||
|
</p>
|
||||||
|
<div className="group grid h-8 grid-cols-[1fr,1px,1fr] overflow-hidden rounded-full border border-zinc-900/10 dark:border-white/10">
|
||||||
|
<FeedbackButton data-response="yes">Yes</FeedbackButton>
|
||||||
|
<div className="bg-zinc-900/10 dark:bg-white/10" />
|
||||||
|
<FeedbackButton data-response="no">No</FeedbackButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const FeedbackThanks = forwardRef<ElementRef<"div">>(
|
||||||
|
// biome-ignore lint/style/useNamingConvention: <explanation>
|
||||||
|
function FeedbackThanks(_props, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute inset-0 flex justify-center md:justify-start"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 rounded-full bg-emerald-50/50 py-1 pl-1.5 pr-3 text-sm text-emerald-900 ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/5 dark:text-emerald-200 dark:ring-emerald-500/30">
|
||||||
|
<CheckIcon className="h-5 w-5 flex-none fill-emerald-500 stroke-white dark:fill-emerald-200/20 dark:stroke-emerald-200" />
|
||||||
|
Thanks for your feedback!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Feedback() {
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// event.nativeEvent.submitter.dataset.response
|
||||||
|
// => "yes" or "no"
|
||||||
|
|
||||||
|
setSubmitted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-8">
|
||||||
|
<Transition
|
||||||
|
show={!submitted}
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
leave="pointer-events-none duration-300"
|
||||||
|
>
|
||||||
|
<FeedbackForm onSubmit={onSubmit} />
|
||||||
|
</Transition>
|
||||||
|
<Transition
|
||||||
|
show={submitted}
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
enter="delay-150 duration-300"
|
||||||
|
>
|
||||||
|
<FeedbackThanks />
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
components/Footer.tsx
Normal file
153
components/Footer.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import type { ComponentPropsWithoutRef, ComponentType, ReactNode } from "react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { navigation } from "./Navigation";
|
||||||
|
|
||||||
|
function PageLink({
|
||||||
|
label,
|
||||||
|
page,
|
||||||
|
previous = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
page: { href: string; title: string };
|
||||||
|
previous?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
href={page.href}
|
||||||
|
aria-label={`${label}: ${page.title}`}
|
||||||
|
variant="secondary"
|
||||||
|
arrow={previous ? "left" : "right"}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href={page.href}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-base font-semibold text-zinc-900 transition hover:text-zinc-600 dark:text-white dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
{page.title}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageNavigation() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const allPages = navigation.flatMap((group) => group.links);
|
||||||
|
const currentPageIndex = allPages.findIndex(
|
||||||
|
(page) => page.href === pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentPageIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPage = allPages[currentPageIndex - 1];
|
||||||
|
const nextPage = allPages[currentPageIndex + 1];
|
||||||
|
|
||||||
|
if (!(previousPage || nextPage)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
{previousPage && (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<PageLink
|
||||||
|
label="Previous"
|
||||||
|
page={previousPage}
|
||||||
|
previous={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nextPage && (
|
||||||
|
<div className="ml-auto flex flex-col items-end gap-3">
|
||||||
|
<PageLink label="Next" page={nextPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function XIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path d="M11.1527 8.92804L16.2525 3H15.044L10.6159 8.14724L7.07919 3H3L8.34821 10.7835L3 17H4.20855L8.88474 11.5643L12.6198 17H16.699L11.1524 8.92804H11.1527ZM9.49748 10.8521L8.95559 10.077L4.644 3.90978H6.50026L9.97976 8.88696L10.5216 9.66202L15.0446 16.1316H13.1883L9.49748 10.8524V10.8521Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 1.667c-4.605 0-8.334 3.823-8.334 8.544 0 3.78 2.385 6.974 5.698 8.106.417.075.573-.182.573-.406 0-.203-.011-.875-.011-1.592-2.093.397-2.635-.522-2.802-1.002-.094-.246-.5-1.005-.854-1.207-.291-.16-.708-.556-.01-.567.656-.01 1.124.62 1.281.876.75 1.292 1.948.93 2.427.705.073-.555.291-.93.531-1.143-1.854-.213-3.791-.95-3.791-4.218 0-.929.322-1.698.854-2.296-.083-.214-.375-1.09.083-2.265 0 0 .698-.224 2.292.876a7.576 7.576 0 0 1 2.083-.288c.709 0 1.417.096 2.084.288 1.593-1.11 2.291-.875 2.291-.875.459 1.174.167 2.05.084 2.263.53.599.854 1.357.854 2.297 0 3.278-1.948 4.005-3.802 4.219.302.266.563.78.563 1.58 0 1.143-.011 2.061-.011 2.35 0 .224.156.491.573.405a8.365 8.365 0 0 0 4.11-3.116 8.707 8.707 0 0 0 1.567-4.99c0-4.721-3.73-8.545-8.334-8.545Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscordIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path d="M16.238 4.515a14.842 14.842 0 0 0-3.664-1.136.055.055 0 0 0-.059.027 10.35 10.35 0 0 0-.456.938 13.702 13.702 0 0 0-4.115 0 9.479 9.479 0 0 0-.464-.938.058.058 0 0 0-.058-.027c-1.266.218-2.497.6-3.664 1.136a.052.052 0 0 0-.024.02C1.4 8.023.76 11.424 1.074 14.782a.062.062 0 0 0 .024.042 14.923 14.923 0 0 0 4.494 2.272.058.058 0 0 0 .064-.02c.346-.473.654-.972.92-1.496a.057.057 0 0 0-.032-.08 9.83 9.83 0 0 1-1.404-.669.058.058 0 0 1-.029-.046.058.058 0 0 1 .023-.05c.094-.07.189-.144.279-.218a.056.056 0 0 1 .058-.008c2.946 1.345 6.135 1.345 9.046 0a.056.056 0 0 1 .059.007c.09.074.184.149.28.22a.058.058 0 0 1 .023.049.059.059 0 0 1-.028.046 9.224 9.224 0 0 1-1.405.669.058.058 0 0 0-.033.033.056.056 0 0 0 .002.047c.27.523.58 1.022.92 1.495a.056.056 0 0 0 .062.021 14.878 14.878 0 0 0 4.502-2.272.055.055 0 0 0 .016-.018.056.056 0 0 0 .008-.023c.375-3.883-.63-7.256-2.662-10.246a.046.046 0 0 0-.023-.021Zm-9.223 8.221c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.717 1.814-1.618 1.814Zm5.981 0c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.71 1.814-1.618 1.814Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SocialLink({
|
||||||
|
href,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="group">
|
||||||
|
<span className="sr-only">{children}</span>
|
||||||
|
<Icon className="h-5 w-5 fill-zinc-700 transition group-hover:fill-zinc-900 dark:group-hover:fill-zinc-500" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallPrint() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-between gap-5 border-t border-zinc-900/5 pt-8 sm:flex-row dark:border-white/5">
|
||||||
|
<p className="text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
© Copyright {new Date().getFullYear()}. All rights
|
||||||
|
reserved.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SocialLink href="#" icon={XIcon}>
|
||||||
|
Follow us on X
|
||||||
|
</SocialLink>
|
||||||
|
<SocialLink href="#" icon={GitHubIcon}>
|
||||||
|
Follow us on GitHub
|
||||||
|
</SocialLink>
|
||||||
|
<SocialLink href="#" icon={DiscordIcon}>
|
||||||
|
Join our Discord server
|
||||||
|
</SocialLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="mx-auto w-full max-w-2xl space-y-10 pb-16 lg:max-w-5xl">
|
||||||
|
<PageNavigation />
|
||||||
|
<SmallPrint />
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/GridPattern.tsx
Normal file
61
components/GridPattern.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { type ComponentPropsWithoutRef, useId } from "react";
|
||||||
|
|
||||||
|
export function GridPattern({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
squares,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<"svg"> & {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x: string | number;
|
||||||
|
y: string | number;
|
||||||
|
squares: [x: number, y: number][];
|
||||||
|
}) {
|
||||||
|
const patternId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" {...props}>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={patternId}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
>
|
||||||
|
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
strokeWidth={0}
|
||||||
|
fill={`url(#${patternId})`}
|
||||||
|
/>
|
||||||
|
{squares && (
|
||||||
|
// biome-ignore lint/a11y/noSvgWithoutTitle: <explanation>
|
||||||
|
<svg
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
className="overflow-visible"
|
||||||
|
aria-label="Grid of squares"
|
||||||
|
>
|
||||||
|
{squares.map(([x, y]) => (
|
||||||
|
<rect
|
||||||
|
strokeWidth="0"
|
||||||
|
key={`${x}-${y}`}
|
||||||
|
width={width + 1}
|
||||||
|
height={height + 1}
|
||||||
|
x={x * width}
|
||||||
|
y={y * height}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/Guides.tsx
Normal file
58
components/Guides.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Heading } from "./Heading";
|
||||||
|
|
||||||
|
const guides = [
|
||||||
|
{
|
||||||
|
href: "/authentication",
|
||||||
|
name: "Authentication",
|
||||||
|
description: "Learn how to authenticate your API requests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/pagination",
|
||||||
|
name: "Pagination",
|
||||||
|
description: "Understand how to work with paginated responses.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/errors",
|
||||||
|
name: "Errors",
|
||||||
|
description:
|
||||||
|
"Read about the different types of errors returned by the API.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/webhooks",
|
||||||
|
name: "Webhooks",
|
||||||
|
description:
|
||||||
|
"Learn how to programmatically configure webhooks for your app.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Guides() {
|
||||||
|
return (
|
||||||
|
<div className="my-16 xl:max-w-none">
|
||||||
|
<Heading level={2} id="guides">
|
||||||
|
Guides
|
||||||
|
</Heading>
|
||||||
|
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:grid-cols-4 dark:border-white/5">
|
||||||
|
{guides.map((guide) => (
|
||||||
|
<div key={guide.href}>
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{guide.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{guide.description}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
<Button
|
||||||
|
href={guide.href}
|
||||||
|
variant="text"
|
||||||
|
arrow="right"
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/Header.tsx
Normal file
104
components/Header.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { motion, useScroll, useTransform } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type ElementRef,
|
||||||
|
type ReactNode,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import {
|
||||||
|
MobileNavigation,
|
||||||
|
useIsInsideMobileNavigation,
|
||||||
|
} from "./MobileNavigation";
|
||||||
|
import { useMobileNavigationStore } from "./MobileNavigation";
|
||||||
|
import { MobileSearch, Search } from "./Search";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
|
function TopLevelNavItem({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="text-sm leading-5 text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header = forwardRef<ElementRef<"div">, { className?: string }>(
|
||||||
|
function Header({ className }, ref) {
|
||||||
|
const { isOpen: mobileNavIsOpen } = useMobileNavigationStore();
|
||||||
|
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||||
|
|
||||||
|
const { scrollY } = useScroll();
|
||||||
|
const bgOpacityLight = useTransform(scrollY, [0, 72], [0.5, 0.9]);
|
||||||
|
const bgOpacityDark = useTransform(scrollY, [0, 72], [0.2, 0.8]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
"fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80",
|
||||||
|
!isInsideMobileNavigation &&
|
||||||
|
"backdrop-blur-sm lg:left-72 xl:left-80 dark:backdrop-blur",
|
||||||
|
isInsideMobileNavigation
|
||||||
|
? "bg-white dark:bg-zinc-900"
|
||||||
|
: "bg-white/[var(--bg-opacity-light)] dark:bg-zinc-900/[var(--bg-opacity-dark)]",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--bg-opacity-light": bgOpacityLight,
|
||||||
|
"--bg-opacity-dark": bgOpacityDark,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute inset-x-0 top-full h-px transition",
|
||||||
|
(isInsideMobileNavigation || !mobileNavIsOpen) &&
|
||||||
|
"bg-zinc-900/7.5 dark:bg-white/7.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Search />
|
||||||
|
<div className="flex items-center gap-5 lg:hidden">
|
||||||
|
<MobileNavigation />
|
||||||
|
<Link href="/" aria-label="Home">
|
||||||
|
<Logo className="h-6" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<nav className="hidden md:block">
|
||||||
|
<ul className="flex items-center gap-8">
|
||||||
|
<TopLevelNavItem href="/">API</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">
|
||||||
|
Documentation
|
||||||
|
</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">Support</TopLevelNavItem>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<MobileSearch />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="hidden min-[416px]:contents">
|
||||||
|
<Button href="#">Sign in</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
126
components/Heading.tsx
Normal file
126
components/Heading.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useInView } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { remToPx } from "../lib/remToPx";
|
||||||
|
import { useSectionStore } from "./SectionProvider";
|
||||||
|
import { Tag } from "./Tag";
|
||||||
|
|
||||||
|
function AnchorIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="m6.5 11.5-.964-.964a3.535 3.535 0 1 1 5-5l.964.964m2 2 .964.964a3.536 3.536 0 0 1-5 5L8.5 13.5m0-5 3 3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Eyebrow({ tag, label }: { tag?: string; label?: string }) {
|
||||||
|
if (!(tag || label)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-x-3">
|
||||||
|
{tag && <Tag>{tag}</Tag>}
|
||||||
|
{tag && label && (
|
||||||
|
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Anchor({
|
||||||
|
id,
|
||||||
|
inView,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
inView: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`#${id}`}
|
||||||
|
className="group text-inherit no-underline hover:text-inherit"
|
||||||
|
>
|
||||||
|
{inView && (
|
||||||
|
<div className="absolute ml-[calc(-1*var(--width))] mt-1 hidden w-[var(--width)] opacity-0 transition [--width:calc(2.625rem+0.5px+50%-min(50%,calc(theme(maxWidth.lg)+theme(spacing.8))))] group-hover:opacity-100 group-focus:opacity-100 md:block lg:z-50 2xl:[--width:theme(spacing.10)]">
|
||||||
|
<div className="group/anchor block h-5 w-5 rounded-lg bg-zinc-50 ring-1 ring-inset ring-zinc-300 transition hover:ring-zinc-500 dark:bg-zinc-800 dark:ring-zinc-700 dark:hover:bg-zinc-700 dark:hover:ring-zinc-600">
|
||||||
|
<AnchorIcon className="h-5 w-5 stroke-zinc-500 transition dark:stroke-zinc-400 dark:group-hover/anchor:stroke-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Heading<Level extends 2 | 3>({
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
label,
|
||||||
|
level,
|
||||||
|
anchor = true,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<`h${Level}`> & {
|
||||||
|
id: string;
|
||||||
|
tag?: string;
|
||||||
|
label?: string;
|
||||||
|
level?: Level;
|
||||||
|
anchor?: boolean;
|
||||||
|
}) {
|
||||||
|
level = level ?? (2 as Level);
|
||||||
|
const Component = `h${level}` as "h2" | "h3";
|
||||||
|
const ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const registerHeading = useSectionStore((s) => s.registerHeading);
|
||||||
|
|
||||||
|
const inView = useInView(ref, {
|
||||||
|
margin: `${remToPx(-3.5)}px 0px 0px 0px`,
|
||||||
|
amount: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (level === 2) {
|
||||||
|
registerHeading({
|
||||||
|
id: props.id,
|
||||||
|
ref,
|
||||||
|
offsetRem: tag || label ? 8 : 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Eyebrow tag={tag} label={label} />
|
||||||
|
<Component
|
||||||
|
ref={ref}
|
||||||
|
className={tag || label ? "mt-2 scroll-mt-32" : "scroll-mt-24"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{anchor ? (
|
||||||
|
<Anchor id={props.id} inView={inView}>
|
||||||
|
{children}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Component>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/HeroPattern.tsx
Normal file
32
components/HeroPattern.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { GridPattern } from "./GridPattern";
|
||||||
|
|
||||||
|
export function HeroPattern() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 -z-10 mx-0 max-w-none overflow-hidden">
|
||||||
|
<div className="absolute left-1/2 top-0 ml-[-38rem] h-[25rem] w-[81.25rem] dark:[mask-image:linear-gradient(white,transparent)]">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-[#36b49f] to-[#DBFF75] opacity-40 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-[#36b49f]/30 dark:to-[#DBFF75]/30 dark:opacity-100">
|
||||||
|
<GridPattern
|
||||||
|
width={72}
|
||||||
|
height={56}
|
||||||
|
x={-12}
|
||||||
|
y={4}
|
||||||
|
squares={[
|
||||||
|
[4, 3],
|
||||||
|
[2, 1],
|
||||||
|
[7, 3],
|
||||||
|
[10, 6],
|
||||||
|
]}
|
||||||
|
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1113 440"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute left-1/2 top-0 ml-[-19rem] w-[69.5625rem] fill-white blur-[26px] dark:hidden"
|
||||||
|
>
|
||||||
|
<path d="M.016 439.5s-9.5-300 434-300S882.516 20 882.516 20V0h230.004v439.5H.016Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
components/Layout.tsx
Normal file
47
components/Layout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Footer } from "./Footer";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import { Navigation } from "./Navigation";
|
||||||
|
import { type Section, SectionProvider } from "./SectionProvider";
|
||||||
|
|
||||||
|
export function Layout({
|
||||||
|
children,
|
||||||
|
allSections,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
allSections: Record<string, Section[]>;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionProvider sections={allSections[pathname] ?? []}>
|
||||||
|
<div className="h-full lg:ml-72 xl:ml-80">
|
||||||
|
<motion.header
|
||||||
|
layoutScroll={true}
|
||||||
|
className="contents lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex"
|
||||||
|
>
|
||||||
|
<div className="contents lg:pointer-events-auto lg:block lg:w-72 lg:overflow-y-auto lg:border-r lg:border-zinc-900/10 lg:px-6 lg:pb-8 lg:pt-4 xl:w-80 lg:dark:border-white/10">
|
||||||
|
<div className="hidden lg:flex">
|
||||||
|
<Link href="/" aria-label="Home">
|
||||||
|
<Logo className="h-6" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Header />
|
||||||
|
<Navigation className="hidden lg:mt-10 lg:block" />
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
|
||||||
|
<main className="flex-auto">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
components/Libraries.tsx
Normal file
89
components/Libraries.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import logoGo from "@/images/logos/go.svg";
|
||||||
|
import logoNode from "@/images/logos/node.svg";
|
||||||
|
import logoPhp from "@/images/logos/php.svg";
|
||||||
|
import logoPython from "@/images/logos/python.svg";
|
||||||
|
import logoRuby from "@/images/logos/ruby.svg";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Heading } from "./Heading";
|
||||||
|
|
||||||
|
const libraries = [
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "PHP",
|
||||||
|
description:
|
||||||
|
"A popular general-purpose scripting language that is especially suited to web development.",
|
||||||
|
logo: logoPhp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Ruby",
|
||||||
|
description:
|
||||||
|
"A dynamic, open source programming language with a focus on simplicity and productivity.",
|
||||||
|
logo: logoRuby,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Node.js",
|
||||||
|
description:
|
||||||
|
"Node.js® is an open-source, cross-platform JavaScript runtime environment.",
|
||||||
|
logo: logoNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Python",
|
||||||
|
description:
|
||||||
|
"Python is a programming language that lets you work quickly and integrate systems more effectively.",
|
||||||
|
logo: logoPython,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "#",
|
||||||
|
name: "Go",
|
||||||
|
description:
|
||||||
|
"An open-source programming language supported by Google with built-in concurrency.",
|
||||||
|
logo: logoGo,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Libraries() {
|
||||||
|
return (
|
||||||
|
<div className="my-16 xl:max-w-none">
|
||||||
|
<Heading level={2} id="official-libraries">
|
||||||
|
Official libraries
|
||||||
|
</Heading>
|
||||||
|
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3 dark:border-white/5">
|
||||||
|
{libraries.map((library) => (
|
||||||
|
<div
|
||||||
|
key={library.name}
|
||||||
|
className="flex flex-row-reverse gap-6"
|
||||||
|
>
|
||||||
|
<div className="flex-auto">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{library.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{library.description}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
<Button
|
||||||
|
href={library.href}
|
||||||
|
variant="text"
|
||||||
|
arrow="right"
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Image
|
||||||
|
src={library.logo}
|
||||||
|
alt=""
|
||||||
|
className="h-12 w-12"
|
||||||
|
unoptimized={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
components/Logo.tsx
Normal file
16
components/Logo.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function Logo(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 99 24" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
className="fill-emerald-400"
|
||||||
|
d="M16 8a5 5 0 0 0-5-5H5a5 5 0 0 0-5 5v13.927a1 1 0 0 0 1.623.782l3.684-2.93a4 4 0 0 1 2.49-.87H11a5 5 0 0 0 5-5V8Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="fill-zinc-900 dark:fill-white"
|
||||||
|
d="M26.538 18h2.654v-3.999h2.576c2.672 0 4.456-1.723 4.456-4.333V9.65c0-2.61-1.784-4.333-4.456-4.333h-5.23V18Zm4.58-10.582c1.52 0 2.416.8 2.416 2.241v.018c0 1.441-.896 2.25-2.417 2.25h-1.925V7.418h1.925ZM38.051 18h2.566v-5.414c0-1.371.923-2.206 2.382-2.206.396 0 .791.061 1.178.15V8.287a3.843 3.843 0 0 0-.958-.123c-1.257 0-2.136.615-2.443 1.661h-.159V8.323h-2.566V18Zm11.55.202c2.979 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.773-5.036-2.953 0-4.772 1.916-4.772 5.036v.018c0 3.146 1.793 5.036 4.772 5.036Zm0-2.013c-1.372 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.144-3.023 1.354 0 2.145 1.134 2.145 3.023v.018c0 1.907-.782 3.023-2.145 3.023Zm10.52 1.846c.492 0 .967-.053 1.283-.114v-1.907a6.057 6.057 0 0 1-.755.044c-.87 0-1.24-.387-1.24-1.257v-4.544h1.995V8.323H59.41V6.012h-2.592v2.311h-1.495v1.934h1.495v5.133c0 1.88.949 2.645 3.304 2.645Zm7.287.167c2.98 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.772-5.036-2.954 0-4.773 1.916-4.773 5.036v.018c0 3.146 1.793 5.036 4.773 5.036Zm0-2.013c-1.372 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.145-3.023 1.353 0 2.144 1.134 2.144 3.023v.018c0 1.907-.782 3.023-2.144 3.023Zm10.767 2.013c2.522 0 4.034-1.353 4.297-3.463l.01-.053h-2.374l-.017.036c-.229.966-.853 1.467-1.908 1.467-1.37 0-2.135-1.08-2.135-3.04v-.018c0-1.934.755-3.006 2.135-3.006 1.099 0 1.74.615 1.908 1.556l.008.017h2.391v-.026c-.228-2.162-1.749-3.56-4.315-3.56-3.033 0-4.738 1.837-4.738 5.019v.017c0 3.217 1.714 5.054 4.738 5.054Zm10.257 0c2.98 0 4.772-1.88 4.772-5.036v-.018c0-3.128-1.82-5.036-4.772-5.036-2.953 0-4.773 1.916-4.773 5.036v.018c0 3.146 1.793 5.036 4.773 5.036Zm0-2.013c-1.371 0-2.145-1.116-2.145-3.023v-.018c0-1.89.782-3.023 2.145-3.023 1.353 0 2.144 1.134 2.144 3.023v.018c0 1.907-.782 3.023-2.144 3.023ZM95.025 18h2.566V4.623h-2.566V18Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
components/MobileNavigation.tsx
Normal file
182
components/MobileNavigation.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
Transition,
|
||||||
|
TransitionChild,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type MouseEvent,
|
||||||
|
Suspense,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { Header } from "./Header";
|
||||||
|
import { Navigation } from "./Navigation";
|
||||||
|
|
||||||
|
function MenuIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 10 9"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M.5 1h9M.5 8h9M.5 4.5h9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function XIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 10 9"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="m1.5 1 7 7M8.5 1l-7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IsInsideMobileNavigationContext = createContext(false);
|
||||||
|
|
||||||
|
function MobileNavigationDialog({
|
||||||
|
isOpen,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialPathname = useRef(pathname).current;
|
||||||
|
const initialSearchParams = useRef(searchParams).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
pathname !== initialPathname ||
|
||||||
|
searchParams !== initialSearchParams
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams, close, initialPathname, initialSearchParams]);
|
||||||
|
|
||||||
|
function onClickDialog(event: MouseEvent<HTMLDivElement>) {
|
||||||
|
if (!(event.target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = event.target.closest("a");
|
||||||
|
if (
|
||||||
|
link &&
|
||||||
|
link.pathname + link.search + link.hash ===
|
||||||
|
window.location.pathname +
|
||||||
|
window.location.search +
|
||||||
|
window.location.hash
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition show={isOpen}>
|
||||||
|
<Dialog
|
||||||
|
onClickCapture={onClickDialog}
|
||||||
|
onClose={close}
|
||||||
|
className="fixed inset-0 z-50 lg:hidden"
|
||||||
|
>
|
||||||
|
<TransitionChild
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 top-14 bg-zinc-400/20 backdrop-blur-sm dark:bg-black/40" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<DialogPanel>
|
||||||
|
<TransitionChild
|
||||||
|
enter="duration-300 ease-out"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="duration-200 ease-in"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<TransitionChild
|
||||||
|
enter="duration-500 ease-in-out"
|
||||||
|
enterFrom="-translate-x-full"
|
||||||
|
enterTo="translate-x-0"
|
||||||
|
leave="duration-500 ease-in-out"
|
||||||
|
leaveFrom="translate-x-0"
|
||||||
|
leaveTo="-translate-x-full"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
layoutScroll={true}
|
||||||
|
className="fixed bottom-0 left-0 top-14 w-full overflow-y-auto bg-white px-4 pb-4 pt-6 shadow-lg shadow-zinc-900/10 ring-1 ring-zinc-900/7.5 min-[416px]:max-w-sm sm:px-6 sm:pb-10 dark:bg-zinc-900 dark:ring-zinc-800"
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
</motion.div>
|
||||||
|
</TransitionChild>
|
||||||
|
</DialogPanel>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsInsideMobileNavigation() {
|
||||||
|
return useContext(IsInsideMobileNavigationContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMobileNavigationStore = create<{
|
||||||
|
isOpen: boolean;
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}>()((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
open: () => set({ isOpen: true }),
|
||||||
|
close: () => set({ isOpen: false }),
|
||||||
|
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function MobileNavigation() {
|
||||||
|
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||||
|
const { isOpen, toggle, close } = useMobileNavigationStore();
|
||||||
|
const ToggleIcon = isOpen ? XIcon : MenuIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IsInsideMobileNavigationContext.Provider value={true}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<ToggleIcon className="w-2.5 stroke-zinc-900 dark:stroke-white" />
|
||||||
|
</button>
|
||||||
|
{!isInsideMobileNavigation && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<MobileNavigationDialog isOpen={isOpen} close={close} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</IsInsideMobileNavigationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
components/Navigation.tsx
Normal file
292
components/Navigation.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { AnimatePresence, motion, useIsPresent } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { type ComponentPropsWithoutRef, type ReactNode, useRef } from "react";
|
||||||
|
|
||||||
|
import { remToPx } from "../lib/remToPx";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { useIsInsideMobileNavigation } from "./MobileNavigation";
|
||||||
|
import { useSectionStore } from "./SectionProvider";
|
||||||
|
import { Tag } from "./Tag";
|
||||||
|
|
||||||
|
interface NavGroup {
|
||||||
|
title: string;
|
||||||
|
links: Array<{
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInitialValue<T>(value: T, condition = true) {
|
||||||
|
const initialValue = useRef(value).current;
|
||||||
|
return condition ? initialValue : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopLevelNavItem({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="md:hidden">
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block py-1 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
active = false,
|
||||||
|
isAnchorLink = false,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
tag?: string;
|
||||||
|
active?: boolean;
|
||||||
|
isAnchorLink?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"flex justify-between gap-2 py-1 pr-3 text-sm transition",
|
||||||
|
isAnchorLink ? "pl-7" : "pl-4",
|
||||||
|
active
|
||||||
|
? "text-zinc-900 dark:text-white"
|
||||||
|
: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{children}</span>
|
||||||
|
{tag && (
|
||||||
|
<Tag variant="small" color="zinc">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisibleSectionHighlight({
|
||||||
|
group,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
group: NavGroup;
|
||||||
|
pathname: string;
|
||||||
|
}) {
|
||||||
|
const [sections, visibleSections] = useInitialValue(
|
||||||
|
[
|
||||||
|
useSectionStore((s) => s.sections),
|
||||||
|
useSectionStore((s) => s.visibleSections),
|
||||||
|
],
|
||||||
|
useIsInsideMobileNavigation(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPresent = useIsPresent();
|
||||||
|
const firstVisibleSectionIndex = Math.max(
|
||||||
|
0,
|
||||||
|
[{ id: "_top" }, ...sections].findIndex(
|
||||||
|
(section) => section.id === visibleSections[0],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const itemHeight = remToPx(2);
|
||||||
|
const height = isPresent
|
||||||
|
? Math.max(1, visibleSections.length) * itemHeight
|
||||||
|
: itemHeight;
|
||||||
|
const top =
|
||||||
|
group.links.findIndex((link) => link.href === pathname) * itemHeight +
|
||||||
|
firstVisibleSectionIndex * itemHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout={true}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-x-0 top-0 bg-zinc-800/2.5 will-change-transform dark:bg-white/2.5"
|
||||||
|
style={{ borderRadius: 8, height, top }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivePageMarker({
|
||||||
|
group,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
group: NavGroup;
|
||||||
|
pathname: string;
|
||||||
|
}) {
|
||||||
|
const itemHeight = remToPx(2);
|
||||||
|
const offset = remToPx(0.25);
|
||||||
|
const activePageIndex = group.links.findIndex(
|
||||||
|
(link) => link.href === pathname,
|
||||||
|
);
|
||||||
|
const top = offset + activePageIndex * itemHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout={true}
|
||||||
|
className="absolute left-2 h-6 w-px bg-emerald-500"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{ top }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationGroup({
|
||||||
|
group,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
group: NavGroup;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
// If this is the mobile navigation then we always render the initial
|
||||||
|
// state, so that the state does not change during the close animation.
|
||||||
|
// The state will still update when we re-open (re-render) the navigation.
|
||||||
|
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||||
|
const [pathname, sections] = useInitialValue(
|
||||||
|
[usePathname(), useSectionStore((s) => s.sections)],
|
||||||
|
isInsideMobileNavigation,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActiveGroup =
|
||||||
|
group.links.findIndex((link) => link.href === pathname) !== -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={clsx("relative mt-6", className)}>
|
||||||
|
<motion.h2
|
||||||
|
layout="position"
|
||||||
|
className="text-xs font-semibold text-zinc-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</motion.h2>
|
||||||
|
<div className="relative mt-3 pl-2">
|
||||||
|
<AnimatePresence initial={!isInsideMobileNavigation}>
|
||||||
|
{isActiveGroup && (
|
||||||
|
<VisibleSectionHighlight
|
||||||
|
group={group}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
layout={true}
|
||||||
|
className="absolute inset-y-0 left-2 w-px bg-zinc-900/10 dark:bg-white/5"
|
||||||
|
/>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isActiveGroup && (
|
||||||
|
<ActivePageMarker group={group} pathname={pathname} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<ul className="border-l border-transparent">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<motion.li
|
||||||
|
key={link.href}
|
||||||
|
layout="position"
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
href={link.href}
|
||||||
|
active={link.href === pathname}
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</NavLink>
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
{link.href === pathname &&
|
||||||
|
sections.length > 0 && (
|
||||||
|
<motion.ul
|
||||||
|
role="list"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
transition: { delay: 0.1 },
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.15 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<li key={section.id}>
|
||||||
|
<NavLink
|
||||||
|
href={`${link.href}#${section.id}`}
|
||||||
|
tag={section.tag}
|
||||||
|
isAnchorLink={true}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</motion.ul>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigation: NavGroup[] = [
|
||||||
|
{
|
||||||
|
title: "Guides",
|
||||||
|
links: [
|
||||||
|
{ title: "Introduction", href: "/" },
|
||||||
|
{ title: "Quickstart", href: "/quickstart" },
|
||||||
|
{ title: "SDKs", href: "/sdks" },
|
||||||
|
{ title: "Authentication", href: "/authentication" },
|
||||||
|
{ title: "Pagination", href: "/pagination" },
|
||||||
|
{ title: "Errors", href: "/errors" },
|
||||||
|
{ title: "Webhooks", href: "/webhooks" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Resources",
|
||||||
|
links: [
|
||||||
|
{ title: "Contacts", href: "/contacts" },
|
||||||
|
{ title: "Conversations", href: "/conversations" },
|
||||||
|
{ title: "Messages", href: "/messages" },
|
||||||
|
{ title: "Groups", href: "/groups" },
|
||||||
|
{ title: "Attachments", href: "/attachments" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Navigation(props: ComponentPropsWithoutRef<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav {...props}>
|
||||||
|
<ul>
|
||||||
|
<TopLevelNavItem href="/">API</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">Documentation</TopLevelNavItem>
|
||||||
|
<TopLevelNavItem href="#">Support</TopLevelNavItem>
|
||||||
|
{navigation.map((group, groupIndex) => (
|
||||||
|
<NavigationGroup
|
||||||
|
key={group.title}
|
||||||
|
group={group}
|
||||||
|
className={groupIndex === 0 ? "md:mt-0" : ""}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
|
||||||
|
<Button href="#" variant="filled" className="w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/Prose.tsx
Normal file
25
components/Prose.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import type { ComponentPropsWithoutRef, ElementType } from "react";
|
||||||
|
|
||||||
|
export function Prose<T extends ElementType = "div">({
|
||||||
|
as,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Omit<ComponentPropsWithoutRef<T>, "as" | "className"> & {
|
||||||
|
as?: T;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const Component = as ?? "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
"prose dark:prose-invert",
|
||||||
|
// `html :where(& > *)` is used to select all direct children without an increase in specificity like you'd get from just `& > *`
|
||||||
|
"[html_:where(&>*)]:mx-auto [html_:where(&>*)]:max-w-2xl [html_:where(&>*)]:lg:mx-[calc(50%-min(50%,theme(maxWidth.lg)))] [html_:where(&>*)]:lg:max-w-3xl",
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
components/Resources.tsx
Normal file
195
components/Resources.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MotionValue,
|
||||||
|
motion,
|
||||||
|
useMotionTemplate,
|
||||||
|
useMotionValue,
|
||||||
|
} from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
ComponentType,
|
||||||
|
MouseEvent,
|
||||||
|
} from "react";
|
||||||
|
import { GridPattern } from "./GridPattern";
|
||||||
|
import { Heading } from "./Heading";
|
||||||
|
import { ChatBubbleIcon } from "./icons/ChatBubbleIcon";
|
||||||
|
import { EnvelopeIcon } from "./icons/EnvelopeIcon";
|
||||||
|
import { UserIcon } from "./icons/UserIcon";
|
||||||
|
import { UsersIcon } from "./icons/UsersIcon";
|
||||||
|
|
||||||
|
interface Resource {
|
||||||
|
href: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
pattern: Omit<
|
||||||
|
ComponentPropsWithoutRef<typeof GridPattern>,
|
||||||
|
"width" | "height" | "x"
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources: Resource[] = [
|
||||||
|
{
|
||||||
|
href: "/contacts",
|
||||||
|
name: "Contacts",
|
||||||
|
description:
|
||||||
|
"Learn about the contact model and how to create, retrieve, update, delete, and list contacts.",
|
||||||
|
icon: UserIcon,
|
||||||
|
pattern: {
|
||||||
|
y: 16,
|
||||||
|
squares: [
|
||||||
|
[0, 1],
|
||||||
|
[1, 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/conversations",
|
||||||
|
name: "Conversations",
|
||||||
|
description:
|
||||||
|
"Learn about the conversation model and how to create, retrieve, update, delete, and list conversations.",
|
||||||
|
icon: ChatBubbleIcon,
|
||||||
|
pattern: {
|
||||||
|
y: -6,
|
||||||
|
squares: [
|
||||||
|
[-1, 2],
|
||||||
|
[1, 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/messages",
|
||||||
|
name: "Messages",
|
||||||
|
description:
|
||||||
|
"Learn about the message model and how to create, retrieve, update, delete, and list messages.",
|
||||||
|
icon: EnvelopeIcon,
|
||||||
|
pattern: {
|
||||||
|
y: 32,
|
||||||
|
squares: [
|
||||||
|
[0, 2],
|
||||||
|
[1, 4],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/groups",
|
||||||
|
name: "Groups",
|
||||||
|
description:
|
||||||
|
"Learn about the group model and how to create, retrieve, update, delete, and list groups.",
|
||||||
|
icon: UsersIcon,
|
||||||
|
pattern: {
|
||||||
|
y: 22,
|
||||||
|
squares: [[0, 1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ResourceIcon({ icon: Icon }: { icon: Resource["icon"] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-zinc-900/5 ring-1 ring-zinc-900/25 backdrop-blur-[2px] transition duration-300 group-hover:bg-white/50 group-hover:ring-zinc-900/25 dark:bg-white/7.5 dark:ring-white/15 dark:group-hover:bg-emerald-300/10 dark:group-hover:ring-emerald-400">
|
||||||
|
<Icon className="h-5 w-5 fill-zinc-700/10 stroke-zinc-700 transition-colors duration-300 group-hover:stroke-zinc-900 dark:fill-white/10 dark:stroke-zinc-400 dark:group-hover:fill-emerald-300/10 dark:group-hover:stroke-emerald-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourcePattern({
|
||||||
|
mouseX,
|
||||||
|
mouseY,
|
||||||
|
...gridProps
|
||||||
|
}: Resource["pattern"] & {
|
||||||
|
mouseX: MotionValue<number>;
|
||||||
|
mouseY: MotionValue<number>;
|
||||||
|
}) {
|
||||||
|
const maskImage = useMotionTemplate`radial-gradient(180px at ${mouseX}px ${mouseY}px, white, transparent)`;
|
||||||
|
const style = { maskImage, WebkitMaskImage: maskImage };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none">
|
||||||
|
<div className="absolute inset-0 rounded-2xl transition duration-300 [mask-image:linear-gradient(white,transparent)] group-hover:opacity-50">
|
||||||
|
<GridPattern
|
||||||
|
width={72}
|
||||||
|
height={56}
|
||||||
|
x="50%"
|
||||||
|
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/[0.02] stroke-black/5 dark:fill-white/1 dark:stroke-white/2.5"
|
||||||
|
{...gridProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-[#D7EDEA] to-[#F4FBDF] opacity-0 transition duration-300 group-hover:opacity-100 dark:from-[#202D2E] dark:to-[#303428]"
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-2xl opacity-0 mix-blend-overlay transition duration-300 group-hover:opacity-100"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<GridPattern
|
||||||
|
width={72}
|
||||||
|
height={56}
|
||||||
|
x="50%"
|
||||||
|
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/50 stroke-black/70 dark:fill-white/2.5 dark:stroke-white/10"
|
||||||
|
{...gridProps}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resource({ resource }: { resource: Resource }) {
|
||||||
|
const mouseX = useMotionValue(0);
|
||||||
|
const mouseY = useMotionValue(0);
|
||||||
|
|
||||||
|
function onMouseMove({
|
||||||
|
currentTarget,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
}: MouseEvent<HTMLDivElement>) {
|
||||||
|
const { left, top } = currentTarget.getBoundingClientRect();
|
||||||
|
mouseX.set(clientX - left);
|
||||||
|
mouseY.set(clientY - top);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={resource.href}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
className="group relative flex rounded-2xl bg-zinc-50 transition-shadow hover:shadow-md hover:shadow-zinc-900/5 dark:bg-white/2.5 dark:hover:shadow-black/5"
|
||||||
|
>
|
||||||
|
<ResourcePattern
|
||||||
|
{...resource.pattern}
|
||||||
|
mouseX={mouseX}
|
||||||
|
mouseY={mouseY}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 rounded-2xl ring-1 ring-inset ring-zinc-900/7.5 group-hover:ring-zinc-900/10 dark:ring-white/10 dark:group-hover:ring-white/20" />
|
||||||
|
<div className="relative rounded-2xl px-4 pb-4 pt-16">
|
||||||
|
<ResourceIcon icon={resource.icon} />
|
||||||
|
<h3 className="mt-4 text-sm font-semibold leading-7 text-zinc-900 dark:text-white">
|
||||||
|
<Link href={resource.href}>
|
||||||
|
<span className="absolute inset-0 rounded-2xl" />
|
||||||
|
{resource.name}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{resource.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Resources() {
|
||||||
|
return (
|
||||||
|
<div className="my-16 xl:max-w-none">
|
||||||
|
<Heading level={2} id="resources">
|
||||||
|
Resources
|
||||||
|
</Heading>
|
||||||
|
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:grid-cols-4 dark:border-white/5">
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<Resource key={resource.href} resource={resource} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
511
components/Search.tsx
Normal file
511
components/Search.tsx
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AutocompleteApi,
|
||||||
|
type AutocompleteCollection,
|
||||||
|
type AutocompleteState,
|
||||||
|
createAutocomplete,
|
||||||
|
} from "@algolia/autocomplete-core";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
Transition,
|
||||||
|
TransitionChild,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ElementRef,
|
||||||
|
Fragment,
|
||||||
|
type MouseEvent,
|
||||||
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
|
Suspense,
|
||||||
|
type SyntheticEvent,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import Highlighter from "react-highlight-words";
|
||||||
|
|
||||||
|
import type { Result } from "@/mdx/search.mjs";
|
||||||
|
import { navigation } from "./Navigation";
|
||||||
|
|
||||||
|
type EmptyObject = Record<string, never>;
|
||||||
|
|
||||||
|
type Autocomplete = AutocompleteApi<
|
||||||
|
Result,
|
||||||
|
SyntheticEvent,
|
||||||
|
MouseEvent,
|
||||||
|
ReactKeyboardEvent
|
||||||
|
>;
|
||||||
|
|
||||||
|
function useAutocomplete({ close }: { close: () => void }) {
|
||||||
|
const id = useId();
|
||||||
|
const router = useRouter();
|
||||||
|
const [autocompleteState, setAutocompleteState] = useState<
|
||||||
|
AutocompleteState<Result> | EmptyObject
|
||||||
|
>({});
|
||||||
|
|
||||||
|
function navigate({ itemUrl }: { itemUrl?: string }) {
|
||||||
|
if (!itemUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(itemUrl);
|
||||||
|
|
||||||
|
if (
|
||||||
|
itemUrl ===
|
||||||
|
window.location.pathname +
|
||||||
|
window.location.search +
|
||||||
|
window.location.hash
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [autocomplete] = useState<Autocomplete>(() =>
|
||||||
|
createAutocomplete<
|
||||||
|
Result,
|
||||||
|
SyntheticEvent,
|
||||||
|
MouseEvent,
|
||||||
|
ReactKeyboardEvent
|
||||||
|
>({
|
||||||
|
id,
|
||||||
|
placeholder: "Find something...",
|
||||||
|
defaultActiveItemId: 0,
|
||||||
|
onStateChange({ state }) {
|
||||||
|
setAutocompleteState(state);
|
||||||
|
},
|
||||||
|
shouldPanelOpen({ state }) {
|
||||||
|
return state.query !== "";
|
||||||
|
},
|
||||||
|
navigator: {
|
||||||
|
navigate,
|
||||||
|
},
|
||||||
|
getSources({ query }) {
|
||||||
|
return import("@/mdx/search.mjs").then(({ search }) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sourceId: "documentation",
|
||||||
|
getItems() {
|
||||||
|
return search(query, { limit: 5 });
|
||||||
|
},
|
||||||
|
getItemUrl({ item }) {
|
||||||
|
return item.url;
|
||||||
|
},
|
||||||
|
onSelect: navigate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { autocomplete, autocompleteState };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoResultsIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12.01 12a4.237 4.237 0 0 0 1.24-3c0-.62-.132-1.207-.37-1.738M12.01 12A4.237 4.237 0 0 1 9 13.25c-.635 0-1.237-.14-1.777-.388M12.01 12l3.24 3.25m-3.715-9.661a4.25 4.25 0 0 0-5.975 5.908M4.5 15.5l11-11"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<circle cx="10" cy="10" r="5.5" strokeLinejoin="round" />
|
||||||
|
<path
|
||||||
|
stroke={`url(#${id})`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={id}
|
||||||
|
x1="13"
|
||||||
|
x2="9.5"
|
||||||
|
y1="9"
|
||||||
|
y2="15"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stopColor="currentColor" />
|
||||||
|
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightQuery({ text, query }: { text: string; query: string }) {
|
||||||
|
return (
|
||||||
|
<Highlighter
|
||||||
|
highlightClassName="underline bg-transparent text-emerald-500"
|
||||||
|
searchWords={[query]}
|
||||||
|
autoEscape={true}
|
||||||
|
textToHighlight={text}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResult({
|
||||||
|
result,
|
||||||
|
resultIndex,
|
||||||
|
autocomplete,
|
||||||
|
collection,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
result: Result;
|
||||||
|
resultIndex: number;
|
||||||
|
autocomplete: Autocomplete;
|
||||||
|
collection: AutocompleteCollection<Result>;
|
||||||
|
query: string;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const sectionTitle = navigation.find((section) =>
|
||||||
|
section.links.find((link) => link.href === result.url.split("#")[0]),
|
||||||
|
)?.title;
|
||||||
|
const hierarchy = [sectionTitle, result.pageTitle].filter(
|
||||||
|
(x): x is string => typeof x === "string",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={clsx(
|
||||||
|
"group block cursor-default px-4 py-3 aria-selected:bg-zinc-50 dark:aria-selected:bg-zinc-800/50",
|
||||||
|
resultIndex > 0 &&
|
||||||
|
"border-t border-zinc-100 dark:border-zinc-800",
|
||||||
|
)}
|
||||||
|
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
||||||
|
{...autocomplete.getItemProps({
|
||||||
|
item: result,
|
||||||
|
source: collection.source,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id={`${id}-title`}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-sm font-medium text-zinc-900 group-aria-selected:text-emerald-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<HighlightQuery text={result.title} query={query} />
|
||||||
|
</div>
|
||||||
|
{hierarchy.length > 0 && (
|
||||||
|
<div
|
||||||
|
id={`${id}-hierarchy`}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-1 truncate whitespace-nowrap text-2xs text-zinc-500"
|
||||||
|
>
|
||||||
|
{hierarchy.map((item, itemIndex, items) => (
|
||||||
|
<Fragment key={item}>
|
||||||
|
<HighlightQuery text={item} query={query} />
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
itemIndex === items.length - 1
|
||||||
|
? "sr-only"
|
||||||
|
: "mx-2 text-zinc-300 dark:text-zinc-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResults({
|
||||||
|
autocomplete,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
}: {
|
||||||
|
autocomplete: Autocomplete;
|
||||||
|
query: string;
|
||||||
|
collection: AutocompleteCollection<Result>;
|
||||||
|
}) {
|
||||||
|
if (collection.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<NoResultsIcon className="mx-auto h-5 w-5 stroke-zinc-900 dark:stroke-zinc-600" />
|
||||||
|
<p className="mt-2 text-xs text-zinc-700 dark:text-zinc-400">
|
||||||
|
Nothing found for{" "}
|
||||||
|
<strong className="break-words font-semibold text-zinc-900 dark:text-white">
|
||||||
|
‘{query}’
|
||||||
|
</strong>
|
||||||
|
. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul {...autocomplete.getListProps()}>
|
||||||
|
{collection.items.map((result, resultIndex) => (
|
||||||
|
<SearchResult
|
||||||
|
key={result.url}
|
||||||
|
result={result}
|
||||||
|
resultIndex={resultIndex}
|
||||||
|
autocomplete={autocomplete}
|
||||||
|
collection={collection}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput = forwardRef<
|
||||||
|
ElementRef<"input">,
|
||||||
|
{
|
||||||
|
autocomplete: Autocomplete;
|
||||||
|
autocompleteState: AutocompleteState<Result> | EmptyObject;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
>(function SearchInput({ autocomplete, autocompleteState, onClose }, inputRef) {
|
||||||
|
const inputProps = autocomplete.getInputProps({ inputElement: null });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex h-12">
|
||||||
|
<SearchIcon className="pointer-events-none absolute left-3 top-0 h-full w-5 stroke-zinc-500" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
data-autofocus={true}
|
||||||
|
className={clsx(
|
||||||
|
"flex-auto appearance-none bg-transparent pl-10 text-zinc-900 outline-none placeholder:text-zinc-500 focus:w-full focus:flex-none sm:text-sm dark:text-white [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
|
||||||
|
autocompleteState.status === "stalled" ? "pr-11" : "pr-4",
|
||||||
|
)}
|
||||||
|
{...inputProps}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "Escape" &&
|
||||||
|
!autocompleteState.isOpen &&
|
||||||
|
autocompleteState.query === ""
|
||||||
|
) {
|
||||||
|
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
||||||
|
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
inputProps.onKeyDown(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{autocompleteState.status === "stalled" && (
|
||||||
|
<div className="absolute inset-y-0 right-3 flex items-center">
|
||||||
|
<LoadingIcon className="h-5 w-5 animate-spin stroke-zinc-200 text-zinc-900 dark:stroke-zinc-800 dark:text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function SearchDialog({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const formRef = useRef<ElementRef<"form">>(null);
|
||||||
|
const panelRef = useRef<ElementRef<"div">>(null);
|
||||||
|
const inputRef = useRef<ElementRef<typeof SearchInput>>(null);
|
||||||
|
const { autocomplete, autocompleteState } = useAutocomplete({
|
||||||
|
close() {
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [setOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition show={open} afterLeave={() => autocomplete.setQuery("")}>
|
||||||
|
<Dialog
|
||||||
|
onClose={setOpen}
|
||||||
|
className={clsx("fixed inset-0 z-50", className)}
|
||||||
|
>
|
||||||
|
<TransitionChild
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-zinc-400/25 backdrop-blur-sm dark:bg-black/40" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]">
|
||||||
|
<TransitionChild
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel className="mx-auto transform-gpu overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-1 ring-zinc-900/7.5 sm:max-w-xl dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
<div {...autocomplete.getRootProps({})}>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
{...autocomplete.getFormProps({
|
||||||
|
inputElement: inputRef.current,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SearchInput
|
||||||
|
ref={inputRef}
|
||||||
|
autocomplete={autocomplete}
|
||||||
|
autocompleteState={autocompleteState}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="border-t border-zinc-200 bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
|
||||||
|
{...autocomplete.getPanelProps({})}
|
||||||
|
>
|
||||||
|
{autocompleteState.isOpen && (
|
||||||
|
<SearchResults
|
||||||
|
autocomplete={autocomplete}
|
||||||
|
query={autocompleteState.query}
|
||||||
|
collection={
|
||||||
|
autocompleteState
|
||||||
|
.collections[0]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSearchProps() {
|
||||||
|
const buttonRef = useRef<ElementRef<"button">>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttonProps: {
|
||||||
|
ref: buttonRef,
|
||||||
|
onClick() {
|
||||||
|
setOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogProps: {
|
||||||
|
open,
|
||||||
|
setOpen: useCallback((open: boolean) => {
|
||||||
|
const { width = 0, height = 0 } =
|
||||||
|
buttonRef.current?.getBoundingClientRect() ?? {};
|
||||||
|
if (!open || (width !== 0 && height !== 0)) {
|
||||||
|
setOpen(open);
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Search() {
|
||||||
|
const [modifierKey, setModifierKey] = useState<string>();
|
||||||
|
const { buttonProps, dialogProps } = useSearchProps();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setModifierKey(
|
||||||
|
/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl ",
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:block lg:max-w-md lg:flex-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hidden h-8 w-full items-center gap-2 rounded-full bg-white pl-2 pr-3 text-sm text-zinc-500 ring-1 ring-zinc-900/10 transition hover:ring-zinc-900/20 ui-not-focus-visible:outline-none lg:flex dark:bg-white/5 dark:text-zinc-400 dark:ring-inset dark:ring-white/10 dark:hover:ring-white/20"
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
<SearchIcon className="h-5 w-5 stroke-current" />
|
||||||
|
Find something...
|
||||||
|
<kbd className="ml-auto text-2xs text-zinc-400 dark:text-zinc-500">
|
||||||
|
<kbd className="font-sans">{modifierKey}</kbd>
|
||||||
|
<kbd className="font-sans">K</kbd>
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SearchDialog className="hidden lg:block" {...dialogProps} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileSearch() {
|
||||||
|
const { buttonProps, dialogProps } = useSearchProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contents lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 ui-not-focus-visible:outline-none lg:hidden dark:hover:bg-white/5"
|
||||||
|
aria-label="Find something..."
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
<SearchIcon className="h-5 w-5 stroke-zinc-900 dark:stroke-white" />
|
||||||
|
</button>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SearchDialog className="lg:hidden" {...dialogProps} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
components/SectionProvider.tsx
Normal file
165
components/SectionProvider.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { type StoreApi, createStore, useStore } from "zustand";
|
||||||
|
|
||||||
|
import { remToPx } from "../lib/remToPx";
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
offsetRem?: number;
|
||||||
|
tag?: string;
|
||||||
|
headingRef?: RefObject<HTMLHeadingElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionState {
|
||||||
|
sections: Section[];
|
||||||
|
visibleSections: string[];
|
||||||
|
setVisibleSections: (visibleSections: string[]) => void;
|
||||||
|
registerHeading: ({
|
||||||
|
id,
|
||||||
|
ref,
|
||||||
|
offsetRem,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
ref: RefObject<HTMLHeadingElement>;
|
||||||
|
offsetRem: number;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSectionStore(sections: Section[]) {
|
||||||
|
return createStore<SectionState>()((set) => ({
|
||||||
|
sections,
|
||||||
|
visibleSections: [],
|
||||||
|
setVisibleSections: (visibleSections) =>
|
||||||
|
set((state) =>
|
||||||
|
state.visibleSections.join() === visibleSections.join()
|
||||||
|
? {}
|
||||||
|
: { visibleSections },
|
||||||
|
),
|
||||||
|
registerHeading: ({ id, ref, offsetRem }) =>
|
||||||
|
set((state) => {
|
||||||
|
return {
|
||||||
|
sections: state.sections.map((section) => {
|
||||||
|
if (section.id === id) {
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
headingRef: ref,
|
||||||
|
offsetRem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return section;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function useVisibleSections(sectionStore: StoreApi<SectionState>) {
|
||||||
|
const setVisibleSections = useStore(
|
||||||
|
sectionStore,
|
||||||
|
(s) => s.setVisibleSections,
|
||||||
|
);
|
||||||
|
const sections = useStore(sectionStore, (s) => s.sections);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function checkVisibleSections() {
|
||||||
|
const { innerHeight, scrollY } = window;
|
||||||
|
const newVisibleSections: string[] = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let sectionIndex = 0;
|
||||||
|
sectionIndex < sections.length;
|
||||||
|
sectionIndex++
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
headingRef,
|
||||||
|
offsetRem = 0,
|
||||||
|
} = sections[sectionIndex];
|
||||||
|
|
||||||
|
if (!headingRef?.current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = remToPx(offsetRem);
|
||||||
|
const top =
|
||||||
|
headingRef.current.getBoundingClientRect().top + scrollY;
|
||||||
|
|
||||||
|
if (sectionIndex === 0 && top - offset > scrollY) {
|
||||||
|
newVisibleSections.push("_top");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSection = sections[sectionIndex + 1];
|
||||||
|
const bottom =
|
||||||
|
(nextSection?.headingRef?.current?.getBoundingClientRect()
|
||||||
|
.top ?? Number.POSITIVE_INFINITY) +
|
||||||
|
scrollY -
|
||||||
|
remToPx(nextSection?.offsetRem ?? 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(top > scrollY && top < scrollY + innerHeight) ||
|
||||||
|
(bottom > scrollY && bottom < scrollY + innerHeight) ||
|
||||||
|
(top <= scrollY && bottom >= scrollY + innerHeight)
|
||||||
|
) {
|
||||||
|
newVisibleSections.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleSections(newVisibleSections);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raf = window.requestAnimationFrame(() => checkVisibleSections());
|
||||||
|
window.addEventListener("scroll", checkVisibleSections, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", checkVisibleSections);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("scroll", checkVisibleSections);
|
||||||
|
window.removeEventListener("resize", checkVisibleSections);
|
||||||
|
};
|
||||||
|
}, [setVisibleSections, sections]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectionStoreContext = createContext<StoreApi<SectionState> | null>(null);
|
||||||
|
|
||||||
|
const useIsomorphicLayoutEffect =
|
||||||
|
typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
|
export function SectionProvider({
|
||||||
|
sections,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
sections: Section[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [sectionStore] = useState(() => createSectionStore(sections));
|
||||||
|
|
||||||
|
useVisibleSections(sectionStore);
|
||||||
|
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
sectionStore.setState({ sections });
|
||||||
|
}, [sectionStore, sections]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionStoreContext.Provider value={sectionStore}>
|
||||||
|
{children}
|
||||||
|
</SectionStoreContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSectionStore<T>(selector: (state: SectionState) => T) {
|
||||||
|
const store = useContext(SectionStoreContext);
|
||||||
|
return useStore(store as NonNullable<typeof store>, selector);
|
||||||
|
}
|
||||||
58
components/Tag.tsx
Normal file
58
components/Tag.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
small: "",
|
||||||
|
medium: "rounded-lg px-1.5 ring-1 ring-inset",
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorStyles = {
|
||||||
|
emerald: {
|
||||||
|
small: "text-emerald-500 dark:text-emerald-400",
|
||||||
|
medium: "ring-emerald-300 dark:ring-emerald-400/30 bg-emerald-400/10 text-emerald-500 dark:text-emerald-400",
|
||||||
|
},
|
||||||
|
sky: {
|
||||||
|
small: "text-sky-500",
|
||||||
|
medium: "ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400",
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
small: "text-amber-500",
|
||||||
|
medium: "ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400",
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
small: "text-red-500 dark:text-rose-500",
|
||||||
|
medium: "ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400",
|
||||||
|
},
|
||||||
|
zinc: {
|
||||||
|
small: "text-zinc-400 dark:text-zinc-500",
|
||||||
|
medium: "ring-zinc-200 bg-zinc-50 text-zinc-500 dark:ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueColorMap = {
|
||||||
|
GET: "emerald",
|
||||||
|
POST: "sky",
|
||||||
|
PUT: "amber",
|
||||||
|
DELETE: "rose",
|
||||||
|
} as Record<string, keyof typeof colorStyles>;
|
||||||
|
|
||||||
|
export function Tag({
|
||||||
|
children,
|
||||||
|
variant = "medium",
|
||||||
|
color = valueColorMap[children] ?? "emerald",
|
||||||
|
}: {
|
||||||
|
children: keyof typeof valueColorMap;
|
||||||
|
variant?: keyof typeof variantStyles;
|
||||||
|
color?: keyof typeof colorStyles;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"font-mono text-[0.625rem] font-semibold leading-6",
|
||||||
|
variantStyles[variant],
|
||||||
|
colorStyles[color][variant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="mt-20">
|
|
||||||
<div class="max-w-3xl">
|
|
||||||
<h1>Thank you!</h1>
|
|
||||||
<p>
|
|
||||||
The Lysand project is made possible by the hard work of our contributors. Here are some of the people
|
|
||||||
who
|
|
||||||
have helped make Lysand what it is today.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ul role="list"
|
|
||||||
class="!mt-10 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:grid-cols-2 lg:max-w-none lg:grid-cols-3 !list-none !pl-0">
|
|
||||||
<li v-for="person in people" :key="person.name"
|
|
||||||
class="bg-[var(--vp-c-bg-soft)] shadow rounded duration-200 !m-0 hover:ring-2 hover:scale-[101%] ring-[var(--vp-color-primary)] p-4">
|
|
||||||
<img class="aspect-[3/2] w-full rounded object-cover ring-1 ring-white/5" :src="person.imageUrl"
|
|
||||||
:alt="`${person.name}'s avatar'`" />
|
|
||||||
<h3 class="mt-6">{{ person.name }}</h3>
|
|
||||||
<p class="!mt-3">
|
|
||||||
<span v-for="role in person.roles"
|
|
||||||
class="text-sm mr-2 last:mr-0 rounded bg-pink-700 text-pink-100 px-2 py-1">{{
|
|
||||||
role }}</span>
|
|
||||||
</p>
|
|
||||||
<ul role="list" class="!mt-6 !flex !gap-6 !list-none !pl-0 flex-wrap">
|
|
||||||
<li v-for="social in person.socials" :key="social.name" class="!m-0">
|
|
||||||
<a :href="social.url" class="text-[var(--vp-color-primary)]" target="_blank" rel="noreferrer">
|
|
||||||
<iconify-icon :icon="social.icon" class="text-2xl" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const people = [
|
|
||||||
{
|
|
||||||
name: "CPlusPatch",
|
|
||||||
roles: ["Lead Developer", "UI Designer"],
|
|
||||||
imageUrl: "https://avatars.githubusercontent.com/u/42910258?v=4",
|
|
||||||
socials: [
|
|
||||||
{
|
|
||||||
name: "Website",
|
|
||||||
icon: "bx:link",
|
|
||||||
url: "https://cpluspatch.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GitHub",
|
|
||||||
icon: "bxl:github",
|
|
||||||
url: "https://github.com/cpluspatch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fediverse",
|
|
||||||
icon: "bxl:mastodon",
|
|
||||||
url: "https://mk.cpluspatch.com/@jessew",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lysand",
|
|
||||||
icon: "bx:server",
|
|
||||||
url: "https://social.lysand.org/@jessew",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Matrix",
|
|
||||||
icon: "simple-icons:matrix",
|
|
||||||
url: "https://matrix.to/#/@jesse:cpluspatch.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Signal",
|
|
||||||
icon: "simple-icons:signal",
|
|
||||||
url: "https://signal.me/#eu/mdX6iV0ayndNmJst43sNtlw3eFXgHSm7if4Y/mwYT1+qFDzl1PFAeroW+RpHGaRu",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Email",
|
|
||||||
icon: "bx:bxs-envelope",
|
|
||||||
url: "mailto:contact@cpluspatch.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "April",
|
|
||||||
roles: ["ActivityPub Bridge Developer"],
|
|
||||||
imageUrl: "https://avatars.githubusercontent.com/u/30842467?v=4",
|
|
||||||
socials: [
|
|
||||||
{
|
|
||||||
name: "GitHub",
|
|
||||||
icon: "bxl:github",
|
|
||||||
url: "https://github.com/cutestnekoaqua",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fediverse",
|
|
||||||
icon: "bxl:mastodon",
|
|
||||||
url: "https://donotsta.re/april",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lysand",
|
|
||||||
icon: "bx:server",
|
|
||||||
url: "https://social.lysand.org/@aprl",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Matrix",
|
|
||||||
icon: "simple-icons:matrix",
|
|
||||||
url: "https://matrix.to/#/@aprl:uwu.is",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Email",
|
|
||||||
icon: "bx:bxs-envelope",
|
|
||||||
url: "mailto:aprl@acab.dev",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
46
components/ThemeToggle.tsx
Normal file
46
components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { type ComponentPropsWithoutRef, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function SunIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path d="M12.5 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z" />
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
d="M10 5.5v-1M13.182 6.818l.707-.707M14.5 10h1M13.182 13.182l.707.707M10 15.5v-1M6.11 13.889l.708-.707M4.5 10h1M6.11 6.111l.708.707"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
|
<path d="M15.224 11.724a5.5 5.5 0 0 1-6.949-6.949 5.5 5.5 0 1 0 6.949 6.949Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const otherTheme = resolvedTheme === "dark" ? "light" : "dark";
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||||
|
aria-label={
|
||||||
|
mounted ? `Switch to ${otherTheme} theme` : "Toggle theme"
|
||||||
|
}
|
||||||
|
onClick={() => setTheme(otherTheme)}
|
||||||
|
>
|
||||||
|
<SunIcon className="h-5 w-5 stroke-zinc-900 dark:hidden" />
|
||||||
|
<MoonIcon className="hidden h-5 w-5 stroke-white dark:block" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/BellIcon.tsx
Normal file
19
components/icons/BellIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function BellIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.438 8.063a5.563 5.563 0 0 1 11.125 0v2.626c0 1.182.34 2.34.982 3.332L17.5 15.5h-15l.955-1.479c.641-.993.982-2.15.982-3.332V8.062Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 15.5v0a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
components/icons/BoltIcon.tsx
Normal file
13
components/icons/BoltIcon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function BoltIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.5 11.5 10 2v5.5a1 1 0 0 0 1 1h4.5L10 18v-5.5a1 1 0 0 0-1-1H4.5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/BookIcon.tsx
Normal file
19
components/icons/BookIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function BookIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m10 5.5-7.5-3v12l7.5 3m0-12 7.5-3v12l-7.5 3m0-12v12"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m17.5 2.5-7.5 3v12l7.5-3v-12Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/icons/CalendarIcon.tsx
Normal file
25
components/icons/CalendarIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function CalendarIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-9Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v2h-15v-2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5.5 5.5v-3M14.5 5.5v-3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
components/icons/CartIcon.tsx
Normal file
17
components/icons/CartIcon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function CartIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
d="M5.98 11.288 3.5 5.5h14l-2.48 5.788A2 2 0 0 1 13.18 12.5H7.82a2 2 0 0 1-1.838-1.212Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m3.5 5.5 2.48 5.788A2 2 0 0 0 7.82 12.5h5.362a2 2 0 0 0 1.839-1.212L17.5 5.5h-14Zm0 0-1-2M6.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM14.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/ChatBubbleIcon.tsx
Normal file
19
components/icons/ChatBubbleIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function ChatBubbleIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 16.5c4.142 0 7.5-3.134 7.5-7s-3.358-7-7.5-7c-4.142 0-7.5 3.134-7.5 7 0 1.941.846 3.698 2.214 4.966L3.5 17.5c2.231 0 3.633-.553 4.513-1.248A8.014 8.014 0 0 0 10 16.5Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 8.5h5M8.5 11.5h3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/CheckIcon.tsx
Normal file
19
components/icons/CheckIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function CheckIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m7.5 10.5 2 2c1-3.5 3-5 3-5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/ChevronRightLeftIcon.tsx
Normal file
19
components/icons/ChevronRightLeftIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function ChevronRightLeftIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M1.5 10A6.5 6.5 0 0 1 8 3.5h4a6.5 6.5 0 1 1 0 13H8A6.5 6.5 0 0 1 1.5 10Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m7.5 7.5-3 2.5 3 2.5M12.5 7.5l3 2.5-3 2.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/ClipboardIcon.tsx
Normal file
19
components/icons/ClipboardIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function ClipboardIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.5 6v10a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1l-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4h-1a2 2 0 0 0-2 2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m13.5 4-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4l.724-1.447A1 1 0 0 1 8.118 2h3.764a1 1 0 0 1 .894.553L13.5 4Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/icons/CogIcon.tsx
Normal file
21
components/icons/CogIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function CogIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M11.063 1.5H8.937l-.14 1.128c-.086.682-.61 1.22-1.246 1.484-.634.264-1.37.247-1.912-.175l-.898-.699-1.503 1.503.699.898c.422.543.44 1.278.175 1.912-.264.635-.802 1.16-1.484 1.245L1.5 8.938v2.124l1.128.142c.682.085 1.22.61 1.484 1.244.264.635.247 1.37-.175 1.913l-.699.898 1.503 1.503.898-.699c.543-.422 1.278-.44 1.912-.175.635.264 1.16.801 1.245 1.484l.142 1.128h2.124l.142-1.128c.085-.683.61-1.22 1.244-1.484.635-.264 1.37-.247 1.913.175l.898.699 1.503-1.503-.699-.898c-.422-.543-.44-1.278-.175-1.913.264-.634.801-1.16 1.484-1.245l1.128-.14V8.937l-1.128-.14c-.683-.086-1.22-.611-1.484-1.246-.264-.634-.247-1.37.175-1.912l.699-.898-1.503-1.503-.898.699c-.543.422-1.278.44-1.913.175-.634-.264-1.16-.802-1.244-1.484L11.062 1.5ZM10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.938 1.5h2.124l.142 1.128c.085.682.61 1.22 1.244 1.484v0c.635.264 1.37.247 1.913-.175l.898-.699 1.503 1.503-.699.898c-.422.543-.44 1.278-.175 1.912v0c.264.635.801 1.16 1.484 1.245l1.128.142v2.124l-1.128.142c-.683.085-1.22.61-1.484 1.244v0c-.264.635-.247 1.37.175 1.913l.699.898-1.503 1.503-.898-.699c-.543-.422-1.278-.44-1.913-.175v0c-.634.264-1.16.801-1.245 1.484l-.14 1.128H8.937l-.14-1.128c-.086-.683-.611-1.22-1.246-1.484v0c-.634-.264-1.37-.247-1.912.175l-.898.699-1.503-1.503.699-.898c.422-.543.44-1.278.175-1.913v0c-.264-.634-.802-1.16-1.484-1.245l-1.128-.14V8.937l1.128-.14c.682-.086 1.22-.61 1.484-1.246v0c.264-.634.247-1.37-.175-1.912l-.699-.898 1.503-1.503.898.699c.543.422 1.278.44 1.912.175v0c.635-.264 1.16-.802 1.245-1.484L8.938 1.5Z"
|
||||||
|
/>
|
||||||
|
<circle cx="10" cy="10" r="2.5" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/CopyIcon.tsx
Normal file
19
components/icons/CopyIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function CopyIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.5 5.5v-1a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5.5 7.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/DocumentIcon.tsx
Normal file
19
components/icons/DocumentIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function DocumentIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.5 4.5v11a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-8h-5v-5h-6a2 2 0 0 0-2 2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m11.5 2.5 5 5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/EnvelopeIcon.tsx
Normal file
19
components/icons/EnvelopeIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function EnvelopeIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.5 5.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v8a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-8Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 10 4.526 5.256c-.7-.607-.271-1.756.655-1.756h9.638c.926 0 1.355 1.15.655 1.756L10 10Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/FaceSmileIcon.tsx
Normal file
19
components/icons/FaceSmileIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function FaceSmileIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 6.5v2M12.5 6.5v2M5.5 11.5s1 3 4.5 3 4.5-3 4.5-3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
components/icons/FolderIcon.tsx
Normal file
24
components/icons/FolderIcon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function FolderIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M17.5 15.5v-8a2 2 0 0 0-2-2h-2.93a2 2 0 0 1-1.664-.89l-.812-1.22A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
d="M8.43 2.5H4.5a2 2 0 0 0-2 2v1h9l-1.406-2.11A2 2 0 0 0 8.43 2.5Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m11.5 5.5-1.406-2.11A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v1h9Zm0 0h2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/icons/LinkIcon.tsx
Normal file
14
components/icons/LinkIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function LinkIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m5.056 11.5-1.221-1.222a4.556 4.556 0 0 1 6.443-6.443L11.5 5.056M7.5 7.5l5 5m2.444-4 1.222 1.222a4.556 4.556 0 0 1-6.444 6.444L8.5 14.944"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/ListIcon.tsx
Normal file
19
components/icons/ListIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function ListIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.5 4.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-11Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6.5 6.5h7M6.5 13.5h7M6.5 10h7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/icons/MagnifyingGlassIcon.tsx
Normal file
15
components/icons/MagnifyingGlassIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function MagnifyingGlassIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path strokeWidth="0" d="M2.5 8.5a6 6 0 1 1 12 0 6 6 0 0 1-12 0Z" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m13 13 4.5 4.5m-9-3a6 6 0 1 1 0-12 6 6 0 0 1 0 12Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/icons/MapPinIcon.tsx
Normal file
21
components/icons/MapPinIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function MapPinIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 2.5A5.5 5.5 0 0 0 4.5 8c0 3.038 5.5 9.5 5.5 9.5s5.5-6.462 5.5-9.5A5.5 5.5 0 0 0 10 2.5Zm0 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.5 8a5.5 5.5 0 1 1 11 0c0 3.038-5.5 9.5-5.5 9.5S4.5 11.038 4.5 8Z"
|
||||||
|
/>
|
||||||
|
<circle cx="10" cy="8" r="1.5" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/icons/PackageIcon.tsx
Normal file
18
components/icons/PackageIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function PackageIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
d="m10 9.5-7.5-4v9l7.5 4v-9ZM10 9.5l7.5-4v9l-7.5 4v-9Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m2.5 5.5 7.5 4m-7.5-4v9l7.5 4m-7.5-13 7.5-4 7.5 4m-7.5 4v9m0-9 7.5-4m-7.5 13 7.5-4v-9m-11 6 .028-3.852L13.5 3.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/PaperAirplaneIcon.tsx
Normal file
19
components/icons/PaperAirplaneIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function PaperAirplaneIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M17 3L1 9L8 12M17 3L11 19L8 12M17 3L8 12"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M11 19L8 12L17 3L11 19Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/icons/PaperClipIcon.tsx
Normal file
14
components/icons/PaperClipIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function PaperClipIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m15.56 7.375-3.678-3.447c-2.032-1.904-5.326-1.904-7.358 0s-2.032 4.99 0 6.895l6.017 5.639c1.477 1.384 3.873 1.384 5.35 0 1.478-1.385 1.478-3.63 0-5.015L10.21 6.122a1.983 1.983 0 0 0-2.676 0 1.695 1.695 0 0 0 0 2.507l4.013 3.76"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/ShapesIcon.tsx
Normal file
19
components/icons/ShapesIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function ShapesIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.5 7.5v-4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1ZM11.5 16.5v-4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m2.5 17.5 3-6 3 6h-6ZM14.5 2.5a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
components/icons/ShirtIcon.tsx
Normal file
13
components/icons/ShirtIcon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function ShirtIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12.5 1.5s0 2-2.5 2-2.5-2-2.5-2h-2L2.207 4.793a1 1 0 0 0 0 1.414L4.5 8.5v10h11v-10l2.293-2.293a1 1 0 0 0 0-1.414L14.5 1.5h-2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/icons/SquaresPlusIcon.tsx
Normal file
19
components/icons/SquaresPlusIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function SquaresPlusIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.5 4.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2ZM8.5 13.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2ZM17.5 4.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.5 11.5v6M17.5 14.5h-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/icons/TagIcon.tsx
Normal file
21
components/icons/TagIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function TagIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M3 8.69499V3H8.69499C9.18447 3 9.65389 3.19444 10 3.54055L16.4594 10C17.1802 10.7207 17.1802 11.8893 16.4594 12.61L12.61 16.4594C11.8893 17.1802 10.7207 17.1802 10 16.4594L3.54055 10C3.19444 9.65389 3 9.18447 3 8.69499ZM7 8.5C7.82843 8.5 8.5 7.82843 8.5 7C8.5 6.17157 7.82843 5.5 7 5.5C6.17157 5.5 5.5 6.17157 5.5 7C5.5 7.82843 6.17157 8.5 7 8.5Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 3V8.69499C3 9.18447 3.19444 9.65389 3.54055 10L10 16.4594C10.7207 17.1802 11.8893 17.1802 12.61 16.4594L16.4594 12.61C17.1802 11.8893 17.1802 10.7207 16.4594 10L10 3.54055C9.65389 3.19444 9.18447 3 8.69499 3H3Z"
|
||||||
|
/>
|
||||||
|
<circle cx="7" cy="7" r="1.5" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
components/icons/UserIcon.tsx
Normal file
26
components/icons/UserIcon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function UserIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
strokeWidth="0"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 .5a9.5 9.5 0 0 1 5.598 17.177C14.466 15.177 12.383 13.5 10 13.5s-4.466 1.677-5.598 4.177A9.5 9.5 0 0 1 10 .5ZM12.5 8a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 .5a9.5 9.5 0 0 1 5.598 17.177A9.458 9.458 0 0 1 10 19.5a9.458 9.458 0 0 1-5.598-1.823A9.5 9.5 0 0 1 10 .5Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.402 17.677C5.534 15.177 7.617 13.5 10 13.5s4.466 1.677 5.598 4.177M10 5.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
components/icons/UsersIcon.tsx
Normal file
30
components/icons/UsersIcon.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
export function UsersIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.046 16H1.955a.458.458 0 0 1-.455-.459C1.5 13.056 3.515 11 6 11h.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 15.454C7.5 12.442 9.988 10 13 10s5.5 2.442 5.5 5.454a.545.545 0 0 1-.546.546H8.045a.545.545 0 0 1-.545-.546Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6.5 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13 2a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
components/mdx.tsx
Normal file
126
components/mdx.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
|
import { Feedback } from "./Feedback";
|
||||||
|
import { Heading } from "./Heading";
|
||||||
|
import { Prose } from "./Prose";
|
||||||
|
|
||||||
|
export const a = Link;
|
||||||
|
// biome-ignore lint/performance/noBarrelFile: <explanation>
|
||||||
|
export { Button } from "./Button";
|
||||||
|
export { CodeGroup, Code as code, Pre as pre } from "./Code";
|
||||||
|
|
||||||
|
export function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<article className="flex h-full flex-col pb-10 pt-16">
|
||||||
|
<Prose className="flex-auto">{children}</Prose>
|
||||||
|
<footer className="mx-auto mt-16 w-full max-w-2xl lg:max-w-5xl">
|
||||||
|
<Feedback />
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const h2 = function H2(
|
||||||
|
props: Omit<ComponentPropsWithoutRef<typeof Heading>, "level">,
|
||||||
|
) {
|
||||||
|
return <Heading level={2} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoIcon(props: ComponentPropsWithoutRef<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||||
|
<circle cx="8" cy="8" r="8" strokeWidth="0" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
d="M6.75 7.75h1.5v3.5"
|
||||||
|
/>
|
||||||
|
<circle cx="8" cy="4" r=".5" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Note({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-6 flex gap-2.5 rounded-2xl border border-emerald-500/20 bg-emerald-50/50 p-4 leading-6 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/5 dark:text-emerald-200 dark:[--tw-prose-links-hover:theme(colors.emerald.300)] dark:[--tw-prose-links:theme(colors.white)]">
|
||||||
|
<InfoIcon className="mt-1 h-4 w-4 flex-none fill-emerald-500 stroke-white dark:fill-emerald-200/20 dark:stroke-emerald-200" />
|
||||||
|
<div className="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Row({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 items-start gap-x-16 gap-y-10 xl:max-w-none xl:grid-cols-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Col({
|
||||||
|
children,
|
||||||
|
sticky = false,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"[&>:first-child]:mt-0 [&>:last-child]:mb-0",
|
||||||
|
sticky && "xl:sticky xl:top-24",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Properties({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-6">
|
||||||
|
<ul className="m-0 max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))] list-none divide-y divide-zinc-900/5 p-0 dark:divide-white/5">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Property({
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
children: ReactNode;
|
||||||
|
type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="m-0 px-0 py-4 first:pt-0 last:pb-0">
|
||||||
|
<dl className="m-0 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<dt className="sr-only">Name</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{name}</code>
|
||||||
|
</dd>
|
||||||
|
{type && (
|
||||||
|
<>
|
||||||
|
<dt className="sr-only">Type</dt>
|
||||||
|
<dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">
|
||||||
|
{type}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="sr-only">Description</dt>
|
||||||
|
<dd className="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||||
|
{children}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# Protocol Extensions
|
|
||||||
|
|
||||||
Lysand accommodates protocol extensions, which are enhancements to the protocol that are not part of the core protocol. These extensions are designed to augment the protocol with additional features and are namespaced.
|
|
||||||
|
|
||||||
Protocol extensions can be incorporated into an object as follows:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"com.organization.name:key": "value"
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `extensions` field is an object that comprises a list of extensions. Each extension is a key-value pair, where the key represents the extension name, and the value signifies the extension value.
|
|
||||||
|
|
||||||
The extension name **MUST** be a string that includes the reverse domain name of the organization that devised the extension, followed by a colon, and then the name of the extension. For instance, `com.example:extension_name`.
|
|
||||||
|
|
||||||
The extension name **MUST** be unique within the organization namespace (i.e., it should be unique for each organization).
|
|
||||||
|
|
||||||
The extension value **MAY** be any valid JSON value. The decision to implement extensions is at the discretion of the servers.
|
|
||||||
|
|
||||||
For instance, a server might implement an extension that enables users to geotag an object. The extension name could be `org.geotagger:geotag`, and the extension value might be a string that contains the geotag.
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.geotagger:geotag": "40.7128° N, 74.0060° W"
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Lysand strongly advocates that extensions are documented and standardized, and that servers refrain from implementing extensions that are not documented or standardized by their author. Moreover, official extensions of the Lysand protocol should take precedence over custom extensions. (Third-party extensions may be incorporated into the official spec if necessary).
|
|
||||||
|
|
||||||
## Adding New Object Types
|
|
||||||
|
|
||||||
Lysand supports the addition of new object types via extensions. This is beneficial for introducing new types of objects to the protocol, such as polls or events.
|
|
||||||
|
|
||||||
Every new object type added **MUST** have `Extension` as its object type, and **MUST** have an `extension_type` field that contains the extension name of the object type.
|
|
||||||
|
|
||||||
The extension name of the object type is formatted as follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
com.organization.name:extension/Type
|
|
||||||
```
|
|
||||||
|
|
||||||
For instance, if a server wishes to add a new object type named `Poll`, the extension name would be `com.example:poll/Poll`.
|
|
||||||
|
|
||||||
Custom types **MUST** commence with a capital letter, **MUST** be alphanumeric values (with PascalCase used instead of spaces) and **MUST** be unique across all extensions.
|
|
||||||
|
|
||||||
Custom types **MUST** be unique within their organization namespace (i.e., it should be unique for each organization).
|
|
||||||
|
|
||||||
An example is provided in the following object:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "com.example:poll/Poll",
|
|
||||||
"id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"question": "What is your favourite colour?",
|
|
||||||
"options": [
|
|
||||||
"Red",
|
|
||||||
"Blue",
|
|
||||||
"Green"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Official Protocol Extensions
|
|
||||||
|
|
||||||
Lysand has a selection of official extensions that are part of the core protocol. These extensions are standardized and documented, and servers **SHOULD** implement them if they implement the core protocol (however, they are not obligated to do so).
|
|
||||||
|
|
||||||
These include:
|
|
||||||
|
|
||||||
- [Custom Emojis](/extensions/custom-emojis)
|
|
||||||
- [Reactions](/extensions/reactions)
|
|
||||||
- [Polls](/extensions/polls)
|
|
||||||
- [Is Cat](/extensions/is-cat)
|
|
||||||
- [Server Endorsement](/extensions/server-endorsement)
|
|
||||||
- [Reports](/extensions/reports)
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Specific extension types will extend from this
|
|
||||||
interface Extension extends Entity {
|
|
||||||
type: "Extension";
|
|
||||||
extension_type: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# Custom Emojis
|
|
||||||
|
|
||||||
For detailed information about the Custom Emoji type, refer to [Custom Emojis](../structures/custom-emoji). The implementation of the extension is as follows:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:custom_emojis": {
|
|
||||||
"emojis": [
|
|
||||||
{
|
|
||||||
"name": "happy_face",
|
|
||||||
"url": {
|
|
||||||
"image/png": {
|
|
||||||
"content": "https://cdn.example.com/emojis/happy_face.png",
|
|
||||||
"content_type": "image/png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In this context, the extension name is `org.lysand:custom_emojis`, and the extension value is an object that includes an array of emojis.
|
|
||||||
|
|
||||||
## Utilizing Custom Emojis
|
|
||||||
|
|
||||||
Clients are **required** to implement custom emojis in any text field where their presence is plausible. This includes, but is not limited to, status text, display names, alt text, and bio fields. However, this does not extend to an [Actor](../objects/actors)'s username, for instance.
|
|
||||||
|
|
||||||
A custom emoji is represented within a text string as follows:
|
|
||||||
```
|
|
||||||
:emoji_name:
|
|
||||||
```
|
|
||||||
|
|
||||||
For instance, to use the `happy_face` emoji, a user would type:
|
|
||||||
```
|
|
||||||
:happy_face:
|
|
||||||
```
|
|
||||||
|
|
||||||
Clients are **required** to substitute the `:emoji_name:` with the corresponding inline emoji. If the client does not support custom emojis, it **should** display the `:emoji_name:` as it is.
|
|
||||||
|
|
||||||
If the client supports Custom Emojis, but does not support a specific emoji that the user is attempting to use (such as with an incompatible MIME type), it **should** display the `:emoji_name:` as it is.
|
|
||||||
|
|
||||||
When rendered as images, Custom Emojis **should** have appropriate alt text for accessibility. The alt text **should** be the alt text of the emoji, if it exists. If the emoji does not have an alt text, the alt text **should** be the name of the emoji.
|
|
||||||
|
|
||||||
### Styling Custom Emojis
|
|
||||||
|
|
||||||
If the styling system, such as CSS, supports it, clients **should** style the emoji to match the height of the text but allow it to take as much width as necessary, instead of treating it as a square and potentially distorting the image.
|
|
||||||
|
|
||||||
Example in HTML:
|
|
||||||
```html
|
|
||||||
Hello, world! <img src="https://cdn.example.com/emojis/happy_face.webp" alt="A happy face emoji." title="A happy face emoji." style="display: inline; height: 1em;">
|
|
||||||
```
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Events
|
|
||||||
|
|
||||||
With the Events extension, users can create events. This is useful for creating gatherings, such as meetups or parties.
|
|
||||||
|
|
||||||
This extension is planned but not yet drafted.
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Interactivity
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> This extension is a work in progress and is not to be used in any production system. The specification is subject to change.
|
|
||||||
|
|
||||||
On platforms like Discord, users can interact with messages with custom fields like buttons or dropdowns. This extension allows you to define these fields in your messages.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# IsCat
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> This is a **light-hearted** extension designed for amusement and not intended for serious application.
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> This extension may be superseded by the upcoming [Vanity Profiles](./vanity) extension.
|
|
||||||
|
|
||||||
The IsCat feature allows users to communicate their cat status to others, similar to Misskey's "IsCat" feature.
|
|
||||||
|
|
||||||
A user, referred to as an Actor, can specify their feline status using the following field:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "User",
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:is_cat": {
|
|
||||||
"cat": true,
|
|
||||||
// Potential additional fields
|
|
||||||
// "dog": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Clients **SHOULD** display an appropriate graphic to signify a user's cat status, such as adding cat ears to the user's avatar.
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
# Microblogging
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
>
|
|
||||||
> Before Lysand 3.0, microblogging was directly integrated into the core spec. As of Lysand 3.0, microblogging has been moved to an extension, as part of a larger modularization effort. This document describes the new microblogging extension.
|
|
||||||
|
|
||||||
The Microblogging extension allows users to perform certain tasks related to microblogging, such as "boosting" (reposting) posts.
|
|
||||||
|
|
||||||
## Announce
|
|
||||||
|
|
||||||
The `Announce` action signifies a user's intent to broadcast or share an object with their followers. This action is analogous to the "retweet" function on Twitter.
|
|
||||||
|
|
||||||
|
|
||||||
`Announce`s can of course be deleted ("unboosting") with a classic [Undo](../objects/undo) object.
|
|
||||||
|
|
||||||
Here's an example of an `Announce` action:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"id": "3e7e4750-afd4-4d99-a256-02f0710a0520",
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:microblogging/Announce",
|
|
||||||
"author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
|
|
||||||
"uri": "https://example.com/actions/3e7e4750-afd4-4d99-a256-02f0710a0520",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
#### Author
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :----- | :------- |
|
|
||||||
| author | String | Yes |
|
|
||||||
|
|
||||||
URI of the [Actor](../objects/actors) who initiated the action.
|
|
||||||
|
|
||||||
#### Object
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :----- | :------- |
|
|
||||||
| object | String | Yes |
|
|
||||||
|
|
||||||
URI of the object being announced. Must be of type [Note](../objects/note)
|
|
||||||
|
|
||||||
#### Implementation
|
|
||||||
|
|
||||||
When a [Note](../objects/note) object is announced, the client **SHOULD** display the original note with an indicator that it has been announced. The client **SHOULD** also display the number of times the note has been announced, such as a number next to a small icon like such on [Mastodon](https://joinmastodon.org/):
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Furthermore, users should be notified when their notes are announced by other users.
|
|
||||||
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Announce extends Extension {
|
|
||||||
extension_type: "org.lysand:microblogging/Announce";
|
|
||||||
author: string;
|
|
||||||
object: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
# Migration
|
|
||||||
|
|
||||||
Sometimes, users may wish to move from one instance to another. This could be due to a change in administration, a desire to be closer to friends, or any other reason. This document outlines an extension to make the process of moving instances easier.
|
|
||||||
|
|
||||||
## User migrations
|
|
||||||
|
|
||||||
The following object is used to represent a user migration:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:migration/Migration",
|
|
||||||
"author": "https://example.com/users/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
||||||
"uri": "https://example.com/actions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"destination": "https://otherinstance.social/users/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
#### Author
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :--- | :------- |
|
|
||||||
| author | URI | Yes |
|
|
||||||
|
|
||||||
URI of the [Actor](../objects/actors) who initiated the action. This Actor will be the user migrating.
|
|
||||||
|
|
||||||
#### Destination
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :---------- | :--- | :------- |
|
|
||||||
| destination | URI | Yes |
|
|
||||||
|
|
||||||
URI of the user's new account.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
When an instance receives a `Migration` object, the client **SHOULD** display a notification to all followers of the migrating user. This notification **SHOULD** include a link to the user's new account.
|
|
||||||
|
|
||||||
Furthermore, all following relationships of the migrating user **SHOULD** be transferred to the new account. This includes followers, following, and any other relationship that may exist. The old account **SHOULD** be marked as inactive and display a message indicating that the user has migrated to a new account.
|
|
||||||
|
|
||||||
#### Server Actors
|
|
||||||
|
|
||||||
If the user in question is a server actor, then it should be considered that the entire instance is migrating to a new address. In this case, the above process should be applied to all users on the instance.
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
# Polls
|
|
||||||
|
|
||||||
The Polls extension enables users to generate polls/surveys, a valuable tool for soliciting feedback or opinions from users, such as "What is your preferred color?".
|
|
||||||
|
|
||||||
Polls are incorporated as a new field under the [Note](../objects/note) Extensions, named `polls`. This field is an object that encapsulates the poll details.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"id": "f08a124e-fe90-439e-8be4-15a428a72a19",
|
|
||||||
"type": "Note",
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:polls": {
|
|
||||||
"poll": {
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"text/plain": {
|
|
||||||
"content": "Red"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text/plain": {
|
|
||||||
"content": "Blue"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text/plain": {
|
|
||||||
"content": "Green"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"votes": [
|
|
||||||
9,
|
|
||||||
5,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"multiple_choice": false,
|
|
||||||
"expires_at": "2021-01-04T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The fields are explained below.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> There is no `question` field, as it is presumed that the question will be included in the `contents` field of the associated [Note](../objects/note). Clients are anticipated to render surveys adjacent to the contents of the [Note](../objects/note) itself.
|
|
||||||
|
|
||||||
## Fields
|
|
||||||
|
|
||||||
### Options
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------ | :--------------------- | :------- |
|
|
||||||
| options | Array of ContentFormat | Yes |
|
|
||||||
|
|
||||||
Displays the various options users can vote for.
|
|
||||||
|
|
||||||
**MUST** contain at least 2 options, but does not have an upper limit for the number of options.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Servers should limit the number of options to a reasonable number, preferably in a configurable manner, such as 40. This is to prevent abuse of the protocol by sending a large number of options, as they are not paginated.
|
|
||||||
|
|
||||||
### Votes
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :---- | :--------------- | :------- |
|
|
||||||
| votes | Array of Integer | Yes |
|
|
||||||
|
|
||||||
Contains the number of votes cast for each option. The index of the array corresponds to the index of the option in the `options` array.
|
|
||||||
|
|
||||||
Votes should not be public: the server should hide the users that casted votes and only show the total amount.
|
|
||||||
|
|
||||||
### Multiple Choice
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :-------------- | :------ | :------- |
|
|
||||||
| multiple_choice | Boolean | No |
|
|
||||||
|
|
||||||
Indicates whether the poll is multiple choice. If true, users can vote for multiple options. If false, users can only vote for one option.
|
|
||||||
|
|
||||||
If not provided, it is assumed that the poll is not multiple choice.
|
|
||||||
|
|
||||||
### Expiration
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :--------- | :----- | :------- |
|
|
||||||
| expires_at | String | Yes |
|
|
||||||
|
|
||||||
The date and time when the poll expires. After this time, the poll is closed and no more votes can be cast.
|
|
||||||
|
|
||||||
Clients **SHOULD** display the time remaining until the poll expires.
|
|
||||||
|
|
||||||
### Integration With Custom Emojis
|
|
||||||
|
|
||||||
If you implement both the Polls and the [Custom Emojis](./custom-emojis) extensions, you can use the Custom Emojis extension to add emojis to poll options.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:polls": {
|
|
||||||
"poll": {
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"text/plain": {
|
|
||||||
"content": "Red :red:"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text/plain": {
|
|
||||||
"content": "Blue :blue:"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text/plain": {
|
|
||||||
"content": "Green :green:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"votes": [
|
|
||||||
9,
|
|
||||||
5,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"multiple_choice": false,
|
|
||||||
"expires_at": "2021-01-04T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"org.lysand:custom_emojis": {
|
|
||||||
"emojis": [
|
|
||||||
{
|
|
||||||
"name": "red",
|
|
||||||
"url": {
|
|
||||||
"image/webp": {
|
|
||||||
"content": "https://cdn.example.com/emojis/red.webp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "blue",
|
|
||||||
"url": {
|
|
||||||
"image/webp": {
|
|
||||||
"content": "https://cdn.example.com/emojis/blue.webp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "green",
|
|
||||||
"url": {
|
|
||||||
"image/webp": {
|
|
||||||
"content": "https://cdn.example.com/emojis/green.webp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When rendering the poll options, clients **SHOULD** display emojis as recommended by the [Custom Emojis](./custom-emojis) extension.
|
|
||||||
|
|
||||||
### Poll Results
|
|
||||||
|
|
||||||
Clients **SHOULD** display poll results as a percentage of votes. For example, if 10 users voted for the first option, and 5 users voted for the second option, the first option should be displayed as 66.67%, and the second option should be displayed as 33.33%. (with the third option being 0%)
|
|
||||||
|
|
||||||
Clients **SHOULD** display the number of votes for each option, and the total number of votes.
|
|
||||||
|
|
||||||
### Sending Votes
|
|
||||||
|
|
||||||
Clients **SHOULD** allow users to vote on polls. When a user votes on a poll, the client **MUST** send a `POST` request to the poll's [Note](../objects/note) URI with the following JSON object in the body:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:polls/Vote",
|
|
||||||
"id": "6b1586cf-1f83-4d85-8d70-a5dc9f213b3e",
|
|
||||||
"uri": "https://example.com/actions/6b1586cf-1f83-4d85-8d70-a5dc9f213b3e",
|
|
||||||
"author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
|
|
||||||
"poll": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19",
|
|
||||||
"option": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `option` field **MUST** be the index of the option in the `options` array that the user is voting for.
|
|
||||||
|
|
||||||
In return, the server **MUST** respond with a `200 OK` response code, and a JSON object in the body, unless there is an error. This JSON object **MUST** be a valid `VoteResult` object.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:polls/VoteResult",
|
|
||||||
"id": "d6eb84ea-cd13-43f9-9c54-01244da8e5e3",
|
|
||||||
"uri": "https://example.com/actions/d6eb84ea-cd13-43f9-9c54-01244da8e5e3",
|
|
||||||
"poll": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19",
|
|
||||||
"votes": [
|
|
||||||
10,
|
|
||||||
5,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each number in the `votes` array corresponds to the number of votes for each option. The index of the array corresponds to the index of the poll option in the original Poll object.
|
|
||||||
|
|
||||||
If the poll is closed, the server **MUST** respond with a `403 Forbidden` response code.
|
|
||||||
|
|
||||||
The total amount of votes can be calculated by summing the `votes` array.
|
|
||||||
|
|
||||||
This amount **MUST** include the user's vote, and **SHOULD** be displayed to the user after voting.
|
|
||||||
|
|
||||||
### Poll Events
|
|
||||||
|
|
||||||
When a poll ends, a user that has voted in it **SHOULD** be notified of the results by the server.
|
|
||||||
|
|
||||||
The server **MAY** send a `GET` request to the poll's Publication URI to update its internal database.
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Poll extends Extension {
|
|
||||||
extension_type: "org.lysand:polls/Poll";
|
|
||||||
options: ContentFormat[];
|
|
||||||
votes: number[]; // unsigned 64-bit integer
|
|
||||||
multiple_choice?: boolean;
|
|
||||||
expires_at: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Vote extends Extension {
|
|
||||||
extension_type: "org.lysand:polls/Vote";
|
|
||||||
poll: string;
|
|
||||||
option: number; // unsigned 64-bit integer
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface VoteResult extends Extension {
|
|
||||||
extension_type: "org.lysand:polls/VoteResult";
|
|
||||||
poll: string;
|
|
||||||
votes: number[]; // unsigned 64-bit integer
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
# Emoji Reactions
|
|
||||||
|
|
||||||
The Emoji Reactions extension allows users to express their responses to objects using emojis, similar to the functionality provided by platforms like Facebook and Discord.
|
|
||||||
|
|
||||||
Here's an example of a reaction to a post using this extension:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:reactions/Reaction",
|
|
||||||
"id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19",
|
|
||||||
"content": "😀",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
## Fields
|
|
||||||
|
|
||||||
### Type
|
|
||||||
|
|
||||||
The `type` field **MUST BE** `Extension`.
|
|
||||||
|
|
||||||
The `extension_type` field **MUST** be `org.lysand:reactions/Reaction`.
|
|
||||||
|
|
||||||
### Object
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :----- | :------- |
|
|
||||||
| object | String | Yes |
|
|
||||||
|
|
||||||
URI of the object that the user is reacting to. This **MUST** be a [Note](../objects/note) object.
|
|
||||||
|
|
||||||
### Content
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------ | :----- | :------- |
|
|
||||||
| content | String | Yes |
|
|
||||||
|
|
||||||
Emoji that the user is reacting with.
|
|
||||||
|
|
||||||
Clients **SHOULD** check if the value of `content` is an emoji: if it is not an emoji and instead is text, depending on which other extensions are implemented, it **MAY** be a [Custom Emoji](./custom-emojis).
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If this field is not recognized as an emoji or [Custom Emoji](./custom-emojis), the whole Reaction object can be discarded as it is invalid.
|
|
||||||
>
|
|
||||||
> Please see [Reactions With Custom Emojis](#reactions-with-custom-emojis) for more information about custom emoji reactions.
|
|
||||||
|
|
||||||
|
|
||||||
### Retrieving Reactions
|
|
||||||
|
|
||||||
Clients can retrieve reactions to an object by sending a `GET` request to the reaction [Collection](../structures/collection)'s URI.
|
|
||||||
|
|
||||||
The URI of the reaction [Collection](../structures/collection) **MUST** be specified as follows, inside a [Note](../objects/note):
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:reactions": {
|
|
||||||
"reactions": "https://example.com/notes/f08a124e-fe90-439e-8be4-15a428a72a19/reactions"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `reactions` field is the URI of the reaction `Collection`.
|
|
||||||
|
|
||||||
The server **MUST** respond with a [Collection](../structures/collection) object that contains a list of `Reaction` objects, as such:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Collection",
|
|
||||||
"first": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19/reactions?page=1",
|
|
||||||
"last": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19/reactions?page=1",
|
|
||||||
"total_count": 1,
|
|
||||||
// "author": ... (for signatures)
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:reactions/Reaction",
|
|
||||||
"id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19",
|
|
||||||
"content": "😀",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Public Reaction Federation
|
|
||||||
|
|
||||||
If a user reacts to a [Note](../objects/note), the user's server **MUST** federate the reaction to all its followers. This is to ensure that all users see the reaction.
|
|
||||||
|
|
||||||
Note, however, that this does not mean that the reaction will be displayed to users. If the [Note](../objects/note), that was reacted to is not visible to a user, the reaction **MUST NOT** be displayed to the user, even if the user follows the user that reacted to the [Note](../objects/note),.
|
|
||||||
|
|
||||||
### Private Reaction Federation
|
|
||||||
|
|
||||||
If a user reacts to a [Note](../objects/note),, the user's server **MUST** federate the reaction to the author of the [Note](../objects/note),. This is to ensure that the author of the [Note](../objects/note), sees the reaction.
|
|
||||||
|
|
||||||
### Reactions With Custom Emojis
|
|
||||||
|
|
||||||
If you implement both the Reactions and the Custom Emojis extensions, you can use the Custom Emojis extension to react with custom emojis.
|
|
||||||
|
|
||||||
The Reaction object needs to be modified as such:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:reactions/Reaction",
|
|
||||||
"id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19",
|
|
||||||
"content": ":happy_face:",
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:custom_emojis": {
|
|
||||||
"emojis": [
|
|
||||||
{
|
|
||||||
"name": "happy_face",
|
|
||||||
"url": {
|
|
||||||
"image/webp": {
|
|
||||||
"content": "https://cdn.example.com/emojis/happy_face.webp",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The only addition to the Reaction object is the `extensions` field, which contains the [Custom Emojis](./custom-emojis) extension.
|
|
||||||
|
|
||||||
When rendering the Reaction object, clients **MUST** replace the `:emoji_name:` with the appropriate emoji. If the client does not support custom emojis, it **MUST** display the `:emoji_name:` as-is.
|
|
||||||
|
|
||||||
This emoji must be rendered according to the rules of the [Custom Emojis](./custom-emojis) extension.
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Reaction extends Extension {
|
|
||||||
extension_type: "org.lysand:reactions/Reaction";
|
|
||||||
object: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
# Reports
|
|
||||||
|
|
||||||
The Reports extension enables users to flag content or users that violate the server's rules. This feature is important for maintaining a safe community environment.
|
|
||||||
|
|
||||||
If the reporting user (reporter) and the reported user (reportee) are on the same server, the report can be handled directly without the need for federation. However, if the reporter and reportee are on different servers, the report **MUST** be federated to the reportee's server.
|
|
||||||
|
|
||||||
## Report Object
|
|
||||||
|
|
||||||
The report object encapsulates the details of the report. It is structured as follows:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:reports/Report",
|
|
||||||
"author": "https://example.com/users/6f3001a1-641b-4763-a9c4-a089852eec84",
|
|
||||||
"id": "6f3001a1-641b-4763-a9c4-a089852eec84",
|
|
||||||
"uri": "https://example.com/actions/f7bbf7fc-88d2-47dd-b241-5d1f770a10f0",
|
|
||||||
"objects": [
|
|
||||||
"https://test.com/publications/46f936a3-9a1e-4b02-8cde-0902a89769fa",
|
|
||||||
"https://test.com/publications/213d7c56-fb9b-4646-a4d2-7d70aa7d106a"
|
|
||||||
],
|
|
||||||
"reason": "spam",
|
|
||||||
"comment": "This is spam."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Report events **MUST** be sent to the server actor's inbox.
|
|
||||||
|
|
||||||
## Fields
|
|
||||||
|
|
||||||
### Objects
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------ | :-------------- | :------- |
|
|
||||||
| objects | Array of String | Yes |
|
|
||||||
|
|
||||||
URIs of the objects that are being reported.
|
|
||||||
|
|
||||||
If `objects` contains Actors, then these Actors **MUST** be treated as the reported users.
|
|
||||||
|
|
||||||
If `objects` contains Notes, then these Notes **MUST** be treated as the reported content.
|
|
||||||
|
|
||||||
`objects` can contain any URI to any kind of objects, however, typically only Actors or Notes should be reportable.
|
|
||||||
|
|
||||||
### Reason
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :----- | :------- |
|
|
||||||
| reason | String | Yes |
|
|
||||||
|
|
||||||
The reason for the report. This should be a concise summary of the report, such as `"spam"`, `"hate speech"`, `"tos violation"`, etc.
|
|
||||||
|
|
||||||
### Comment
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------ | :----- | :------- |
|
|
||||||
| comment | String | No |
|
|
||||||
|
|
||||||
Additional comments about the report. This is meant to provide a more detailed description of the report, such as `"This user has been spamming my inbox with advertisements."`.
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Report extends Extension {
|
|
||||||
extension_type: "org.lysand:reports/Report";
|
|
||||||
objects: string[];
|
|
||||||
reason: string;
|
|
||||||
comment?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
# Server Endorsement
|
|
||||||
|
|
||||||
The Server Endorsement extension provides a mechanism for servers to vouch for the credibility of other servers. This is a valuable alternative to unrestricted federation, enabling servers to endorse those they deem trustworthy. Federation will only occur with endorsed servers and their subsequent endorsements, up to an admin-defined depth.
|
|
||||||
|
|
||||||
## Endorsement Entity
|
|
||||||
|
|
||||||
The endorsement entity encapsulates the details of an endorsement. It adheres to the following structure:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Extension",
|
|
||||||
"extension_type": "org.lysand:server_endorsement/Endorsement",
|
|
||||||
"author": "https://example.com/actor", // The endorsing server's actor
|
|
||||||
"id": "ed480922-b095-4f09-9da5-c995be8f5960",
|
|
||||||
"uri": "https://example.com/actions/ed480922-b095-4f09-9da5-c995be8f5960",
|
|
||||||
"server": "https://example.com",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Endorsement entities **MUST** be dispatched to the endorsing server actor's inbox whenever the server administrators create a new endorsement.
|
|
||||||
|
|
||||||
### Server
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :----- | :------- |
|
|
||||||
| server | String | Yes |
|
|
||||||
|
|
||||||
URI of the endorsed server. This URI **MUST** correspond to the server's root endpoint, such as `https://example.com`.
|
|
||||||
|
|
||||||
This URI **MUST NOT** be an IP address, except for development purposes.
|
|
||||||
|
|
||||||
### Author
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :----- | :------- |
|
|
||||||
| author | String | Yes |
|
|
||||||
|
|
||||||
The `author` field **MUST** represent the endorsing server actor. This requirement ensures that endorsements can be cryptographically signed using the server actor's `public_key`.
|
|
||||||
|
|
||||||
## Endorsement Collection
|
|
||||||
|
|
||||||
The URI for the endorsement collection can be specified within the server's metadata, under `extensions`:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:server_endorsement": {
|
|
||||||
"endorsements": "https://example.com/endorsements"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This should return a [Collection](../structures/collection) of `Endorsement` entities.
|
|
||||||
|
|
||||||
## Endorsement Protocol Behavior
|
|
||||||
|
|
||||||
Upon receiving an endorsement, a server **MUST** validate the endorsement's signature. If the signature is invalid, the server **MUST** disregard the endorsement. This also applies when fetching the endorsement collection.
|
|
||||||
|
|
||||||
The server has the discretion to decide how to handle the endorsement. Endorsements are intended to serve as a dynamic whitelist for servers, with administrators initially selecting a few trusted servers and progressively adding more.
|
|
||||||
|
|
||||||
Servers **SHOULD** display received endorsements to their administrators, allowing them to accept or reject the endorsement. Ultimately, the decision on how to handle the endorsement lies with the administrators.
|
|
||||||
|
|
||||||
Endorsements should be viewed as a seal of trust: If a server endorses another, it is expressing its trust in that server. Servers should refrain from endorsing servers they do not trust.
|
|
||||||
|
|
||||||
Lastly, servers **MAY** verify the endorsements of the servers they have endorsed, up to a user-defined depth. This creates a trust chain, where servers endorse those they trust, and those servers, in turn, endorse servers they trust, and so forth.
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Endorsement extends Extension {
|
|
||||||
extension_type: "org.lysand:server_endorsement/Endorsement";
|
|
||||||
author: string;
|
|
||||||
server: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
# Vanity
|
|
||||||
|
|
||||||
The Vanity extension allows users to customize their profile in a more in-depth manner.
|
|
||||||
|
|
||||||
Here is an example object:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
// ...
|
|
||||||
"type": "User",
|
|
||||||
// ...
|
|
||||||
"extensions": {
|
|
||||||
"org.lysand:vanity": {
|
|
||||||
"avatar_overlay": {
|
|
||||||
"image/png": {
|
|
||||||
"content": "https://cdn.example.com/ab5081cf-b11f-408f-92c2-7c246f290593/cat_ears.png",
|
|
||||||
"content_type": "image/png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"avatar_mask": {
|
|
||||||
"image/png": {
|
|
||||||
"content": "https://cdn.example.com/d8c42be1-d0f7-43ef-b4ab-5f614e1beba4/rounded_square.jpeg",
|
|
||||||
"content_type": "image/jpeg"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"image/png": {
|
|
||||||
"content": "https://cdn.example.com/6492ddcd-311e-4921-9567-41b497762b09/untitled-file-0019822.png",
|
|
||||||
"content_type": "image/png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"audio": {
|
|
||||||
"audio/mpeg": {
|
|
||||||
"content": "https://cdn.example.com/4da2f0d4-4728-4819-83e4-d614e4c5bebc/michael-jackson-thriller.mp3",
|
|
||||||
"content_type": "audio/mpeg"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pronouns": {
|
|
||||||
"en-us": [
|
|
||||||
"he/him",
|
|
||||||
{
|
|
||||||
"subject": "they",
|
|
||||||
"object": "them",
|
|
||||||
"dependent_possessive": "their",
|
|
||||||
"independent_possessive": "theirs",
|
|
||||||
"reflexive": "themself"
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"birthday": "1998-04-12",
|
|
||||||
"location": "+40.6894-074.0447/",
|
|
||||||
"activitypub": [
|
|
||||||
"@erikuden@mastodon.de"
|
|
||||||
],
|
|
||||||
"aliases": [
|
|
||||||
"https://burger.social/accounts/349ee237-c672-41c1-aadc-677e185f795a",
|
|
||||||
"https://social.lysand.org/users/f565ef02-035d-4974-ba5e-f62a8558331d"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The extension name is `org.lysand:vanity`. All properties are optional.
|
|
||||||
|
|
||||||
## Fields
|
|
||||||
|
|
||||||
### Avatar Overlay
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------------- | :------------ | :------- |
|
|
||||||
| avatar_overlay | ContentFormat | No |
|
|
||||||
|
|
||||||
An overlay to be placed on top of the user's avatar. This can be used to add accessories, such as hats or glasses. Overlay should always be a transparent image.
|
|
||||||
|
|
||||||
### Avatar Mask
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :---------- | :------------ | :------- |
|
|
||||||
| avatar_mask | ContentFormat | No |
|
|
||||||
|
|
||||||
A mask to be applied to the user's avatar. This can be used to change the shape of the avatar, such as making it a circle or a rounded square. Mask should be a fully black (#000000) image with the shape of the mask being transparent. As such, a rounded square mask should have a fully black square with rounded corners.
|
|
||||||
|
|
||||||
### Background
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :--------- | :------------ | :------- |
|
|
||||||
| background | ContentFormat | No |
|
|
||||||
|
|
||||||
A background image to be displayed on the user's profile. This should be a full-width high-resolution image, preferably at least 1920x1080 pixels.
|
|
||||||
|
|
||||||
Space-efficient formats such as WebP are recommended.
|
|
||||||
|
|
||||||
### Audio
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :---- | :------------ | :------- |
|
|
||||||
| audio | ContentFormat | No |
|
|
||||||
|
|
||||||
An audio file to be played on the user's profile. This can be used to add a theme song or a voice introduction.
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Audio files should be muted by default and should not autoplay. Users should have the option to play the audio file, or disable them entirely.
|
|
||||||
>
|
|
||||||
> Furthermore, audio file support in this extension should be toggleable per server, as it can be a potential vector for abuse.
|
|
||||||
|
|
||||||
### Pronouns
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------- | :----------------------------------- | :------- |
|
|
||||||
| pronouns | Array of ShortPronoun or LongPronoun | No |
|
|
||||||
|
|
||||||
An array of pronouns the user uses. Pronouns can be represented as a string or an object.
|
|
||||||
|
|
||||||
See [Types](#types) for a full description of the `ShortPronoun` and `LongPronoun` types.
|
|
||||||
|
|
||||||
### Birthday
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------- | :----- | :------- |
|
|
||||||
| birthday | String | No |
|
|
||||||
|
|
||||||
The user's birthday. This should be in the format `YYYY-MM-DD` (ISO 8601). If the year is set to `0000`, clients should not display the year.
|
|
||||||
|
|
||||||
Clients might choose to display the user's age as well when the year is present.
|
|
||||||
|
|
||||||
### Location
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------- | :----- | :------- |
|
|
||||||
| location | String | No |
|
|
||||||
|
|
||||||
The user's location. This should be a string of GPS data as defined in [ISO 6709 Annex H](https://en.wikipedia.org/wiki/ISO_6709#String_expression_(Annex_H)), or alternatively a raw string such as "New York, NY". GPS data does not need to be precise, and can be as simple as `+46+002/` (France) or `+48.52+002.20/` (Paris, France).
|
|
||||||
|
|
||||||
Clients might choose to display a map of the user's location.
|
|
||||||
|
|
||||||
### ActivityPub
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :--------- | :----- | :------- |
|
|
||||||
| activitypub | Array of String | No |
|
|
||||||
|
|
||||||
The user's ActivityPub profile. This should be an array of strings in the format `@username@domain`.
|
|
||||||
|
|
||||||
Servers are expected to use standard WebFinger resolution to fetch the user's ActivityPub profile if needed.
|
|
||||||
|
|
||||||
### Aliases
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------ | :----- | :------- |
|
|
||||||
| aliases | Array of String | No |
|
|
||||||
|
|
||||||
Aliases to the user's profile on other Lysand-compatible servers. This should be an array of URIs resolving to the user's Lysand object.
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface VanityExtension {
|
|
||||||
avatar_overlay?: ContentFormat;
|
|
||||||
avatar_mask?: ContentFormat;
|
|
||||||
background?: ContentFormat;
|
|
||||||
audio?: ContentFormat;
|
|
||||||
pronouns?: {
|
|
||||||
[language: string]: (ShortPronoun | LongPronoun)[];
|
|
||||||
};
|
|
||||||
birthday?: string;
|
|
||||||
location?: string;
|
|
||||||
activitypub?: string[];
|
|
||||||
aliases?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type ShortPronoun = string;
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface LongPronoun {
|
|
||||||
subject: string;
|
|
||||||
object: string;
|
|
||||||
dependent_possessive: string;
|
|
||||||
independent_possessive: string;
|
|
||||||
reflexive: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
# Federation
|
|
||||||
|
|
||||||
This section explains how federation operates within Lysand.
|
|
||||||
|
|
||||||
Lysand's federation is built upon the HTTP stack. Servers interact with each other by exchanging HTTP requests.
|
|
||||||
|
|
||||||
These requests are predominantly `POST` requests that carry a JSON object in the body. This JSON object **MUST** conform to the Lysand object schema.
|
|
||||||
|
|
||||||
Servers that receive non-conforming Lysand objects **SHOULD** disregard these objects as invalid, and return a `400 Bad Request` response code (these could include debugging information in the response body).
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Values such as `https://example.com/users/uuid` are example implementations and not a guide on how an implementation should format a URI. These must follow the rules outlined in [the base spec](../spec.md).
|
|
||||||
>
|
|
||||||
## User Actor Endpoints
|
|
||||||
|
|
||||||
When a server aims to retrieve the profile of a user on a differentserver, it follows the process outlined in [User Discovery](user-discovery).
|
|
||||||
|
|
||||||
Upon discovering the target server's endpoints, the requesting server can issue a `GET` request to the user's endpoint to retrieve the user's actor.
|
|
||||||
|
|
||||||
For instance, to fetch user information, the requesting server **MUST** send a `GET` request to the endpoint `https://example.com/users/uuid` with the required headers. The server can use either the [Server Actor](/federation/server-actor) or a requesting user's actor to sign the request, as appropriate depending on which actor the request is associated with.
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [Actor](../objects/actors) object.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> While servers are not obligated to implement functionality between certain endpoints such as dislikes or featured notes, they **MUST** at least return an empty collection for these endpoints. Servers may also disregard any objects they do not handle, but should return a success response code.
|
|
||||||
|
|
||||||
## User Inbox
|
|
||||||
|
|
||||||
After the requesting server has discovered the target server's endpoints, it can issue a `POST` request to the `inbox` endpoint to transmit an object to the user. This process mirrors how objects are sent in ActivityPub.
|
|
||||||
|
|
||||||
Typically, the inbox can be found at the same URL as the user's actor, but this is not a requirement. The server **MUST** specify the inbox URL in the actor object.
|
|
||||||
|
|
||||||
Example inbox URL: `https://example.com/users/uuid/inbox`
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `POST` request to the endpoint `https://example.com/users/uuid/inbox` with the headers `Content-Type: application/json` and `Accept: application/json`.
|
|
||||||
|
|
||||||
The body of the request **MUST** contain a valid Lysand object.
|
|
||||||
|
|
||||||
Example with cURL (without signature):
|
|
||||||
```bash
|
|
||||||
curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -d '{ \
|
|
||||||
"type":"Publication", \
|
|
||||||
"id":"6f27bc77-58ee-4c9b-b804-8cc1c1182fa9", \
|
|
||||||
"uri":"https://example.com/publications/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9", \
|
|
||||||
"author":"https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe", \
|
|
||||||
"created_at":"2021-01-01T00:00:00.000Z", \
|
|
||||||
"contents":"Hello, world!" \
|
|
||||||
}' https://example.com/users/uuid/inbox
|
|
||||||
```
|
|
||||||
|
|
||||||
The server **MUST** respond with a `201 CREATED` response code if the operation was successful.
|
|
||||||
|
|
||||||
## User Outbox
|
|
||||||
|
|
||||||
In Lysand, users have an outbox, which is a list of objects that the user has posted. This is akin to the outbox in ActivityPub.
|
|
||||||
|
|
||||||
The server **MUST** specify the outbox URL in the actor object.
|
|
||||||
|
|
||||||
Example outbox URL: `https://example.com/users/uuid/outbox`
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `GET` request to the outbox endpoint (`https://example.com/users/uuid/outbox`) with the headers `Accept: application/json`.
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [Collection](../structures/collection) object containing [Publications](../objects/publications).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"first": "https://example.com/users/uuid/outbox?page=1",
|
|
||||||
"last": "https://example.com/users/uuid/outbox?page=1",
|
|
||||||
// No next or prev attribute in this case, but they can exist
|
|
||||||
"total_items": 1,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "Note",
|
|
||||||
"id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"uri": "https://example.com/publications/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
|
|
||||||
"author": "https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"contents": [
|
|
||||||
{
|
|
||||||
"content": "Hello, world!",
|
|
||||||
"content_type": "text/plain"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
These publications **MUST BE** ordered from newest to oldest, in descending order.
|
|
||||||
|
|
||||||
## User Followers
|
|
||||||
|
|
||||||
Users in Lysand have a list of followers, which is a list of users that follow the user. This is similar to the followers list in ActivityPub.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you prefer not to display this list publicly, you can configure the followers endpoint to return an empty collection.
|
|
||||||
|
|
||||||
The server **MUST** specify the followers URL in the actor object.
|
|
||||||
|
|
||||||
Example followers URL: `https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/followers`
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `GET` request to the followers endpoint (`https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/followers`) with the headers `Accept: application/json`.
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [Collection](../structures/collection) object containing [Actors](../objects/actors). This collection may be empty.
|
|
||||||
|
|
||||||
## User Following
|
|
||||||
|
|
||||||
Users in Lysand have a followlist, which is a list of users that the user follows. This is similar to the following list in ActivityPub.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you prefer not to display this list publicly, you can configure the following endpoint to return an empty collection.
|
|
||||||
|
|
||||||
The server **MUST** specify the following URL in the actor object.
|
|
||||||
|
|
||||||
Example following URL: `https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/following`
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `GET` request to the following endpoint (`https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/following`) with the headers `Accept: application/json`.
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [Collection](../structures/collection) object containing [Actors](../objects/actors). This collection may be empty.
|
|
||||||
|
|
||||||
## User Featured Publications
|
|
||||||
|
|
||||||
Users in Lysand have a list of featured publications, which is a list of publications that the user has pinned or deemed important. This is similar to the featured publications list in ActivityPub.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you prefer not to display this list publicly, you can configure the featured publications endpoint to return an empty collection.
|
|
||||||
|
|
||||||
The server **MUST** specify the featured publications URL in the actor object.
|
|
||||||
|
|
||||||
Example featured publications URL: `https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/featured`
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `GET` request to the featured publications endpoint (`https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/featured`) with the headers `Accept: application/json`.
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [Collection](../structures/collection) object containing [Publications](../objects/publications). This collection may be empty.
|
|
||||||
|
|
||||||
## User Likes
|
|
||||||
|
|
||||||
Users in Lysand have a list of likes, which is a list of posts that the user has liked. This is similar to the likes list in ActivityPub.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you prefer not to display this list publicly, you can configure the likes endpoint to return an empty collection.
|
|
||||||
|
|
||||||
The server **MUST** specify the likes URL in the actor object.
|
|
||||||
|
|
||||||
Example likes URL: `https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/likes`
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `GET` request to the likes endpoint (`https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/likes`) with the headers `Accept: application/json`.
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [Collection](../structures/collection) object containing [Publications](../objects/publications). This collection may be empty.
|
|
||||||
|
|
||||||
|
|
||||||
## User Dislikes
|
|
||||||
|
|
||||||
Users in Lysand have a list of dislikes, which is a list of posts that the user has disliked. This is similar to the dislikes list in ActivityPub.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If you prefer not to display this list publicly, you can configure the dislikes endpoint to return an empty collection.
|
|
||||||
|
|
||||||
The server **MUST** specify the dislikes URL in the actor object.
|
|
||||||
|
|
||||||
Example dislikes URL: `https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/dislikes`
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `GET` request to the dislikes endpoint (`https://example.com/users/731bae4a-9279-4b11-bd8f-f30af7bec755/dislikes`) with the headers `Accept: application/json`.
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [Collection](../structures/collection) object containing [Publications](../objects/publications). This collection may be empty.
|
|
||||||
|
|
||||||
## Server Discovery
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The terms "the server" and "the requesting server" are used in this section. "The server" refers to the server that is being discovered, and "the requesting server" refers to the server that is attempting to discover the server.
|
|
||||||
|
|
||||||
To retrieve the metadata of a server, the requesting server **MUST** send a `GET` request to the endpoint `https://example.com/.well-known/lysand`.
|
|
||||||
|
|
||||||
The requesting server **MUST** send the following headers with the request:
|
|
||||||
```
|
|
||||||
Accept: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** be a valid [ServerMetadata](../objects/server-metadata) object.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "ServerMetadata",
|
|
||||||
"name": "Example",
|
|
||||||
"uri": "https://example.com",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"supported_extensions": [
|
|
||||||
"org.lysand:reactions",
|
|
||||||
"org.lysand:polls",
|
|
||||||
"org.lysand:custom_emojis",
|
|
||||||
"org.lysand:is_cat"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# Server Actor
|
|
||||||
|
|
||||||
Servers **MUST** have an Actor object that represents the server. This Actor object **MUST** be a valid User object.
|
|
||||||
|
|
||||||
The Actor object can be found by sending a WebFinger request to the server's WebFinger endpoint for `actor@server.com`. For more information about WebFinger, please see [User Discovery](/federation/user-discovery).
|
|
||||||
|
|
||||||
The Actor object **MUST** contain a `public_key` field that contains the public key of the server. This public key **MUST** be used to sign all requests sent by the server when the `author` field of an object is the server actor.
|
|
||||||
|
|
||||||
The server actor **MUST** be used to sign all requests sent by the server when the `author` field of an object is the server actor.
|
|
||||||
|
|
||||||
The server actor **SHOULD** contain empty data fields, such as `display_name` and `bio`. However, if the server actor does contain data fields, they **MUST** be valid, as with any actor.
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
# User Discovery
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The terms "the server" and "the requesting server" are used in this section. "The server" refers to the server that is being discovered, and "the requesting server" refers to the server that is trying to discover the server.
|
|
||||||
|
|
||||||
Servers **MUST** implement the [WebFinger](https://tools.ietf.org/html/rfc7033) protocol to allow other servers to discover their endpoints. This is done by serving a `host-meta` file at the address `/.well-known/host-meta`.
|
|
||||||
|
|
||||||
The document **MUST** contain the following information, as specified by the WebFinger protocol:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
|
||||||
<Link rel="lrdd" type="application/xrd+xml" template="https://example.com/.well-known/webfinger?resource={uri}" />
|
|
||||||
</XRD>
|
|
||||||
```
|
|
||||||
|
|
||||||
The `template` field **MUST** be the URI of the server's WebFinger endpoint, which is usually `https://example.com/.well-known/webfinger?resource={uri}`.
|
|
||||||
|
|
||||||
The `resource` field **MUST** be the URI of the user that the server is trying to discover (in the format `acct:identifier@example.com`)
|
|
||||||
|
|
||||||
Breaking down this URI, we get the following:
|
|
||||||
|
|
||||||
- `acct`: The protocol of the URI. This is always `acct` for Lysand.
|
|
||||||
- `identifier`: Either the UUID or the username of the user that the server is trying to discover.
|
|
||||||
- `example.com`: The domain of the server that the user is on. This is usually the domain of the server. This can also be a subdomain of the server, such as `lysand.example.com`.
|
|
||||||
|
|
||||||
This format is reminiscent of the `acct` format used by ActivityPub, but with either a UUID or a username instead of just an username. Users will typically not use the `id` of an actor to identify it, but instead its `username`: servers **MUST** only use the `id` to identify actors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Once the server's WebFinger endpoint has been discovered, it can receive a `GET` request to the endpoint to discover the endpoints of the user.
|
|
||||||
|
|
||||||
The requesting server **MUST** send a `GET` request to the endpoint `https://example.com/.well-known/webfinger`.
|
|
||||||
|
|
||||||
The requesting server **MUST** send the following headers with the request:
|
|
||||||
|
|
||||||
- `Accept: application/jrd+json`
|
|
||||||
- `Accept: application/json`
|
|
||||||
|
|
||||||
The requestinng server **MUST** send the following query parameters with the request:
|
|
||||||
|
|
||||||
- `resource`: The URI of the user that the server is trying to discover (in the format `acct:identifier@example.com` (replace `identifier` with the user's ID or username)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
The server **MUST** respond with a `200 OK` response code, and a JSON object in the body. This JSON object **MUST** contain the following information, as specified by the WebFinger protocol:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"subject": "acct:identifier@example.com",
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"rel": "self",
|
|
||||||
"type": "application/json",
|
|
||||||
"href": "https://example.com/users/uuid"
|
|
||||||
},
|
|
||||||
// The following links are optional but could be added to server software to display XML feeds and an HTML profile page
|
|
||||||
{
|
|
||||||
"rel": "http://webfinger.net/rel/profile-page",
|
|
||||||
"type": "text/html",
|
|
||||||
"href": "https://example.com/users/uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rel": "http://schemas.google.com/g/2010#updates-from",
|
|
||||||
"type": "application/atom+xml",
|
|
||||||
"href": "https://example.com/users/uuid"
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The `subject` field **MUST** be the same as the `resource` field in the request.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The server implementation is free to add any additional links to the `links` array, such as for compatibility with other federation protocols. However, the links specified above **MUST** be included.
|
|
||||||
>
|
|
||||||
> The `href` values of these links can be anything as long as it includes the `uuid` of the user, such as `https://example.com/accounts/uuid` or `https://example.com/uuid.`.
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
# Groups
|
|
||||||
|
|
||||||
Groups are a way to organize the visibility of objects on the server. Groups can be thought of as something similar to a Matrix room or a Discord channel, while also being similar to a Mastodon list.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Groups replace the old "visibility" system for Notes, which was designed for a microblogging context. Groups are more flexible and can be used for any application.
|
|
||||||
>
|
|
||||||
> Notes can still use visibility in cases where groups are not needed with the `followers` and `public` values where you'd typically put a group URI (for example, in a [Publication](./objects/publications.md)'s `group` field').
|
|
||||||
|
|
||||||
# Group Entity
|
|
||||||
|
|
||||||
The group entity encapsulates the details of a group. It adheres to the following structure:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Group",
|
|
||||||
"id": "ed480922-b095-4f09-9da5-c995be8f5960",
|
|
||||||
"uri": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
|
|
||||||
"name": {
|
|
||||||
"text/html": {
|
|
||||||
"content": "The <strong>Woozy</strong> fan club"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"text/plain": {
|
|
||||||
"content": "A group for fans of the Woozy emoji."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"members": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960/members",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fields
|
|
||||||
|
|
||||||
### Name
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :--- | :------------ | :------- |
|
|
||||||
| name | ContentFormat | No |
|
|
||||||
|
|
||||||
The name of the group. This field is optional. Can contain custom emojis, like most other text fields.
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :---------- | :------------ | :------- |
|
|
||||||
| description | ContentFormat | No |
|
|
||||||
|
|
||||||
A description of the group. This field is optional. Can contain custom emojis, like most other text fields.
|
|
||||||
|
|
||||||
### Members
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :------ | :----- | :------- |
|
|
||||||
| members | String | Yes |
|
|
||||||
|
|
||||||
The URI of the group's members list. This field is required. Resolves to a [Collection](./structures/collection) of [User](./objects/user) objects.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
`Note` objects can be posted to groups by setting the `group` field to the URI of the group. If there is no `group` field, the note is posted to whoever is mentioned in the `to` field.
|
|
||||||
|
|
||||||
Other values for `group` are:
|
|
||||||
- `public` for public notes, which can be seen by anyone.
|
|
||||||
- `followers` for notes that can be seen by the author's followers only.
|
|
||||||
|
|
||||||
If the `group` field is empty, and nobody is mentioned in the `to` field, the note is only visible to the author.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
# https://vitepress.dev/reference/default-theme-home-page
|
|
||||||
layout: home
|
|
||||||
|
|
||||||
hero:
|
|
||||||
name: "Lysand"
|
|
||||||
text: "Federation, simpler"
|
|
||||||
tagline: A simple to implement and complete federation protocol
|
|
||||||
image:
|
|
||||||
src: https://cdn.lysand.org/logo.webp
|
|
||||||
alt: Lysand Logo
|
|
||||||
actions:
|
|
||||||
- theme: brand
|
|
||||||
text: Protocol Docs
|
|
||||||
link: /spec
|
|
||||||
- theme: alt
|
|
||||||
text: Lysand Server
|
|
||||||
link: https://github.com/lysand-org/lysand
|
|
||||||
---
|
|
||||||
|
|
||||||
<Features />
|
|
||||||
|
|
||||||
<Team />
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Features from "../components/Features.vue"
|
|
||||||
import Team from "../components/Team.vue"
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Data Entities
|
|
||||||
|
|
||||||
Lysand employs JSON (JavaScript Object Notation) entities for its data structure. This format is designed to be straightforward, facilitating easy implementation and comprehension.
|
|
||||||
|
|
||||||
All JSON entities such as [Publications](objects/publications), [Actors](objects/actors), and [Actions](objects/actions) **MUST** include the following attributes:
|
|
||||||
|
|
||||||
## Identifier (ID)
|
|
||||||
|
|
||||||
The `id` attribute of an Entity is a string that serves as the unique identifier of the entity. It is utilized to distinguish the entity, and **MUST** be unique among all entities on the same server.
|
|
||||||
|
|
||||||
While the `id` attribute is not mandated to be unique across the entire network, it is advisable to do so. Servers **MUST** employ UUIDs or a UUID-compatible system for the `id` attribute.
|
|
||||||
|
|
||||||
## Creation Timestamp
|
|
||||||
|
|
||||||
The `created_at` attribute of an entity is a string that signifies the date and time when the entity was created. It is used to sequence the entities. The data **MUST** adhere to the ISO 8601 format.
|
|
||||||
|
|
||||||
Example: `2021-01-01T00:00:00.000Z`
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The `created_at` attribute should reflect the actual date and time of the post, but it is not mandatory. Any ISO 8601 date is permissible in the `created_at` field. Servers have the discretion to process dates they deem invalid, such as future dates.
|
|
||||||
|
|
||||||
## Uniform Resource Identifier (URI)
|
|
||||||
|
|
||||||
The `uri` attribute of an entity is a string that signifies the URI of the entity. It is used to identify the entity, and **MUST** be unique among all entities. This URI **MUST** be unique across the entire network, and include the `id` of the entity in the URI.
|
|
||||||
|
|
||||||
URIs must adhere to the rules defined [here](spec).
|
|
||||||
|
|
||||||
## Entity Type
|
|
||||||
|
|
||||||
The `type` attribute of an entity is a string that signifies the type of the entity. It is used to determine how the entity should be presented to the user.
|
|
||||||
|
|
||||||
The `type` attribute **MUST** a type officially defined in the Lysand protocol. Extension types are **NOT** permitted and should instead use the [Extension System](extensions.md).
|
|
||||||
|
|
||||||
# Types
|
|
||||||
|
|
||||||
This document uses TypeScript to define the types of the entities in a clear and universal manner. TypeScript is a superset of JavaScript that adds static type definitions to the language. The types are defined in the following format:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Entity {
|
|
||||||
id: string;
|
|
||||||
created_at: string;
|
|
||||||
uri: string;
|
|
||||||
type: string;
|
|
||||||
extensions?: {
|
|
||||||
"org.lysand:custom_emojis"?: {
|
|
||||||
emojis: Emoji[];
|
|
||||||
};
|
|
||||||
[key: string]: object | undefined;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `Entity` type is the base type for all entities in the Lysand protocol. It includes the `id`, `created_at`, `uri`, and `type` attributes.
|
|
||||||
|
|
||||||
Other entities described in other parts of this documentation will extend the `Entity` type to include additional attributes, such as:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ImaginaryNote extends Entity {
|
|
||||||
content: string;
|
|
||||||
mentions: string[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# User Interactions
|
|
||||||
|
|
||||||
User interactions in the Lysand protocol are primarily facilitated through Actions. These are JSON objects that encapsulate a user's intended operation, such as favouriting an object or initiating a follow request to another user.
|
|
||||||
|
|
||||||
Actions are a broad category encompassing various types of objects, rather than being a specific object type.
|
|
||||||
|
|
||||||
Here's an example of an Action:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
"type": "Like",
|
|
||||||
"id": "3e7e4750-afd4-4d99-a256-02f0710a0520",
|
|
||||||
"uri": "https://example.com/actions/3e7e4750-afd4-4d99-a256-02f0710a0520",
|
|
||||||
"author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
|
|
||||||
"created_at": "2021-01-01T00:00:00.000Z",
|
|
||||||
"object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Action Types
|
|
||||||
|
|
||||||
The currently supported action types include:
|
|
||||||
- [`Like`](./like)
|
|
||||||
- [`Dislike`](./dislike)
|
|
||||||
- [`Follow`](./follow)
|
|
||||||
- [`FollowAccept`](./follow-accept)
|
|
||||||
- [`FollowReject`](./follow-reject)
|
|
||||||
- [`Announce`](./announce)
|
|
||||||
- [`Undo`](./undo)
|
|
||||||
|
|
||||||
Notably, the Lysand protocol does not include a `Block` action. This is because Lysand does not inherently support the concept of blocking users. Instead, the decision to display or hide content from a particular user should be done server-side.
|
|
||||||
|
|
||||||
This approach helps prevent potential misuse of the protocol to determine if a user has been blocked by another, thereby addressing a significant privacy concern.
|
|
||||||
|
|
||||||
## Fields
|
|
||||||
|
|
||||||
### Author
|
|
||||||
|
|
||||||
| Name | Type | Required |
|
|
||||||
| :----- | :----- | :------- |
|
|
||||||
| author | String | Yes |
|
|
||||||
|
|
||||||
URI of the [Actor](./actors) who initiated the action.
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Action extends Entity {
|
|
||||||
type: "Like" | "Dislike" | "Follow" | "FollowAccept" | "FollowReject" | "Announce" | "Undo";
|
|
||||||
author: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue