From a3783222c8eb80952e2120a7c2ae7597aa30d179 Mon Sep 17 00:00:00 2001 From: pigui Date: Thu, 8 Oct 2026 19:13:58 +0200 Subject: [PATCH] Show a project's roadmap, releases and first issues from TaskStream When a project has a board in TaskStream, its page lists the epics with their dates and progress, the releases with their notes, the sprint under way and the tickets meant for newcomers, linking to the board. TaskStream being slow or down only leaves those sections out. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + landing/openapi.yaml | 4362 ++++++++++++++++++++++- landing/src/app/core/api.types.ts | 4 + landing/src/app/core/copy.ts | 40 + landing/src/app/core/routed-page.ts | 21 +- landing/src/app/core/taskstream.spec.ts | 47 + landing/src/app/core/taskstream.ts | 85 + landing/src/app/ui/project-detail.scss | 47 + landing/src/app/ui/project-detail.ts | 106 + landing/src/app/ui/routed-page.ts | 1 + 10 files changed, 4708 insertions(+), 10 deletions(-) create mode 100644 landing/src/app/core/taskstream.spec.ts create mode 100644 landing/src/app/core/taskstream.ts diff --git a/README.md b/README.md index 4098626..c463309 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,11 @@ pass on the server: | Charlas | `/talks?limit=6` | | Contact form | `POST /contact` | +A project page also asks TaskStream, when the project has a board there +(`taskstream.showcase_url` in its payload): roadmap, releases, the sprint under +way and tickets labelled `good-first-issue`. It is public, answers within four +seconds or the page goes without those sections, and its links open the board. + `landing/openapi.yaml` is the specification the app was written against, and `landing/src/app/core/api.types.ts` is the API's own generated type file. Both are copies — refresh them from the API when the contract moves: diff --git a/landing/openapi.yaml b/landing/openapi.yaml index 0bbb129..375da4e 100644 --- a/landing/openapi.yaml +++ b/landing/openapi.yaml @@ -16,9 +16,8 @@ info: ## Authentication Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued - by the **Pireon identity server** (`https://oauth.pireon.dev`). This - application does not issue tokens of its own; it validates the ones the - identity server issued. + by the **Pireon identity server** (`https://oauth.pireon.dev`), which this + application validates — or a **machine token** of its own. Obtain a token through `POST /v1/auth/token`, which proxies the identity server's password grant. The panel's own sign-in form uses the same grant, @@ -26,6 +25,13 @@ info: against `https://oauth.pireon.dev/oauth/authorize` remains available for clients that prefer never to handle a password. + Machine tokens (`pmt_…`) are for scripts and integrations: a person makes + one with `POST /v1/tokens`, naming the permissions it gets — never more + than their own — and when it expires. It acts as that person within + those permissions, checked on every request, so taking a role away from + them narrows their tokens at once. It is checked here and never sent to + the identity server. + ## Authorisation The identity server establishes *who* the caller is. What they may *do* is @@ -106,8 +112,46 @@ tags: description: Translatable page content - name: Categories description: Translatable page categories + - name: Projects + description: The collective's work — translatable, with its team and stack + - name: Members + description: The people in the collective + - name: Services + description: What the collective offers + - name: Clients + description: The organisations the work was for + - name: Testimonials + description: What the people the collective worked with said + - name: Talks + description: Talks given elsewhere, by members of the collective + - name: Calls + description: Open calls for contributors + - name: Applications + description: The inbox — what people sent from the public site + - name: Technologies + description: What the collective uses, and its place on the tech radar + - name: Redirects + description: Old addresses, and the missing ones the frontend reported + - name: Repositories + description: The collective's git repositories and their synced numbers + - name: Settings + description: Runtime settings and the public site's maintenance mode + - name: History + description: Who changed what and when, and undoing a change + - name: Reviews + description: Review, publication and preview links + - name: Health + description: What is wrong with the site, and what is left to translate + - name: Batch + description: Several operations in one, and importing records from JSON or CSV + - name: Webhooks + description: Telling other systems what happened here + - name: Audit + description: Who did what, when, and how they came in - name: Users description: Local user administration + - name: Media + description: The media library, and images uploaded for a record to point at - name: Roles description: Local authorisation model (read-only) @@ -235,6 +279,81 @@ paths: description: Null applies in every language. "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/search: + get: + tags: [Public] + summary: Search the site + description: | + What the site shows that matches some words, in the request's + language, best match first: published pages, projects and services, + open calls, public members, and technologies in use. + + Full-text in production — words are stemmed in the language searched, + so "proyectos" finds "proyecto", and a title match ranks above one in + the body. `path` is where the result lives on the site; null for a + technology, which has no address on this side. + security: [] + parameters: + - $ref: "#/components/parameters/Lang" + - name: q + in: query + required: true + schema: { type: string, minLength: 2, maxLength: 120 } + - name: type[] + in: query + description: "Limit to some kinds: `page`, `project`, `service`, `call`, `member`, `technology`." + schema: + type: array + items: { type: string, enum: [page, project, service, call, member, technology] } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 50, default: 20 } + responses: + "200": + description: Results + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PublicSearchResult" } + meta: + type: object + properties: + query: { type: string } + locale: { type: string } + count: { type: integer } + "422": { $ref: "#/components/responses/ValidationError" } + "503": { $ref: "#/components/responses/Maintenance" } + + /public/v1/tags: + get: + tags: [Public] + summary: Blog tags in use + description: Tags on something published, with how many posts each has in the request's language. Most used first. + security: [] + parameters: + - $ref: "#/components/parameters/Lang" + responses: + "200": + description: The tags + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + name: { type: string } + slug: { type: string } + count: { type: integer } + "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/projects: get: tags: [Public] @@ -778,6 +897,10 @@ paths: - name: category in: query schema: { type: string } + - name: tag + in: query + description: A tag's slug in the active language. + schema: { type: string } - name: featured in: query schema: { type: boolean } @@ -909,8 +1032,9 @@ paths: description: | Proxies the identity server's `password` and `refresh_token` grants so first-party clients need not embed the OAuth server's URL or secret. - Rate limited to 6 requests per minute. Browser clients should use - Authorization Code + PKCE instead. + Rate limited to 6 requests per minute per account and address, and 30 + per address overall. Browser clients should use Authorization Code + + PKCE instead. security: [] requestBody: required: true @@ -945,6 +1069,71 @@ paths: data: { $ref: "#/components/schemas/User" } "401": { $ref: "#/components/responses/Unauthorized" } + /v1/tokens: + get: + tags: [Auth] + summary: List my machine tokens + description: Newest first, revoked and expired ones included. A super admin may ask for everyone's with `all`. Not available to a machine token. + parameters: + - $ref: "#/components/parameters/PerPage" + - name: all + in: query + description: "Everyone's tokens. Super admins only." + schema: { type: boolean } + responses: + "200": + description: Paginated tokens + content: + application/json: + schema: { $ref: "#/components/schemas/ApiTokenCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Auth] + summary: Make a machine token + description: | + `abilities` are permission names — `pages.update` — or a whole + resource — `pages.*` — and only ones the caller holds. It expires + after 90 days unless `expires_at` says otherwise, a year at most. The + token itself is in the answer's `token`, and nowhere else ever again. + Not available to a machine token. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, abilities] + properties: + name: { type: string, maxLength: 120, example: Despliegue continuo } + abilities: { type: array, items: { type: string }, example: ["technologies.*", "media.view_any"] } + expires_at: { type: string, format: date-time } + responses: + "201": + description: Token made + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ApiToken" } + token: { type: string, description: The token. Shown once. } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/tokens/{token}: + delete: + tags: [Auth] + summary: Revoke a machine token + description: It stops working at once and stays listed as revoked. Your own, or anyone's for a super admin. + parameters: + - name: token + in: path + required: true + schema: { type: integer } + responses: + "204": { description: Revoked } + "403": { $ref: "#/components/responses/Forbidden" } + /v1/pages: get: tags: [Pages] @@ -1171,6 +1360,3157 @@ paths: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } + /v1/tags: + get: + tags: [Categories] + summary: List blog tags + description: Every tag, unpaginated, with how many pages carry it. Kept by whoever keeps the categories. + responses: + "200": + description: The tags + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Categories] + summary: Create a blog tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "201": + description: Tag created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/tags/{tag}: + parameters: + - name: tag + in: path + required: true + schema: { type: integer } + patch: + tags: [Categories] + summary: Update a blog tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "200": + description: Tag updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Categories] + summary: Delete a blog tag + description: The pages keep existing; they just stop carrying it. + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/technologies: + get: + tags: [Technologies] + summary: List technologies + description: Ordered by `position`, then name. + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: category + in: query + schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } + - name: radar_ring + in: query + description: '`none` for the technologies kept off the radar.' + schema: { type: string, enum: [adopt, trial, assess, hold, none] } + - name: search + in: query + description: Matches the name. + schema: { type: string } + responses: + "200": + description: Paginated technologies + content: + application/json: + schema: { $ref: "#/components/schemas/TechnologyCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Technologies] + summary: Create a technology + description: | + For a logo, upload it first with `POST /v1/media` and `for: + technologies`, and send the `path` it answers with as `logo`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TechnologyWrite" } + responses: + "201": + description: Technology created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Technology" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/technologies/{technology}: + parameters: + - name: technology + in: path + required: true + schema: { type: integer } + get: + tags: [Technologies] + summary: Show a technology + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The technology + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Technology" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Technologies] + summary: Update a technology + description: | + Partial update. `description` and `radar_note` merge into the existing + translations; send `radar_ring: null` to take it off the radar. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TechnologyWrite" } + responses: + "200": + description: Technology updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Technology" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Technologies] + summary: Delete a technology + description: Refused with 403 while a project, member or call still uses it. + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects: + get: + tags: [Projects] + summary: List projects + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + schema: { type: string, enum: [idea, active, paused, completed, archived] } + - name: search + in: query + description: Matches the title within the active language only. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated projects + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Projects] + summary: Create a project + description: | + Relations are sent as ids and replaced whole when present: + `technology_ids`, `service_ids`, and `members` with each one's role. + `published_at` and `unpublish_at` require `projects.publish`. For a + cover, upload it with `POST /v1/media` and `for: projects`. + + With `taskstream: true` the project also gets a **public** board in + TaskStream, the collective's issue tracker, and the answer carries its + link in `taskstream_url`. Its key is `taskstream_key`, or one made from + the slug in the default language (one word: its first ten characters; + several: their initials). The board is opened first: if TaskStream + refuses it (a key in use, say), nothing is created here either and the + reason comes back as a validation error. It is opened as the caller, + whose Pireon token is passed on; a machine token cannot ask for one + unless the server has a `TASKSTREAM_TOKEN` of its own. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectWrite" } + responses: + "201": + description: Project created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Project" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + "502": + description: TaskStream did not answer; nothing was created. + + /v1/projects/{project}: + parameters: + - name: project + in: path + required: true + schema: { type: integer } + get: + tags: [Projects] + summary: Show a project + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The project + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Project" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Projects] + summary: Update a project + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectWrite" } + responses: + "200": + description: Project updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Project" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Projects] + summary: Soft-delete a project + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/restore: + post: + tags: [Projects] + summary: Restore a soft-deleted project + parameters: + - name: project + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Project restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Project" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/members: + get: + tags: [Members] + summary: List members + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: search + in: query + description: Matches the name. + schema: { type: string } + - name: is_public + in: query + schema: { type: boolean } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated members + content: + application/json: + schema: { $ref: "#/components/schemas/MemberCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Members] + summary: Create a member + description: | + `links` is a label → URL map. `technology_ids` is replaced whole when + present; which projects someone is on is set from the project, where + the role belongs. For a photo, upload it with `POST /v1/media` and + `for: members`. Someone may edit their own profile without + `members.update`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MemberWrite" } + responses: + "201": + description: Member created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Member" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/members/{member}: + parameters: + - name: member + in: path + required: true + schema: { type: integer } + get: + tags: [Members] + summary: Show a member + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The member + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Member" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Members] + summary: Update a member + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MemberWrite" } + responses: + "200": + description: Member updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Member" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Members] + summary: Soft-delete a member + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/members/{member}/restore: + post: + tags: [Members] + summary: Restore a soft-deleted member + parameters: + - name: member + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Member restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Member" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services: + get: + tags: [Services] + summary: List services + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: is_published + in: query + schema: { type: boolean } + - name: search + in: query + description: Matches the title within the active language only. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated services + content: + application/json: + schema: { $ref: "#/components/schemas/ServiceCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Services] + summary: Create a service + description: | + `technology_ids` and `project_ids` are replaced whole when present. + `is_published`, `published_at` and `unpublish_at` require + `services.publish`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ServiceWrite" } + responses: + "201": + description: Service created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Service" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}: + parameters: + - name: service + in: path + required: true + schema: { type: integer } + get: + tags: [Services] + summary: Show a service + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The service + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Service" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Services] + summary: Update a service + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ServiceWrite" } + responses: + "200": + description: Service updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Service" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Services] + summary: Soft-delete a service + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/restore: + post: + tags: [Services] + summary: Restore a soft-deleted service + parameters: + - name: service + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Service restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Service" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/clients: + get: + tags: [Clients] + summary: List clients + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: is_public + in: query + schema: { type: boolean } + - name: search + in: query + description: Matches the name. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated clients + content: + application/json: + schema: { $ref: "#/components/schemas/ClientCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Clients] + summary: Create a client + description: | + Send the `path` from `POST /v1/media` with `for=clients` as `logo`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ClientWrite" } + responses: + "201": + description: Client created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/clients/{client}: + parameters: + - name: client + in: path + required: true + schema: { type: integer } + get: + tags: [Clients] + summary: Show a client + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The client + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Clients] + summary: Update a client + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ClientWrite" } + responses: + "200": + description: Client updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Clients] + summary: Soft-delete a client + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/clients/{client}/restore: + post: + tags: [Clients] + summary: Restore a soft-deleted client + parameters: + - name: client + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Client restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/testimonials: + get: + tags: [Testimonials] + summary: List testimonials + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: is_public + in: query + schema: { type: boolean } + - name: client_id + in: query + schema: { type: integer } + - name: project_id + in: query + schema: { type: integer } + - name: service_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the author's name. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated testimonials + content: + application/json: + schema: { $ref: "#/components/schemas/TestimonialCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Testimonials] + summary: Create a testimonial + description: | + `quote` holds what the person said in `original_locale`, and any + translations of it. Send the `path` from `POST /v1/media` with + `for=testimonials` as `avatar`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TestimonialWrite" } + responses: + "201": + description: Testimonial created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/testimonials/{testimonial}: + parameters: + - name: testimonial + in: path + required: true + schema: { type: integer } + get: + tags: [Testimonials] + summary: Show a testimonial + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The testimonial + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Testimonials] + summary: Update a testimonial + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TestimonialWrite" } + responses: + "200": + description: Testimonial updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Testimonials] + summary: Soft-delete a testimonial + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/testimonials/{testimonial}/restore: + post: + tags: [Testimonials] + summary: Restore a soft-deleted testimonial + parameters: + - name: testimonial + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Testimonial restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/talks: + get: + tags: [Talks] + summary: List talks + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: is_public + in: query + schema: { type: boolean } + - name: member_id + in: query + schema: { type: integer } + - name: project_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the title or the event. + schema: { type: string } + responses: + "200": + description: Paginated talks + content: + application/json: + schema: { $ref: "#/components/schemas/TalkCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Talks] + summary: Create a talk + description: | + `title` is the one the talk was given under, in `language`. Only + `summary` is translated. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TalkWrite" } + responses: + "201": + description: Talk created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Talk" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/talks/{talk}: + parameters: + - name: talk + in: path + required: true + schema: { type: integer } + get: + tags: [Talks] + summary: Show a talk + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The talk + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Talk" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Talks] + summary: Update a talk + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TalkWrite" } + responses: + "200": + description: Talk updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Talk" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Talks] + summary: Delete a talk + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls: + get: + tags: [Calls] + summary: List calls + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`open`, `filled` or `closed`." + schema: { type: string } + - name: project_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the title within the active language only. + schema: { type: string } + responses: + "200": + description: Paginated calls + content: + application/json: + schema: { $ref: "#/components/schemas/CallCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Calls] + summary: Create a call + description: | + `technology_ids` is replaced whole when present. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CallWrite" } + responses: + "201": + description: Call created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Call" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/calls/{call}: + parameters: + - name: call + in: path + required: true + schema: { type: integer } + get: + tags: [Calls] + summary: Show a call + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The call + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Call" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Calls] + summary: Update a call + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CallWrite" } + responses: + "200": + description: Call updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Call" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Calls] + summary: Delete a call + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications: + get: + tags: [Applications] + summary: List applications + parameters: + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`new`, `contacted`, `accepted`, `declined` or `spam`. Without it, spam is left out." + schema: { type: string } + - name: kind + in: query + description: "`contribute` or `request`." + schema: { type: string } + - name: call_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the name, e-mail or organisation. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated applications + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}: + parameters: + - name: application + in: path + required: true + schema: { type: integer } + get: + tags: [Applications] + summary: Show an application + responses: + "200": + description: The application + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Applications] + summary: Handle an application + description: | + Only how it is being handled changes: `status` and the private + `notes`. Moving it to `contacted`, `accepted` or `declined` records who + did it and when; back to `new` clears both. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationWrite" } + responses: + "200": + description: Application updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Applications] + summary: Soft-delete an application + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}/restore: + post: + tags: [Applications] + summary: Restore a soft-deleted application + parameters: + - name: application + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Application restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/redirects: + get: + tags: [Redirects] + summary: List redirects + parameters: + - $ref: "#/components/parameters/PerPage" + - name: is_enabled + in: query + schema: { type: boolean } + - name: locale + in: query + schema: { type: string } + - name: automatic + in: query + description: "True for the ones a slug rename left behind, false for those typed by hand." + schema: { type: boolean } + - name: search + in: query + description: "Matches either end." + schema: { type: string } + responses: + "200": + description: Paginated redirects + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Redirects] + summary: Create a redirect + description: | + Both ends are normalised — leading slash, no trailing one, no query + string. A `locale` of null applies in every language. Creating one marks + the matching missing URLs as resolved. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectWrite" } + responses: + "201": + description: Redirect created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/redirects/{redirect}: + parameters: + - name: redirect + in: path + required: true + schema: { type: integer } + get: + tags: [Redirects] + summary: Show a redirect + responses: + "200": + description: The redirect + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Redirects] + summary: Update a redirect + description: Partial update. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectWrite" } + responses: + "200": + description: Redirect updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Redirects] + summary: Delete a redirect + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/missing-urls: + get: + tags: [Redirects] + summary: List missing urls + parameters: + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`pending` (the default), `resolved` or `ignored`." + schema: { type: string } + - name: locale + in: query + schema: { type: string } + - name: search + in: query + description: "Matches the path." + schema: { type: string } + responses: + "200": + description: Paginated missing URLs + content: + application/json: + schema: { $ref: "#/components/schemas/MissingUrlCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/missing-urls/{missing_url}: + parameters: + - name: missing_url + in: path + required: true + schema: { type: integer } + get: + tags: [Redirects] + summary: Show a missing URL + responses: + "200": + description: The missing URL + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/MissingUrl" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Redirects] + summary: Ignore a missing URL, or stop ignoring it + description: "`status` moves between `pending` and `ignored` only: `resolved` is what giving it a redirect does." + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MissingUrlWrite" } + responses: + "200": + description: MissingUrl updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/MissingUrl" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Redirects] + summary: Delete a missing URL + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/missing-urls/{missing_url}/redirect: + post: + tags: [Redirects] + summary: Give a missing URL a destination + description: | + Creates the redirect from its path, which marks it resolved. `locale` + defaults to the language it was seen in; send null for every language. + parameters: + - name: missing_url + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [to] + properties: + to: { type: string, maxLength: 500 } + status: { type: integer, enum: [301, 302], default: 301 } + locale: { type: string, nullable: true } + responses: + "201": + description: Redirect created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/repositories: + get: + tags: [Repositories] + summary: List repositories + parameters: + - $ref: "#/components/parameters/PerPage" + - name: provider + in: query + description: "`gitea`, `github` or `gitlab`." + schema: { type: string } + - name: project_id + in: query + schema: { type: integer } + - name: show_publicly + in: query + schema: { type: boolean } + - name: failing + in: query + description: "Only those whose sync has failed three times or more." + schema: { type: boolean } + - name: search + in: query + description: "Matches the full name." + schema: { type: string } + responses: + "200": + description: Paginated repositories + content: + application/json: + schema: { $ref: "#/components/schemas/RepositoryCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Repositories] + summary: Create a repository + description: | + Only which repository, whose project and whether to show it. Everything + else is what the provider reports on the next sync. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RepositoryWrite" } + responses: + "201": + description: Repository created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/repositories/{repository}: + parameters: + - name: repository + in: path + required: true + schema: { type: integer } + get: + tags: [Repositories] + summary: Show a repository + responses: + "200": + description: The repository + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Repositories] + summary: Update a repository + description: Partial update. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RepositoryWrite" } + responses: + "200": + description: Repository updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Repositories] + summary: Delete a repository + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/repositories/{repository}/sync: + post: + tags: [Repositories] + summary: Sync a repository + description: | + Queues a sync with the provider and answers straight away. Requires + `repositories.sync`: a sync spends a rate limit the whole application + shares. + parameters: + - name: repository + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Sync queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/settings: + get: + tags: [Settings] + summary: List settings + description: Every stored setting, unpaginated. Requires `settings.view`. + responses: + "200": + description: The settings + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Setting" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/settings/{setting}: + parameters: + - name: setting + in: path + required: true + description: "The setting's key, e.g. `site.maintenance`." + schema: { type: string } + get: + tags: [Settings] + summary: Show a setting + responses: + "200": + description: The setting + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Setting" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Settings] + summary: Update a setting + description: | + Replaces the value whole. Requires `settings.update`, or + `settings.maintenance` for `site.maintenance`. `content.version` is + written by the application and refused here. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [value] + properties: + value: { description: "Any JSON value, null included." } + responses: + "200": + description: Setting updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Setting" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/maintenance: + get: + tags: [Settings] + summary: Show maintenance mode + description: The public site's maintenance mode. Requires `settings.view`. + responses: + "200": + description: Maintenance state + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Maintenance" } + "403": { $ref: "#/components/responses/Forbidden" } + put: + tags: [Settings] + summary: Switch maintenance mode + description: | + Only the public site goes down; the panel and this API keep working. + `message` (language → text) and `until` are kept either way, so the + wording can be written before switching it on. A passed `until` lifts + it by itself. Requires `settings.maintenance`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: { type: boolean } + message: { type: object, nullable: true, additionalProperties: { type: string, maxLength: 500 } } + until: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Maintenance state + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Maintenance" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/revisions: + get: + tags: [History] + summary: List the history of a page + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/categories/{category}/revisions: + get: + tags: [History] + summary: List the history of a category + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: category + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/revisions: + get: + tags: [History] + summary: List the history of a project + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/members/{member}/revisions: + get: + tags: [History] + summary: List the history of a member + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: member + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/revisions: + get: + tags: [History] + summary: List the history of a service + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/clients/{client}/revisions: + get: + tags: [History] + summary: List the history of a client + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: client + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/testimonials/{testimonial}/revisions: + get: + tags: [History] + summary: List the history of a testimonial + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: testimonial + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/talks/{talk}/revisions: + get: + tags: [History] + summary: List the history of a talk + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: talk + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/technologies/{technology}/revisions: + get: + tags: [History] + summary: List the history of a technology + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: technology + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls/{call}/revisions: + get: + tags: [History] + summary: List the history of a call + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: call + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/revisions/{revision}/restore: + post: + tags: [History] + summary: Undo a revision + description: | + Puts back the values one revision changed, as the panel's history + does. Restoring is itself a change and lands in the history. Requires + permission to edit the record, and to publish it when the revision + touched its publication. + parameters: + - name: revision + in: path + required: true + schema: { type: integer } + responses: + "200": + description: The revision the restore recorded + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Revision" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/reviews: + get: + tags: [Reviews] + summary: What is waiting for my review + description: | + Pages, projects and services awaiting review that the caller could + publish — addressed to them, or to nobody in particular. Oldest + request first, unpaginated. + responses: + "200": + description: The queue + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ReviewState" } + + /v1/pages/{page}/reviews: + get: + tags: [Reviews] + summary: Review thread of a page + description: Requests, returns, publications and comments, newest first. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated thread + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewCommentCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/pages/{page}/comments: + post: + tags: [Reviews] + summary: Comment on the review of a page + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 2000 } + responses: + "201": + description: Comment added + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewComment" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/review: + post: + tags: [Reviews] + summary: Ask for a page to be reviewed + description: | + The way to put it live without `pages.publish`: whoever can publish it + is told. `reviewer_id` addresses one of them; without it, anyone may + answer. Refused if it is already waiting or already published. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + reviewer_id: { type: integer, nullable: true } + note: { type: string, nullable: true, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/return: + post: + tags: [Reviews] + summary: Send a page back for changes + description: Tells whoever asked, with the reason. Requires `pages.publish`. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: { type: string, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/publish: + post: + tags: [Reviews] + summary: Publish a page, now or later + description: | + Approves it: live now, or from `at` when that is in the future. Any + pending review is answered either way. Requires `pages.publish`. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/unpublish: + post: + tags: [Reviews] + summary: Take a page off the site + description: Requires `pages.publish`. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/reviews: + get: + tags: [Reviews] + summary: Review thread of a project + description: Requests, returns, publications and comments, newest first. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated thread + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewCommentCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/comments: + post: + tags: [Reviews] + summary: Comment on the review of a project + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 2000 } + responses: + "201": + description: Comment added + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewComment" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/review: + post: + tags: [Reviews] + summary: Ask for a project to be reviewed + description: | + The way to put it live without `projects.publish`: whoever can publish it + is told. `reviewer_id` addresses one of them; without it, anyone may + answer. Refused if it is already waiting or already published. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + reviewer_id: { type: integer, nullable: true } + note: { type: string, nullable: true, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/return: + post: + tags: [Reviews] + summary: Send a project back for changes + description: Tells whoever asked, with the reason. Requires `projects.publish`. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: { type: string, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/publish: + post: + tags: [Reviews] + summary: Publish a project, now or later + description: | + Approves it: live now, or from `at` when that is in the future. Any + pending review is answered either way. Requires `projects.publish`. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/unpublish: + post: + tags: [Reviews] + summary: Take a project off the site + description: Requires `projects.publish`. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/reviews: + get: + tags: [Reviews] + summary: Review thread of a service + description: Requests, returns, publications and comments, newest first. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated thread + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewCommentCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/comments: + post: + tags: [Reviews] + summary: Comment on the review of a service + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 2000 } + responses: + "201": + description: Comment added + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewComment" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/review: + post: + tags: [Reviews] + summary: Ask for a service to be reviewed + description: | + The way to put it live without `services.publish`: whoever can publish it + is told. `reviewer_id` addresses one of them; without it, anyone may + answer. Refused if it is already waiting or already published. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + reviewer_id: { type: integer, nullable: true } + note: { type: string, nullable: true, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/return: + post: + tags: [Reviews] + summary: Send a service back for changes + description: Tells whoever asked, with the reason. Requires `services.publish`. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: { type: string, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/publish: + post: + tags: [Reviews] + summary: Publish a service, now or later + description: | + Approves it: live now, or from `at` when that is in the future. Any + pending review is answered either way. Requires `services.publish`. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/unpublish: + post: + tags: [Reviews] + summary: Take a service off the site + description: Requires `services.publish`. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/pages/{page}/preview: + post: + tags: [Reviews] + summary: Preview links for a page + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/preview: + post: + tags: [Reviews] + summary: Preview links for a project + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/preview: + post: + tags: [Reviews] + summary: Preview links for a service + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls/{call}/preview: + post: + tags: [Reviews] + summary: Preview links for a call + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: call + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/health: + get: + tags: [Health] + summary: Site health + description: | + The panel's health screen: every check the caller may see, problems + first — broken images, missing slugs, stale translations, unanswered + submissions… A check behind a permission the caller lacks is left out. + responses: + "200": + description: The findings + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/HealthFinding" } + meta: + type: object + properties: + problems: { type: integer } + checked_at: { type: string, format: date-time } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/translations/pending: + get: + tags: [Health] + summary: What is left to translate + description: | + Pages, projects, services and calls with a language still to write + (`missing`) or left behind by the one it was translated from (`stale`, + with since when). Unpaginated. + parameters: + - name: locale + in: query + description: "Only what this language lacks." + schema: { type: string } + - name: type + in: query + description: "`pages`, `projects`, `services` or `calls`." + schema: { type: string } + - name: published + in: query + description: "Only what is on the site \u2014 open, for calls." + schema: { type: boolean } + responses: + "200": + description: The records + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PendingTranslation" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/batch: + post: + tags: [Batch] + summary: Run several operations + description: | + Up to 50 admin API calls in one request. Each runs as a request of its + own with the caller's credentials — validated, authorised and answered + exactly as if sent separately — so a batch can do nothing its + operations would be refused. Their answers come back in order. + + With `atomic`, it stops at the first failure and undoes everything + before it. What already left the database — a queued job, a + notification — is not undone. + + `path` is an admin path such as `/v1/pages/3`; batches do not nest and + do not reach the import or the token exchange. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [operations] + properties: + atomic: { type: boolean, default: false } + operations: + type: array + maxItems: 50 + items: + type: object + required: [method, path] + properties: + method: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] } + path: { type: string, example: /v1/technologies } + body: { type: object, nullable: true, additionalProperties: true } + responses: + "200": + description: Each operation's answer + content: + application/json: + schema: { $ref: "#/components/schemas/BatchOutcome" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/import/{resource}: + post: + tags: [Batch] + summary: Import records + description: | + Rows of one kind of record, as JSON (`rows`) or a CSV `file`, turned + into the create and update calls the API already has — up to 50 per + import, each validated and authorised like any other call. + + A row with an `id`, or whose `match` field (`slug`, `name` or + `full_name`) names an existing record, updates it; any other row + creates one. In a CSV, a column `title.es` fills the Spanish title, a + list is written `1|2|3`, `true`/`false`/`null` are what they say, and + an empty cell is left out rather than erasing what is there. + + All-or-nothing unless `atomic` is false. `dry_run` checks every row and + keeps none. + parameters: + - name: resource + in: path + required: true + schema: + type: string + enum: [pages, categories, projects, members, services, clients, testimonials, talks, technologies, calls] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [rows] + properties: + rows: + type: array + maxItems: 50 + items: { type: object, additionalProperties: true, description: The same body the resource's create endpoint takes. } + match: { type: string, enum: [slug, name, full_name], nullable: true } + atomic: { type: boolean, default: true } + dry_run: { type: boolean, default: false } + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary, description: "CSV with a header row, UTF-8." } + match: { type: string, enum: [slug, name, full_name] } + atomic: { type: boolean, default: true } + dry_run: { type: boolean, default: false } + responses: + "200": + description: Each row's answer + content: + application/json: + schema: { $ref: "#/components/schemas/BatchOutcome" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/webhooks: + get: + tags: [Webhooks] + summary: List webhooks + parameters: + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated webhooks + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Webhooks] + summary: Create a webhook + description: | + Somewhere to POST to when something happens here. HTTPS to a public + address only — checked now and again at every delivery — and redirects are + not followed. The `secret` signing the deliveries is generated unless sent, + and is in this answer's `secret` once, never again. + + Each delivery is a JSON body `{id, event, created_at, data}` with headers + `X-Webhook-Event`, `X-Webhook-Delivery`, `X-Webhook-Timestamp` and + `X-Webhook-Signature`: `sha256=` and the HMAC-SHA256 of + `{timestamp}.{body}` with the secret. Failed deliveries are retried five + times over about two and a half hours; twenty failures in a row switch the + webhook off. + + Events with a meaning of their own: `application.received` (its + sender's details only with `include_personal_data`), + `content.published`, `content.unpublished`, `review.requested`, + `review.returned`. + + And every change a person makes to a record, as `{resource}.{action}` + — `projects.created`, `pages.updated`, `media.deleted`, + `calls.restored` — for pages, categories, tags, projects, members, + services, clients, testimonials, talks, technologies, calls, + redirects, repositories, media and applications. Its `data` says + what (`type`, `id`, `title`, `url` when it is on the site), who + (`by`), how they came in (`via`: panel, api, token) and, on an + update, `changes` as field → `{old, new}`. What the application does + by itself — a sync, a scheduled publication — is not sent. Changes to + an application carry only how it is handled unless + `include_personal_data`. Users, tokens, webhooks and settings are + never sent. + + Subscribe to an event, to `*`, to all of one kind of record + (`projects.*`) or to one action on any (`*.deleted`). + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWrite" } + responses: + "201": + description: Webhook created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/webhooks/{webhook}: + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + get: + tags: [Webhooks] + summary: Show a webhook + responses: + "200": + description: The webhook + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Webhooks] + summary: Update a webhook + description: "Partial update. `rotate_secret` makes a new secret, in this answer's `secret` once. Switching it back on resets its failure count." + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWrite" } + responses: + "200": + description: Webhook updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Webhooks] + summary: Delete a webhook + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhooks/{webhook}/deliveries: + get: + tags: [Webhooks] + summary: Deliveries of a webhook + description: Newest first. + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`pending`, `succeeded` or `failed`." + schema: { type: string } + responses: + "200": + description: Paginated deliveries + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookDeliveryCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhooks/{webhook}/ping: + post: + tags: [Webhooks] + summary: Send a test delivery + description: A `ping` event, to check the receiving end. + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Ping queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/WebhookDelivery" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhook-deliveries/{delivery}/redeliver: + post: + tags: [Webhooks] + summary: Send a delivery again + description: As a new delivery of the same event and payload. + parameters: + - name: delivery + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Delivery queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/WebhookDelivery" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/audit: + get: + tags: [Audit] + summary: The audit log + description: | + Who did what, when, and how they came in — the panel, the API, a + machine token. Every change a person makes to content, the inbox, + redirects, repositories, media, settings, users, tokens and webhooks; + role changes; signing in to the panel. What the application does by + itself (a sync, a scheduled publication) is not here; each record's + own history has it. Secrets are never written down. Kept for a year + by default. Requires `audit.view_any`. + parameters: + - $ref: "#/components/parameters/PerPage" + - name: user_id + in: query + schema: { type: integer } + - name: action + in: query + description: "Exact, e.g. `pages.updated`, or a prefix ending in a dot, e.g. `pages.`." + schema: { type: string } + - name: subject_type + in: query + description: "e.g. `pages`, `users`, `settings`." + schema: { type: string } + - name: subject_id + in: query + schema: { type: string } + - name: via + in: query + description: "`panel`, `api`, `token`, `web` or `console`." + schema: { type: string } + - name: from + in: query + schema: { type: string, format: date-time } + - name: to + in: query + schema: { type: string, format: date-time } + responses: + "200": + description: Paginated entries, newest first + content: + application/json: + schema: { $ref: "#/components/schemas/AuditLogCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/pages/{page}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a page as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a project as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a service as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls/{call}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a call as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: call + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}/replies: + get: + tags: [Applications] + summary: Replies given to an application + description: Newest first. + parameters: + - name: application + in: path + required: true + schema: { type: integer } + responses: + "200": + description: The replies + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ApplicationReply" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}/reply: + post: + tags: [Applications] + summary: Reply to an application + description: | + `subject` and `body`, or a `template_id` to fill whichever is left out + — in the language the person wrote in. Sent by this application when + it has a mailer that delivers; otherwise recorded, and the answer + carries a `mailto` link to send it by hand. Moves a new application to + `contacted`. + parameters: + - name: application + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + template_id: { type: integer, nullable: true } + subject: { type: string, maxLength: 255 } + body: { type: string } + responses: + "201": + description: Reply recorded + content: + application/json: + schema: + type: object + properties: + data: + allOf: + - $ref: "#/components/schemas/ApplicationReply" + - type: object + properties: + mailto: { type: string, nullable: true } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/reply-templates: + get: + tags: [Applications] + summary: Reply templates + description: Every template, in every language. Managed in the panel. + responses: + "200": + description: The templates + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ReplyTemplate" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/media: + get: + tags: [Media] + summary: List the media library + description: Every image on the media disk, newest first. Requires `media.view_any`. + parameters: + - $ref: "#/components/parameters/PerPage" + - name: for + in: query + description: The kind of record it was uploaded for. + schema: { $ref: "#/components/schemas/MediaFor" } + - name: mime + in: query + description: "e.g. `image/svg+xml`." + schema: { type: string } + - name: search + in: query + description: "Matches the path." + schema: { type: string } + - name: pending + in: query + description: "Only those the queue has not processed yet." + schema: { type: boolean } + - name: failed + in: query + description: "Only those whose processing failed." + schema: { type: boolean } + - name: orphans + in: query + description: "Only those nothing points at \u2014 what `content:prune --media` would delete." + schema: { type: boolean } + responses: + "200": + description: Paginated media + content: + application/json: + schema: { $ref: "#/components/schemas/MediaCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Media] + summary: Upload an image + description: | + Stores an image and answers with the `path` a record points at — send + it as a page's `featured_image`. Two ways in, exactly one per request: + + - **`multipart/form-data`** with the image as `file`. + - **`application/json`** with a `url`, which the API downloads itself. + HTTPS only, from a public address — anything resolving to a private, + reserved or loopback address is refused — and redirects are not + followed: send the image's final address. + + JPEG, PNG, WebP, GIF, AVIF or SVG, decided from the file's bytes rather + than its name or declared type. An SVG is stored sanitized: scripts, + event handlers and references to anything outside the file are + removed. 20 MB at most. + + `for` says which kind of record the image is for — `pages` (the + default), `technologies`, `projects`, `members`, `clients` or + `testimonials` — which decides where it is stored and whose permission + it takes. + + The smaller versions and the placeholder are derived afterwards, on the + queue: `processed` says whether that has happened, and the image serves + either way. Requires permission to create or edit the records named in + `for`. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: The image. + for: { $ref: "#/components/schemas/MediaFor" } + application/json: + schema: + type: object + required: [url] + properties: + url: + type: string + format: uri + description: An `https://` address the API downloads the image from. + example: https://images.example.org/hero.jpg + for: { $ref: "#/components/schemas/MediaFor" } + responses: + "201": + description: Image stored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/media/{media}: + parameters: + - name: media + in: path + required: true + schema: { type: integer } + get: + tags: [Media] + summary: Show an image + responses: + "200": + description: The image, with whether anything uses it + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + delete: + tags: [Media] + summary: Delete an image + description: | + The file, its derived versions and its record. Only when nothing points + at it — a page, a logo, an image inside a text — since deleting one in + use would put a broken image on the public site. Requires + `media.delete`. + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { description: The image is in use. } + + /v1/media/{media}/reprocess: + post: + tags: [Media] + summary: Process an image again + description: Queues deriving the smaller versions and the placeholder again. + parameters: + - name: media + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Processing queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "403": { $ref: "#/components/responses/Forbidden" } + /v1/users: get: tags: [Users] @@ -1320,9 +4660,9 @@ components: type: http scheme: bearer description: | - An access token issued by https://oauth.pireon.dev. Validated on each - request against the identity server's `GET /api/user`, with the result - cached briefly. + An access token issued by https://oauth.pireon.dev, validated on each + request against the identity server's `GET /api/user` with the result + cached briefly — or a machine token (`pmt_…`) from `POST /v1/tokens`. ingestToken: type: apiKey @@ -1701,6 +5041,15 @@ components: repositories: type: array items: { $ref: "#/components/schemas/PublicRepository" } + taskstream: + type: object + nullable: true + description: "Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue." + properties: + key: { type: string, example: WEB } + url: { type: string, format: uri } + web_url: { type: string, format: uri } + showcase_url: { type: string, format: uri } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true, example: /proyectos/mi-web } @@ -1768,6 +5117,16 @@ components: alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } + PublicSearchResult: + type: object + properties: + type: { type: string, enum: [page, project, service, call, member, technology] } + id: { type: integer } + title: { type: string } + excerpt: { type: string, description: Plain text around the first match. } + path: { type: string, nullable: true, example: /proyectos/mi-proyecto } + score: { type: number, description: Higher is a better match. Only comparable within one response. } + PublicTechnology: type: object properties: @@ -2000,6 +5359,26 @@ components: cache downstream at the same moment. updated_at: { type: string, format: date-time } category: { $ref: "#/components/schemas/PublicCategory" } + tags: + type: array + description: The ones named in this language, to link to the blog filtered by each. + items: + type: object + properties: + name: { type: string } + slug: { type: string } + byline: + type: object + nullable: true + description: The member who signs it, when they have a public profile. + properties: + name: { type: string } + slug: { type: string } + headline: { type: string, nullable: true } + avatar: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + path: { type: string } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } @@ -2076,6 +5455,8 @@ components: deleted_at: { type: string, format: date-time, nullable: true } category: { $ref: "#/components/schemas/Category" } author: { $ref: "#/components/schemas/User" } + byline_member_id: { type: integer, nullable: true, description: The member who signs it on the site. } + tag_ids: { type: array, items: { type: integer } } featured_image_alt: type: string nullable: true @@ -2104,6 +5485,8 @@ components: meta_title: { $ref: "#/components/schemas/TranslatedField" } meta_description: { $ref: "#/components/schemas/TranslatedField" } category_id: { type: integer, nullable: true } + byline_member_id: { type: integer, nullable: true, description: The member who signs it on the site. } + tag_ids: { type: array, items: { type: integer }, description: Replaced whole when sent. } status: { type: string, enum: [draft, review, published, archived] } featured_image: type: string @@ -2123,6 +5506,969 @@ components: nullable: true description: Must be after `published_at`. When it arrives the record leaves the site on its own. + Project: + type: object + properties: + id: { type: integer } + locale: { type: string, description: The language the fields below were resolved into. } + title: { type: string, nullable: true } + slug: { type: string, nullable: true } + tagline: { type: string, nullable: true } + description: { type: string, nullable: true, description: HTML from the panel's rich editor. } + decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. } + postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. } + meta_title: { type: string, nullable: true } + meta_description: { type: string, nullable: true } + cover_image_alt: { type: string, nullable: true } + status: { type: string, enum: [idea, active, paused, completed, archived] } + is_featured: { type: boolean } + needs_help: { type: boolean } + demo_url: { type: string, nullable: true, format: uri } + taskstream_key: { type: string, nullable: true, description: "The project's key in TaskStream, when it has a board there." } + taskstream_url: { type: string, nullable: true, format: uri, readOnly: true, description: Where its TaskStream board opens. } + cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. } + cover_image_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + started_at: { type: string, format: date, nullable: true } + ended_at: { type: string, format: date, nullable: true } + published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. } + unpublish_at: { type: string, format: date-time, nullable: true } + position: { type: integer } + client_id: { type: integer, nullable: true } + lead_member_id: { type: integer, nullable: true } + technology_ids: { type: array, items: { type: integer } } + service_ids: { type: array, items: { type: integer } } + members: + type: array + items: + type: object + properties: + id: { type: integer } + name: { type: string } + role: { type: string, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + deleted_at: { type: string, format: date-time, nullable: true } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + ProjectWrite: + type: object + required: [title, slug] + properties: + title: { $ref: "#/components/schemas/TranslatedField" } + slug: + allOf: [{ $ref: "#/components/schemas/TranslatedField" }] + description: Unique per language. + tagline: { $ref: "#/components/schemas/TranslatedField" } + description: { $ref: "#/components/schemas/TranslatedField" } + decisions: { $ref: "#/components/schemas/TranslatedField" } + postmortem: { $ref: "#/components/schemas/TranslatedField" } + meta_title: { $ref: "#/components/schemas/TranslatedField" } + meta_description: { $ref: "#/components/schemas/TranslatedField" } + cover_image_alt: { $ref: "#/components/schemas/TranslatedField" } + status: { type: string, enum: [idea, active, paused, completed, archived] } + is_featured: { type: boolean } + needs_help: { type: boolean } + demo_url: { type: string, format: uri, nullable: true } + taskstream: + type: boolean + description: On creation only. Also open a public board for it in TaskStream; see `POST /v1/projects`. + taskstream_key: + type: string + nullable: true + pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$" + description: On creation only, with `taskstream`. The key in TaskStream; made from the slug when left out. + cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. } + started_at: { type: string, format: date, nullable: true } + ended_at: { type: string, format: date, nullable: true } + published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. } + unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. } + position: { type: integer, minimum: 0 } + client_id: { type: integer, nullable: true } + lead_member_id: { type: integer, nullable: true } + technology_ids: { type: array, items: { type: integer } } + service_ids: { type: array, items: { type: integer } } + members: + type: array + description: The whole team, replacing the previous one. + items: + type: object + required: [id] + properties: + id: { type: integer } + role: { type: string, nullable: true, maxLength: 120 } + + ProjectCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Project" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Member: + type: object + properties: + id: { type: integer } + locale: { type: string } + name: { type: string } + slug: { type: string } + headline: { type: string, nullable: true } + bio: { type: string, nullable: true, description: Plain text. } + avatar: { type: string, nullable: true, description: The stored path. } + avatar_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + email: { type: string, nullable: true, description: Never shown on the site. } + github_username: { type: string, nullable: true } + links: { type: object, additionalProperties: { type: string }, description: Label → URL. } + is_public: { type: boolean } + is_founder: { type: boolean } + joined_at: { type: string, format: date, nullable: true } + left_at: { type: string, format: date, nullable: true } + position: { type: integer } + technology_ids: { type: array, items: { type: integer } } + projects: + type: array + items: + type: object + properties: + id: { type: integer } + title: { type: string, nullable: true } + role: { type: string, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + deleted_at: { type: string, format: date-time, nullable: true } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + MemberWrite: + type: object + required: [name, slug] + properties: + name: { type: string, maxLength: 255 } + slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" } + headline: { $ref: "#/components/schemas/TranslatedField" } + bio: { $ref: "#/components/schemas/TranslatedField" } + avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. } + email: { type: string, format: email, nullable: true } + github_username: { type: string, nullable: true, maxLength: 120 } + links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } } + is_public: { type: boolean } + is_founder: { type: boolean } + joined_at: { type: string, format: date, nullable: true } + left_at: { type: string, format: date, nullable: true } + position: { type: integer, minimum: 0 } + technology_ids: { type: array, items: { type: integer } } + + MemberCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Member" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Service: + type: object + properties: + id: { type: integer } + locale: { type: string } + title: { type: string, nullable: true } + slug: { type: string, nullable: true } + tagline: { type: string, nullable: true } + description: { type: string, nullable: true, description: Plain text. } + meta_title: { type: string, nullable: true } + meta_description: { type: string, nullable: true } + icon: { type: string, nullable: true } + is_featured: { type: boolean } + is_published: { type: boolean } + published_at: { type: string, format: date-time, nullable: true } + unpublish_at: { type: string, format: date-time, nullable: true } + position: { type: integer } + technology_ids: { type: array, items: { type: integer } } + project_ids: { type: array, items: { type: integer } } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + deleted_at: { type: string, format: date-time, nullable: true } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + ServiceWrite: + type: object + required: [title, slug] + properties: + title: { $ref: "#/components/schemas/TranslatedField" } + slug: + allOf: [{ $ref: "#/components/schemas/TranslatedField" }] + description: Unique per language. + tagline: { $ref: "#/components/schemas/TranslatedField" } + description: { $ref: "#/components/schemas/TranslatedField" } + meta_title: { $ref: "#/components/schemas/TranslatedField" } + meta_description: { $ref: "#/components/schemas/TranslatedField" } + icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." } + is_featured: { type: boolean } + position: { type: integer, minimum: 0 } + is_published: { type: boolean, description: Requires `services.publish`. } + published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. } + unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. } + technology_ids: { type: array, items: { type: integer } } + project_ids: { type: array, items: { type: integer } } + + ServiceCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Service" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Client: + type: object + properties: + id: { type: integer } + locale: { type: string } + name: { type: string } + slug: { type: string } + sector: { type: string, nullable: true } + logo: { type: string, nullable: true, description: The stored path. } + logo_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + url: { type: string, format: uri, nullable: true } + is_public: { type: boolean } + position: { type: integer } + projects_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + deleted_at: { type: string, format: date-time, nullable: true } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + ClientWrite: + type: object + required: [name, slug] + properties: + name: { type: string, maxLength: 255, description: A proper noun; not translated. } + slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" } + sector: { $ref: "#/components/schemas/TranslatedField" } + logo: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=clients`. } + url: { type: string, format: uri, nullable: true } + is_public: { type: boolean } + position: { type: integer, minimum: 0 } + + ClientCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Client" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Testimonial: + type: object + properties: + id: { type: integer } + locale: { type: string } + quote: { type: string, nullable: true } + author_role: { type: string, nullable: true } + original_locale: { type: string, description: The language the person spoke in. } + author_name: { type: string } + avatar: { type: string, nullable: true, description: The stored path. } + avatar_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + client_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + service_id: { type: integer, nullable: true } + is_public: { type: boolean } + position: { type: integer } + given_at: { type: string, format: date, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + deleted_at: { type: string, format: date-time, nullable: true } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + TestimonialWrite: + type: object + required: [quote, original_locale, author_name] + properties: + quote: { $ref: "#/components/schemas/TranslatedField" } + original_locale: { type: string, description: A supported language code. } + author_name: { type: string, maxLength: 255 } + author_role: { $ref: "#/components/schemas/TranslatedField" } + avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=testimonials`. } + client_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + service_id: { type: integer, nullable: true } + is_public: { type: boolean } + position: { type: integer, minimum: 0 } + given_at: { type: string, format: date, nullable: true } + + TestimonialCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Testimonial" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Talk: + type: object + properties: + id: { type: integer } + locale: { type: string } + title: { type: string } + language: { type: string } + summary: { type: string, nullable: true } + event: { type: string } + city: { type: string, nullable: true } + held_at: { type: string, format: date, nullable: true } + video_url: { type: string, format: uri, nullable: true } + slides_url: { type: string, format: uri, nullable: true } + member_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + is_public: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + TalkWrite: + type: object + required: [title, language, event] + properties: + title: { type: string, maxLength: 255, description: Not translated. } + language: { type: string, description: A supported language code. } + summary: { $ref: "#/components/schemas/TranslatedField" } + event: { type: string, maxLength: 255 } + city: { type: string, nullable: true, maxLength: 120 } + held_at: { type: string, format: date, nullable: true } + video_url: { type: string, format: uri, nullable: true } + slides_url: { type: string, format: uri, nullable: true } + member_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + is_public: { type: boolean } + + TalkCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Talk" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Call: + type: object + properties: + id: { type: integer } + locale: { type: string } + title: { type: string, nullable: true } + slug: { type: string, nullable: true } + description: { type: string, nullable: true } + what_you_get: { type: string, nullable: true } + status: { type: string, enum: [open, filled, closed] } + hours_per_week: { type: integer, nullable: true } + duration_weeks: { type: integer, nullable: true } + seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } + opens_at: { type: string, format: date, nullable: true } + closes_at: { type: string, format: date, nullable: true } + project_id: { type: integer, nullable: true } + technology_ids: { type: array, items: { type: integer } } + applications_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + CallWrite: + type: object + required: [title, slug] + properties: + title: { $ref: "#/components/schemas/TranslatedField" } + slug: + allOf: [{ $ref: "#/components/schemas/TranslatedField" }] + description: Unique per language. + description: { $ref: "#/components/schemas/TranslatedField" } + what_you_get: { $ref: "#/components/schemas/TranslatedField" } + status: { type: string, enum: [open, filled, closed], default: open } + hours_per_week: { type: integer, nullable: true, minimum: 1, maximum: 40 } + duration_weeks: { type: integer, nullable: true, minimum: 1 } + seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } + opens_at: { type: string, format: date, nullable: true } + closes_at: { type: string, format: date, nullable: true } + project_id: { type: integer, nullable: true } + technology_ids: { type: array, items: { type: integer } } + + CallCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Call" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Application: + type: object + properties: + id: { type: integer } + kind: { type: string, enum: [contribute, request] } + status: { type: string, enum: [new, contacted, accepted, declined, spam] } + call_id: { type: integer, nullable: true } + subject: { type: string, description: "The call's title, or a general enquiry." } + name: { type: string } + email: { type: string, format: email } + organisation: { type: string, nullable: true } + message: { type: string } + hours_per_week: { type: integer, nullable: true } + links: { type: object, additionalProperties: { type: string }, description: "The links the sender gave, keyed as they were sent." } + locale: { type: string, description: The language it was written in. } + notes: { type: string, nullable: true, description: Private to the collective. } + handled_by: + type: object + nullable: true + properties: + id: { type: integer } + name: { type: string } + handled_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + deleted_at: { type: string, format: date-time, nullable: true } + + ApplicationWrite: + type: object + properties: + status: { type: string, enum: [new, contacted, accepted, declined, spam] } + notes: { type: string, nullable: true, maxLength: 10000 } + + ApplicationCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Application" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Redirect: + type: object + properties: + id: { type: integer } + from: { type: string } + to: { type: string } + status: { type: integer, enum: [301, 302] } + locale: { type: string, nullable: true, description: Null applies in every language. } + is_enabled: { type: boolean } + automatic: { type: boolean, description: Left behind by a slug rename. } + hits: { type: integer } + last_used_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + RedirectWrite: + type: object + required: [from, to] + properties: + from: { type: string, maxLength: 500 } + to: { type: string, maxLength: 500, description: Different from `from`. } + status: { type: integer, enum: [301, 302], default: 301 } + locale: { type: string, nullable: true } + is_enabled: { type: boolean, default: true } + + RedirectCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Redirect" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + MissingUrl: + type: object + properties: + id: { type: integer } + path: { type: string } + locale: { type: string } + referrer: { type: string, nullable: true } + internal_referrer: { type: boolean, description: "A page of our own links here — more urgent than someone else's stale link." } + status: { type: string, enum: [pending, resolved, ignored] } + hits: { type: integer } + first_seen_at: { type: string, format: date-time, nullable: true } + last_seen_at: { type: string, format: date-time, nullable: true } + resolved_at: { type: string, format: date-time, nullable: true } + + MissingUrlWrite: + type: object + required: [status] + properties: + status: { type: string, enum: [pending, ignored] } + + MissingUrlCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/MissingUrl" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Repository: + type: object + properties: + id: { type: integer } + provider: { type: string, enum: [gitea, github, gitlab] } + full_name: { type: string, description: "`owner/name`." } + project_id: { type: integer, nullable: true } + is_primary: { type: boolean } + show_publicly: { type: boolean } + html_url: { type: string, nullable: true } + description: { type: string, nullable: true } + homepage: { type: string, nullable: true } + default_branch: { type: string, nullable: true } + primary_language: { type: string, nullable: true } + license: { type: string, nullable: true } + topics: { type: array, items: { type: string } } + stars: { type: integer } + forks: { type: integer } + open_issues_count: { type: integer } + is_archived: { type: boolean } + pushed_at: { type: string, format: date-time, nullable: true } + last_synced_at: { type: string, format: date-time, nullable: true } + sync_error: { type: string, nullable: true } + sync_failures: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + RepositoryWrite: + type: object + required: [provider, full_name] + properties: + provider: { type: string, enum: [gitea, github, gitlab] } + full_name: { type: string, maxLength: 255, description: "`owner/name`, unique per provider." } + project_id: { type: integer, nullable: true } + is_primary: { type: boolean } + show_publicly: { type: boolean } + + RepositoryCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Repository" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Setting: + type: object + properties: + key: { type: string } + value: { nullable: true, description: Any JSON value. } + updated_at: { type: string, format: date-time, nullable: true } + + Maintenance: + type: object + properties: + enabled: { type: boolean, description: False once `until` has passed. } + message: { type: object, additionalProperties: { type: string }, description: Language → text shown to visitors. } + until: { type: string, format: date-time, nullable: true } + started_at: { type: string, format: date-time, nullable: true } + + Revision: + type: object + properties: + id: { type: integer } + event: { type: string, enum: [created, updated, published, scheduled, unpublished, deleted, restored, erased] } + author: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string } + changes: + type: object + description: "Field → `{old, new}`. Translatable fields carry every language." + additionalProperties: + type: object + properties: + old: { nullable: true } + new: { nullable: true } + created_at: { type: string, format: date-time } + + RevisionCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Revision" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + ReviewComment: + type: object + properties: + id: { type: integer } + event: { type: string, enum: [requested, returned, published, commented] } + body: { type: string, nullable: true } + author: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + created_at: { type: string, format: date-time } + + ReviewCommentCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ReviewComment" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + ReviewState: + type: object + properties: + type: { type: string, enum: [pages, projects, services] } + id: { type: integer } + title: { type: string, nullable: true } + published: { type: boolean, description: On the site right now. } + published_at: { type: string, format: date-time, nullable: true, description: In the future when scheduled. } + awaiting_review: { type: boolean } + requested_at: { type: string, format: date-time, nullable: true } + requested_by: { $ref: "#/components/schemas/UserRef" } + reviewer: { $ref: "#/components/schemas/UserRef" } + + UserRef: + type: object + nullable: true + properties: + id: { type: integer } + name: { type: string } + + PreviewLinks: + type: object + properties: + links: { type: object, additionalProperties: { type: string, format: uri }, description: Language → link. } + expires_at: { type: string, format: date-time } + + HealthFinding: + type: object + properties: + key: { type: string, example: missing-translations } + severity: { type: string, enum: [ok, warning, error] } + count: { type: integer } + title: { type: string } + description: { type: string } + samples: { type: array, items: { type: string }, description: Up to three examples. } + panel_url: { type: string, nullable: true, description: Where to fix it in the panel. } + + PendingTranslation: + type: object + properties: + type: { type: string, enum: [pages, projects, services, calls] } + id: { type: integer } + title: { type: string } + missing: { type: array, items: { type: string }, description: Languages with nothing written. } + stale: { type: object, additionalProperties: { type: string, format: date-time }, description: Language → since when it has been behind. } + + BatchOutcome: + type: object + properties: + data: + type: array + items: + type: object + properties: + index: { type: integer } + method: { type: string } + path: { type: string } + status: { type: integer, description: The HTTP status the operation answered with. } + body: { nullable: true, description: What the operation answered. } + meta: + type: object + properties: + committed: { type: boolean, description: False when an atomic run or a dry run undid everything. } + succeeded: { type: integer } + failed: { type: integer } + dry_run: { type: boolean, nullable: true } + + ApiToken: + type: object + properties: + id: { type: integer } + name: { type: string } + prefix: { type: string, description: "The first characters, to tell tokens apart." } + abilities: { type: array, items: { type: string } } + owner: + type: object + properties: + id: { type: integer } + name: { type: string, nullable: true } + status: { type: string, enum: [active, expired, revoked] } + expires_at: { type: string, format: date-time } + last_used_at: { type: string, format: date-time, nullable: true } + revoked_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + + ApiTokenCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ApiToken" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Webhook: + type: object + properties: + id: { type: integer } + name: { type: string } + url: { type: string, format: uri } + events: { type: array, items: { type: string } } + include_personal_data: { type: boolean } + is_active: { type: boolean } + consecutive_failures: { type: integer } + last_delivered_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + WebhookWrite: + type: object + required: [name, url, events] + properties: + name: { type: string, maxLength: 120 } + url: { type: string, format: uri, description: "HTTPS, to a public address." } + events: + type: array + items: + type: string + description: "An event, `*`, `{resource}.*` or `*.{action}`." + example: projects.* + secret: { type: string, minLength: 16, description: On creation only; generated when left out. } + rotate_secret: { type: boolean, description: On update only. } + include_personal_data: { type: boolean, default: false } + is_active: { type: boolean } + + WebhookCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Webhook" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + WebhookDelivery: + type: object + properties: + id: { type: integer } + uuid: { type: string, description: "Sent as `X-Webhook-Delivery`." } + event: { type: string } + status: { type: string, enum: [pending, succeeded, failed] } + attempts: { type: integer } + response_status: { type: integer, nullable: true } + error: { type: string, nullable: true } + payload: { type: object, additionalProperties: true } + delivered_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + + WebhookDeliveryCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/WebhookDelivery" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + AuditLog: + type: object + properties: + id: { type: integer } + action: { type: string, example: pages.updated } + user: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + via: { type: string, enum: [panel, api, token, web, console] } + api_token_id: { type: integer, nullable: true, description: The machine token used. } + ip: { type: string, nullable: true } + subject: + type: object + nullable: true + properties: + type: { type: string } + id: { type: string } + label: { type: string, nullable: true } + changes: + type: object + nullable: true + additionalProperties: true + description: "Field → `{old, new}` on an update; the values on a creation. Long values are cut short." + created_at: { type: string, format: date-time } + + AuditLogCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/AuditLog" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + ApplicationReply: + type: object + properties: + id: { type: integer } + subject: { type: string } + body: { type: string } + channel: { type: string, enum: [mail, mailto], description: "`mail` when sent from here; `mailto` when handed to the replier's mail program." } + author: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + created_at: { type: string, format: date-time } + + ReplyTemplate: + type: object + properties: + id: { type: integer } + name: { type: string } + kind: { type: string, nullable: true, enum: [contribute, request] } + subject: { type: object, additionalProperties: { type: string }, description: "Language → text. `{name}`, `{subject}` and `{sender}` are filled in." } + body: { type: object, additionalProperties: { type: string } } + + Tag: + type: object + properties: + id: { type: integer } + name: { type: object, additionalProperties: { type: string }, description: Language → name. } + slug: { type: object, additionalProperties: { type: string } } + pages_count: { type: integer } + + TagWrite: + type: object + required: [name, slug] + properties: + name: { $ref: "#/components/schemas/TranslatedField" } + slug: + allOf: [{ $ref: "#/components/schemas/TranslatedField" }] + description: Unique per language. + + MediaFor: + type: string + enum: [pages, technologies, projects, members, clients, testimonials] + default: pages + description: The kind of record the image is for. + + Technology: + type: object + properties: + id: { type: integer } + locale: { type: string, description: The language the prose below was resolved into. } + name: { type: string, description: A proper noun — the same in every language. } + slug: { type: string } + description: { type: string, nullable: true } + radar_note: { type: string, nullable: true, description: Why it sits in its ring. } + category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } + radar_ring: + type: string + enum: [adopt, trial, assess, hold] + nullable: true + description: Null keeps it off the radar; projects can still use it. + color: { type: string, nullable: true, example: "#DD0031" } + logo: + type: string + nullable: true + description: The stored path — what `POST /v1/media` answered — and what a client PATCHes back. + logo_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + website: { type: string, nullable: true, format: uri } + position: { type: integer } + projects_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + translations: + type: object + nullable: true + description: Every language of `description` and `radar_note`, with `?with=translations`. + + TechnologyWrite: + type: object + required: [name, slug] + properties: + name: { type: string, maxLength: 120 } + slug: { type: string, maxLength: 120, pattern: "^[A-Za-z0-9_-]+$" } + description: { $ref: "#/components/schemas/TranslatedField" } + radar_note: { $ref: "#/components/schemas/TranslatedField" } + category: + type: string + enum: [language, framework, datastore, infrastructure, tool, practice] + default: tool + radar_ring: { type: string, enum: [adopt, trial, assess, hold], nullable: true } + color: { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" } + logo: + type: string + nullable: true + description: A path from `POST /v1/media`, uploaded with `for=technologies`. + website: { type: string, format: uri, nullable: true, maxLength: 255 } + position: { type: integer, minimum: 0 } + + TechnologyCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Technology" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Media: + type: object + properties: + id: { type: integer } + path: + type: string + description: What a record stores, e.g. as a page's `featured_image`. + example: pages/Xk3fQ9sLw2PmN8vR1tY6uZ0aB4cD7eF5gH2jK9mN.jpg + url: { type: string, format: uri, description: Absolute. } + mime: { type: string, nullable: true, example: image/jpeg } + size: { type: integer, nullable: true, description: Bytes. } + width: { type: integer, nullable: true } + height: { type: integer, nullable: true } + processed: + type: boolean + description: | + Whether the queue has derived the smaller versions and the + placeholder yet. The image serves either way. + error: { type: string, nullable: true, description: "Why processing failed, when it did." } + referenced: + type: boolean + nullable: true + description: Whether anything points at it. Sent by the library only, not on upload. + created_at: { type: string, format: date-time } + + MediaCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Media" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + Category: type: object properties: diff --git a/landing/src/app/core/api.types.ts b/landing/src/app/core/api.types.ts index 749e830..cdaed06 100644 --- a/landing/src/app/core/api.types.ts +++ b/landing/src/app/core/api.types.ts @@ -241,6 +241,10 @@ export interface PublicProject { lead: PublicMember; calls: PublicCall[]; repositories: PublicRepository[]; + /** + * Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue. + */ + taskstream?: { key: string; url: string; web_url: string; showcase_url: string }; alternates: Alternates; path?: string; } diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index e505f3b..dbbbfe9 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -107,6 +107,16 @@ export interface Copy { archived: string; openCalls: string; technologies: string; + roadmap: string; + roadmapLead: string; + releases: string; + released: string; + planned: string; + sprint: string; + goodFirst: string; + goodFirstLead: string; + board: string; + done: string; }; member: { projects: string; @@ -297,6 +307,16 @@ const es: Copy = { archived: 'Archivado', openCalls: 'Buscamos ayuda', technologies: 'Tecnologías', + roadmap: 'Hoja de ruta', + roadmapLead: 'Lo que viene, según nuestro tablero.', + releases: 'Versiones', + released: 'Publicada', + planned: 'Prevista', + sprint: 'Ahora mismo', + goodFirst: 'Para empezar a colaborar', + goodFirstLead: 'Tareas pensadas para quien llega nuevo.', + board: 'Ver el tablero', + done: 'Hecho', }, member: { projects: 'Proyectos', @@ -506,6 +526,16 @@ const en: Copy = { archived: 'Archived', openCalls: 'Looking for help', technologies: 'Technologies', + roadmap: 'Roadmap', + roadmapLead: 'What is coming, from our board.', + releases: 'Releases', + released: 'Released', + planned: 'Planned', + sprint: 'Right now', + goodFirst: 'Good first issues', + goodFirstLead: 'Tasks meant for newcomers.', + board: 'See the board', + done: 'Done', }, member: { projects: 'Projects', @@ -715,6 +745,16 @@ const ca: Copy = { archived: 'Arxivat', openCalls: 'Busquem ajuda', technologies: 'Tecnologies', + roadmap: 'Full de ruta', + roadmapLead: 'El que ve, segons el nostre tauler.', + releases: 'Versions', + released: 'Publicada', + planned: 'Prevista', + sprint: 'Ara mateix', + goodFirst: 'Per començar a col·laborar', + goodFirstLead: 'Tasques pensades per a qui arriba de nou.', + board: 'Veure el tauler', + done: 'Fet', }, member: { projects: 'Projectes', diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index 77e7f40..dcc7750 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -25,11 +25,16 @@ import type { import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog'; import { copyFor } from './copy'; import { listingPath, resolveRoute, type RouteMatch } from './routes'; +import { + fetchShowcase, + hasContent, + type ProjectBoard, +} from './taskstream'; /** The record, tagged with its type so the template can switch on it. */ export type RoutedRecord = | { type: 'page'; record: PublicPage } - | { type: 'project'; record: PublicProject } + | { type: 'project'; record: PublicProject; board: ProjectBoard | null } | { type: 'member'; record: PublicMember } | { type: 'call'; record: PublicCall } | { type: 'service'; record: PublicService } @@ -82,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise { } case 'project': { const { data } = await pireonApi.project(locale, slug); - return data ? { type: 'project', record: data } : null; + return data + ? { type: 'project', record: data, board: await boardOf(data) } + : null; } case 'member': { const { data } = await pireonApi.member(locale, slug); @@ -166,3 +173,13 @@ export function pageNavLinks( ] : featured; } + +/** The project's TaskStream board, when it has one with something to show. */ +async function boardOf(project: PublicProject): Promise { + const board = project.taskstream; + if (!board?.showcase_url) return null; + const showcase = await fetchShowcase(board.showcase_url); + return showcase && hasContent(showcase) + ? { url: board.url, webUrl: board.web_url, showcase } + : null; +} diff --git a/landing/src/app/core/taskstream.spec.ts b/landing/src/app/core/taskstream.spec.ts new file mode 100644 index 0000000..652a48e --- /dev/null +++ b/landing/src/app/core/taskstream.spec.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { boardLink, fetchShowcase, hasContent } from './taskstream'; + +const showcase = { + project: { id: 'p', key: 'WEB', name: 'Web', description: null }, + activeSprint: null, + roadmap: [], + releases: [], + goodFirst: [ + { key: 'WEB-9', title: 'Typo', type: 'TASK', path: '/p/WEB/issues/WEB-9' }, + ], +}; + +describe('TaskStream showcase', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('reads a showcase, and goes without it when TaskStream fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(showcase))), + ); + expect(await fetchShowcase('https://api.ts/showcase')).toEqual(showcase); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('nope', { status: 404 })), + ); + expect(await fetchShowcase('https://api.ts/showcase')).toBeNull(); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('down'); + }), + ); + expect(await fetchShowcase('https://api.ts/showcase')).toBeNull(); + }); + + it('builds links into the board and knows an empty one', () => { + expect( + boardLink('https://taskstream.pireon.dev/', '/p/WEB/issues/WEB-9'), + ).toBe('https://taskstream.pireon.dev/p/WEB/issues/WEB-9'); + expect(hasContent(showcase)).toBe(true); + expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false); + }); +}); diff --git a/landing/src/app/core/taskstream.ts b/landing/src/app/core/taskstream.ts new file mode 100644 index 0000000..40c19f5 --- /dev/null +++ b/landing/src/app/core/taskstream.ts @@ -0,0 +1,85 @@ +/** + * A project's board in TaskStream, the collective's issue tracker: its public + * showcase (roadmap, releases, the sprint under way and tickets for + * newcomers). Fetched on the server; a slow or absent TaskStream only means + * the project page goes without those sections. + */ + +export interface TaskStreamShowcase { + project: { + id: string; + key: string; + name: string; + description: string | null; + }; + activeSprint: { + name: string; + goal: string | null; + startDate: string; + endDate: string; + percent: number; + } | null; + roadmap: { + key: string; + title: string; + startDate: string | null; + dueDate: string | null; + percent: number; + done: boolean; + path: string; + }[]; + releases: { + name: string; + status: 'PLANNED' | 'RELEASED' | 'ARCHIVED'; + targetDate: string | null; + releasedAt: string | null; + percent: number; + notes: string | null; + }[]; + goodFirst: { key: string; title: string; type: string; path: string }[]; +} + +/** What the page renders: the showcase plus where its links go. */ +export interface ProjectBoard { + url: string; + webUrl: string; + showcase: TaskStreamShowcase; +} + +export async function fetchShowcase( + showcaseUrl: string, + timeout = 4000, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(showcaseUrl, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + if (!response.ok) return null; + const body = (await response.json()) as Partial; + return Array.isArray(body.roadmap) && Array.isArray(body.goodFirst) + ? (body as TaskStreamShowcase) + : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** Something worth showing: a board with nothing on it says nothing. */ +export function hasContent(showcase: TaskStreamShowcase): boolean { + return ( + showcase.roadmap.length > 0 || + showcase.releases.length > 0 || + showcase.goodFirst.length > 0 || + showcase.activeSprint !== null + ); +} + +/** A showcase path as an address in TaskStream's web app. */ +export function boardLink(webUrl: string, path: string): string { + return `${webUrl.replace(/\/$/, '')}${path}`; +} diff --git a/landing/src/app/ui/project-detail.scss b/landing/src/app/ui/project-detail.scss index fa78f1e..4f62c6b 100644 --- a/landing/src/app/ui/project-detail.scss +++ b/landing/src/app/ui/project-detail.scss @@ -87,3 +87,50 @@ margin: 0; } } + +.project-roadmap, +.project-releases, +.project-first { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.project-roadmap__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.25rem 1rem; + align-items: baseline; + + &.is-done a { + text-decoration: line-through; + } +} + +.project-roadmap__bar { + grid-column: 1 / -1; + height: 0.375rem; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, currentColor 12%, transparent); + + span { + display: block; + height: 100%; + background: var(--pi-color-primary, currentColor); + } +} + +.project-releases li { + display: grid; + gap: 0.25rem; +} + +.project-releases__notes { + margin: 0; + white-space: pre-wrap; + font: inherit; + font-size: 0.9rem; +} diff --git a/landing/src/app/ui/project-detail.ts b/landing/src/app/ui/project-detail.ts index 5199391..6a64c82 100644 --- a/landing/src/app/ui/project-detail.ts +++ b/landing/src/app/ui/project-detail.ts @@ -16,6 +16,7 @@ import { import type { Locale } from '../core/api'; import type { PublicProject } from '../core/api.types'; import type { Copy } from '../core/copy'; +import { boardLink, type ProjectBoard } from '../core/taskstream'; import { ApiImage } from './api-image'; import { ContactCta } from './contact-cta'; import { PROJECT_STATUS_TONES } from './project-card'; @@ -143,6 +144,61 @@ import { LinkButton } from './link-button'; } + @if (board(); as b) { + @if (b.showcase.roadmap.length > 0) { +
+

{{ copy().project.roadmap }}

+

{{ copy().project.roadmapLead }}

+
    + @for (epic of b.showcase.roadmap; track epic.key) { +
  1. + {{ + epic.title + }} + {{ + dates(epic.startDate, epic.dueDate) + }} + +
  2. + } +
+
+ } + @if (b.showcase.releases.length > 0) { +
+

{{ copy().project.releases }}

+
    + @for (release of b.showcase.releases; track release.name) { +
  • + {{ release.name }} + + {{ + release.status === 'RELEASED' + ? copy().project.released + : copy().project.planned + }} + @if (release.releasedAt ?? release.targetDate; as day) { + · {{ dates(day, null) }} + } + @if (release.status !== 'RELEASED') { + · {{ release.percent }}% + } + + @if (release.notes) { +
    {{ release.notes }}
    + } +
  • + } +
+
+ } + } + @if ((project().gallery ?? []).length > 0) {

{{ copy().project.gallery }}

@@ -202,6 +258,36 @@ import { LinkButton } from './link-button'; + @if (board(); as b) { +
+ @if (b.showcase.activeSprint; as sprint) { +

{{ copy().project.sprint }}

+

+ {{ sprint.name }} · {{ sprint.percent }}% + @if (sprint.goal) { +
{{ sprint.goal }} + } +

+ } + @if (b.showcase.goodFirst.length > 0) { +

{{ copy().project.goodFirst }}

+

{{ copy().project.goodFirstLead }}

+ + } + {{ + copy().project.board + }} → +
+ } + @if ((project().calls ?? []).length > 0) {