Compare commits

..
Author SHA1 Message Date
bflatastic 0aa88abcd2 revert dashboard merge 2026-06-23 21:24:49 +07:00
302 changed files with 452 additions and 38674 deletions
@@ -48,7 +48,7 @@
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
@@ -132,7 +132,7 @@
false,
false,
true,
true,
false,
true,
true,
true,
@@ -48,7 +48,7 @@
"name": "maybe_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
@@ -132,7 +132,7 @@
false,
false,
true,
true,
false,
true,
true,
true,
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,\n jobs.created_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `order_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,\n jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.job_type = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
@@ -92,16 +92,6 @@
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
@@ -116,9 +106,8 @@
false,
true,
true,
true,
false
true
]
},
"hash": "1703bc63ce65d98cbcad81d515cbcf211a943c533e38e887d2c12713c78b687d"
"hash": "ddbbd3886c00268edd3993508f635549fc8ddc877bb059fd58f14b8c07586fc9"
}
@@ -1,8 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- Revert `maybe_entity_token` back to NOT NULL.
-- NB: This will fail if any rows have a null `maybe_entity_token`.
ALTER TABLE staff_audit_logs
MODIFY COLUMN maybe_entity_token VARCHAR(32) NOT NULL;
@@ -1,9 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- `maybe_entity_token` is optional (many audited actions have no entity), but it
-- was mistakenly created as NOT NULL, so inserts with a null entity token fail
-- with "Column 'maybe_entity_token' cannot be null". Make it nullable.
ALTER TABLE staff_audit_logs
MODIFY COLUMN maybe_entity_token VARCHAR(32) DEFAULT NULL;
@@ -17,7 +17,7 @@ CREATE TABLE staff_audit_logs (
maybe_entity_type VARCHAR(16) DEFAULT NULL,
-- The token of the entity mutated (if any)
maybe_entity_token VARCHAR(32) DEFAULT NULL,
maybe_entity_token VARCHAR(32) NOT NULL,
-- The staff user who performed the action.
staff_user_token VARCHAR(32) DEFAULT NULL,
-625
View File
@@ -1,625 +0,0 @@
# ArtCraft Omni API
> # 📚 Full API reference: **<https://storyteller-docs.netlify.app/>**
>
> **Every ArtCraft API endpoint is documented there.** This guide covers only the Omni API video
> endpoints — for everything else, start at the link above.
The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the
in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key
in the `Authorization` header instead of a browser session cookie.
This guide covers **video generation from URL inputs** — passing image, video, and audio reference
URLs directly. The server downloads each URL, stores it as a media file on your account, and then
runs generation against it. You never have to upload files yourself.
> All examples send **no cookies**. The Omni API is authenticated purely by your API key. If you
> send session cookies they are ignored.
---
## Base URLs
| Environment | Base URL |
| ------------- | ------------------------------ |
| Development | `http://localhost:12345` |
| Production | `https://api.storyteller.ai` |
All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/video`).
The examples in this guide use the **development** base URL. To run against production, swap
`http://localhost:12345` for `https://api.storyteller.ai`.
Note: Our APIs are hosted on "`api.storyteller.ai`", not "`api.getartcraft.com`". This is not a
typo. Our Cloudflare CDN URLs are `cdn-2.fakeyou.com`.
---
## Getting an API key
Create and manage API keys from the ArtCraft web app:
1. Go to **<https://app.getartcraft.com/>**
2. Click **Account → Settings → API Keys** (your account must have API keys enabled from our staff)
3. Create a key and copy it. The secret is shown **once** — store it somewhere safe.
> **Don't see the API Keys section?** API access is gated per account. Contact the Artcraft team
> to enable it.
### What an API key looks like
- Prefix: `artcraft_api_`
- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`)
- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6`
Treat the key like a password: never commit it to source control or paste it into logs.
---
## Authentication
Send the secret key in the `Authorization` header. Three forms are accepted:
```
Authorization: Bearer artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: Key artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
`Bearer` is recommended. **Do not send cookies.**
---
## Video generation — `POST /v1/omni_api/generate/video`
Generates a video and returns an inference job token you can poll for results.
### Request body
| Field | Type | Notes |
| ---------------------------- | --------------- | ----- |
| `model` | string | Generation model, e.g. `seedance_2p0`. **Required.** |
| `prompt` | string | Text prompt. |
| `duration_seconds` | integer | Length of the output video, e.g. `6`. |
| `aspect_ratio` | string | e.g. `wide_sixteen_by_nine`, `tall_nine_by_sixteen`, `square`. |
| `idempotency_token` | string (UUID) | **Required.** A fresh UUID per request; prevents accidental duplicates. |
| `negative_prompt` | string | Optional. |
| `resolution` | string | Optional, e.g. `seven_twenty_p`, `ten_eighty_p`. |
| `generate_audio` | boolean | Optional. |
| **URL inputs** (this guide) | | |
| `start_frame_image_url` | string (URL) | Starting keyframe image. |
| `end_frame_image_url` | string (URL) | Ending keyframe image. |
| `reference_image_urls` | array of URL | Reference images. |
| `reference_video_urls` | array of URL | Reference videos (`mp4`). |
| `reference_audio_urls` | array of URL | Reference audio (`wav`, `mp3`, `ogg`, …). |
| **Media-token inputs** | | |
| `start_frame_image_media_token`, `end_frame_image_media_token`, `reference_image_media_tokens`, `reference_video_media_tokens`, `reference_audio_media_tokens` | token(s) | Use these instead if you've already uploaded media. |
#### Rules for URL inputs
- Each URL field and its media-token counterpart are **mutually exclusive**. Sending both
`reference_image_urls` and `reference_image_media_tokens`, for example, returns:
`Either reference_image_media_tokens or reference_image_urls must be set, not both`.
- Every URL must start with `http://` or `https://`, otherwise:
`URL must start with http:// or https://, bad URL: <url>`.
- Redirects are followed (up to 10), so CDN/redirecting URLs work.
- The file type is detected from the downloaded bytes. Images must be a real image
(`jpeg`/`png`/`gif`/`webp`), reference videos must be `mp4`, reference audio must be a
supported audio type (`wav`, `mp3`, `aac`, `ogg`, `flac`, …).
### Response
```json
{
"success": true,
"inference_job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"all_job_tokens": ["jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"]
}
```
Use `inference_job_token` to poll for the finished video. `all_job_tokens` lists every job created
(batch requests can create more than one).
---
## Examples
The examples reuse these public sample URLs:
- Images (Wikimedia Commons): an Atlantic puffin, the Matterhorn, a toco toucan
- Video: a short sample `mp4`
- Audio: a short sample `wav`
Replace `YOUR_API_KEY` with your `artcraft_api_…` secret, and generate a fresh `idempotency_token`
(UUID) for every request.
---
### Example 1 — Starting-frame Seedance (`start_frame_image_url`)
Animate outward from a single starting image.
#### bash
```bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900"
}'
```
#### python
```python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"start_frame_image_url": "https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
```
#### javascript
```javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"An Atlantic puffin stands on a windy cliff edge and turns its head as morning light catches its colorful beak.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
start_frame_image_url:
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
}),
});
console.log(resp.status, await resp.json());
```
---
### Example 2 — Image-reference Seedance (`reference_image_urls`)
Guide the generation with one or more reference images.
#### bash
```bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
]
}'
```
#### python
```python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
```
#### javascript
```javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"A whimsical journey: an Atlantic puffin perches on a cliff beneath the snow-capped Matterhorn while a brilliant toco toucan watches from a misty rainforest canopy.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
reference_image_urls: [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
}),
});
console.log(resp.status, await resp.json());
```
---
### Example 3 — Image + video reference Seedance (`reference_image_urls` + `reference_video_urls`)
Combine reference images with a reference video (and, optionally, reference audio). This is the
fullest URL-driven request.
#### bash
```bash
curl -s -X POST http://localhost:12345/v1/omni_api/generate/video \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": "'"$(uuidgen)"'",
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900"
],
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"]
}'
```
#### python
```python
import uuid
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
resp = requests.post(
f"{BASE_URL}/v1/omni_api/generate/video",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "seedance_2p0",
"prompt": "An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
"duration_seconds": 6,
"aspect_ratio": "wide_sixteen_by_nine",
"idempotency_token": str(uuid.uuid4()),
"reference_image_urls": [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
"reference_video_urls": ["https://download.samplelib.com/mp4/sample-5s.mp4"],
"reference_audio_urls": ["https://download.samplelib.com/wav/sample-6s.wav"],
},
timeout=240,
)
resp.raise_for_status()
print(resp.json())
```
#### javascript
```javascript
import { randomUUID } from "node:crypto";
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const resp = await fetch(`${BASE_URL}/v1/omni_api/generate/video`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance_2p0",
prompt:
"An Atlantic puffin stands on a windy cliff edge; the scene sweeps up to the snow-capped Matterhorn at sunrise before settling on a brilliant toco toucan in a misty rainforest canopy. The camera glides with the motion of the reference video, set to the mood of the reference audio.",
duration_seconds: 6,
aspect_ratio: "wide_sixteen_by_nine",
idempotency_token: randomUUID(),
reference_image_urls: [
"https://commons.wikimedia.org/wiki/Special:FilePath/Puffin_%28Fratercula_arctica%29.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Matterhorn_from_Domh%C3%BCtte_-_2.jpg?width=900",
"https://commons.wikimedia.org/wiki/Special:FilePath/Toco_toucan.jpg?width=900",
],
reference_video_urls: ["https://download.samplelib.com/mp4/sample-5s.mp4"],
reference_audio_urls: ["https://download.samplelib.com/wav/sample-6s.wav"],
}),
});
console.log(resp.status, await resp.json());
```
---
## Checking job status
Generation is asynchronous. The generate call returns an `inference_job_token`; poll the job-status
endpoint until the job reaches a terminal state, then read the result URL.
### Endpoints
- **Single job:** `GET /v1/omni_api/job_status/job/{token}`
- **Batch (many jobs):** `GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb`
Both require the same `Authorization: Bearer …` header (no cookies).
### Response shape
```json
{
"success": true,
"state": {
"job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "pending",
"maybe_first_started_at": null,
"maybe_failure_category": null,
"progress_percentage": 0
},
"maybe_result": null,
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:06:14Z"
}
}
```
- `state.status.status` — the job state. Keep polling while it's `pending`, `started`, or
`attempt_failed`. **Terminal** states are `complete_success`, `complete_failure`, `dead`,
`cancelled_by_user`, and `cancelled_by_system`.
- `state.status.progress_percentage` — an integer 0–100.
- `state.maybe_result` — `null` until the job succeeds. On `complete_success` it contains the
finished media; the playable/downloadable URL is **`state.maybe_result.media_links.cdn_url`**.
For video, `media_links.maybe_video_previews` also carries still/animated preview URLs.
The batch endpoint returns `{ "success": true, "job_states": [ … ] }` where each entry is the same
`state` payload as above.
### Where the result video / image lives
When `state.status.status` is `complete_success`, the finished media is under `state.maybe_result`:
- **`state.maybe_result.media_links.cdn_url`** — the primary, playable/downloadable URL of the
finished asset. **This is the field you want.** For a video job it's the `.mp4`; for an image job
it's the image file (`.jpg` / `.png` / etc.).
- `state.maybe_result.entity_token` — the `m_…` media-file token for the result.
- `state.maybe_result.media_links.maybe_video_previews` — **video jobs only**. Contains a `still`
(poster `.jpg`) and an `animated` (`.gif`) preview URL, plus `{WIDTH}` thumbnail templates. This
is `null` for image jobs.
- `state.maybe_result.media_links.maybe_thumbnail_template` — **image jobs only**. A URL template;
replace `{WIDTH}` with a pixel width to fetch a resized thumbnail. `null` for video jobs.
**Example — completed video job:**
```json
{
"success": true,
"state": {
"job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "complete_success",
"maybe_first_started_at": "2026-06-24T04:07:11Z",
"maybe_failure_category": null,
"progress_percentage": 100
},
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4",
"maybe_thumbnail_template": null,
"maybe_video_previews": {
"still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg",
"animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif",
"still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg",
"animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif"
}
},
"maybe_successfully_completed_at": "2026-06-24T04:17:30Z"
},
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:17:30Z"
}
}
```
→ The video is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4`
(example, not a live URL).
**Example — completed image job** (from `POST /v1/omni_api/generate/image`):
```json
{
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg",
"maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg",
"maybe_video_previews": null
},
"maybe_successfully_completed_at": "2026-06-24T04:09:02Z"
}
}
```
→ The image is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg`
(example, not a live URL).
### Poll until complete
#### bash
```bash
JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
while :; do
resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \
-H "Authorization: Bearer YOUR_API_KEY")
status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])")
echo "status=$status"
case "$status" in
complete_success)
echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])"
break ;;
complete_failure|dead|cancelled_by_user|cancelled_by_system)
echo "job ended without a result: $status"; break ;;
esac
sleep 5
done
```
#### python
```python
import time
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"}
while True:
resp = requests.get(
f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
resp.raise_for_status()
state = resp.json()["state"]
status = state["status"]["status"]
print("status:", status, state["status"]["progress_percentage"], "%")
if status == "complete_success":
print("result URL:", state["maybe_result"]["media_links"]["cdn_url"])
break
if status in TERMINAL_FAILURES:
print("job ended without a result:", status)
break
time.sleep(5)
```
#### javascript
```javascript
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx";
const TERMINAL_FAILURES = new Set([
"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system",
]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
while (true) {
const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { state } = await resp.json();
const status = state.status.status;
console.log("status:", status, state.status.progress_percentage, "%");
if (status === "complete_success") {
console.log("result URL:", state.maybe_result.media_links.cdn_url);
break;
}
if (TERMINAL_FAILURES.has(status)) {
console.log("job ended without a result:", status);
break;
}
await sleep(5000);
}
```
---
## Notes & troubleshooting
- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate.
- **No cookies.** The endpoint authenticates only via the `Authorization` header.
- **URL must be `http(s)`** and reachable by the server; redirects are followed.
- **Type mismatches** (e.g. a `.webm` passed as a reference video, which requires `mp4`) are
rejected with a `400` describing the unpermitted type.
- **`401 Unauthorized`** — missing/invalid API key, or the key's owner is banned.
- **`402 Payment required`** — insufficient credits/balance.
- There is a companion image endpoint, `POST /v1/omni_api/generate/image`, which accepts
`image_urls` (mutually exclusive with `image_media_tokens`) following the same rules.
@@ -1,2 +0,0 @@
pub mod omni_api_image_generate_request;
pub mod omni_api_video_generate_request;
@@ -1,58 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use tokens::tokens::media_files::MediaFileToken;
/// Request body for the Omni API image generation endpoint.
///
/// Forked from `OmniGenImageCostAndGenerateRequest` so the Omni API's request
/// shape can evolve independently without affecting the product-critical Omni
/// Gen endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniApiImageGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
/// If not supplied, we'll generate one for the required providers.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonImageModel>,
/// The prompt for the image generation.
pub prompt: Option<String>,
/// Input images for image editing.
/// If present, we're doing image editing (image-to-image).
/// If absent, we're doing text-to-image.
pub image_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `image_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub image_urls: Option<Vec<String>>,
/// The resolution to use.
pub resolution: Option<CommonResolution>,
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// The quality to use.
pub quality: Option<CommonQuality>,
/// How many images to generate.
pub image_batch_count: Option<u16>,
/// Only for angle manipulation models.
pub adjust_horizontal_angle: Option<f64>,
/// Only for angle manipulation models.
pub adjust_vertical_angle: Option<f64>,
/// Only for angle manipulation models.
pub adjust_zoom: Option<f64>,
}
@@ -1,96 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Request body for the Omni API video generation endpoint.
///
/// Forked from `OmniGenVideoCostAndGenerateRequest` so the Omni API's request
/// shape can evolve independently without affecting the product-critical Omni
/// Gen endpoints.
#[derive(Clone, Serialize, Deserialize, ToSchema, Debug)]
pub struct OmniApiVideoGenerateRequest {
/// REQUIRED (even if marked optional)
/// Idempotency token to prevent duplicate requests.
pub idempotency_token: Option<String>,
/// REQUIRED (even if marked optional)
/// Which model to use.
pub model: Option<CommonVideoModel>,
/// The text prompt for the video generation.
pub prompt: Option<String>,
/// Some models support negative text prompts.
pub negative_prompt: Option<String>,
/// Starting keyframe (optional).
pub start_frame_image_media_token: Option<MediaFileToken>,
/// URL alternative to `start_frame_image_media_token`. Mutually exclusive with it.
/// Downloaded and stored as a media file before generation.
pub start_frame_image_url: Option<String>,
/// Ending keyframe (optional).
pub end_frame_image_media_token: Option<MediaFileToken>,
/// URL alternative to `end_frame_image_media_token`. Mutually exclusive with it.
/// Downloaded and stored as a media file before generation.
pub end_frame_image_url: Option<String>,
/// Reference images (optional).
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `reference_image_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub reference_image_urls: Option<Vec<String>>,
/// Reference videos (optional).
pub reference_video_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `reference_video_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub reference_video_urls: Option<Vec<String>>,
/// Reference audio (optional).
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// URL alternative to `reference_audio_media_tokens`. Mutually exclusive with it.
/// Each URL is downloaded and stored as a media file before generation.
pub reference_audio_urls: Option<Vec<String>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// The resolution to use.
pub resolution: Option<CommonResolution>,
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// The output bitrate to use.
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// The quality to use.
pub quality: Option<CommonQuality>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
/// How many videos to generate.
pub video_batch_count: Option<u16>,
/// Whether to turn on/off audio.
/// Not all models support audio, not all models have a choice.
/// Some models will default this to true, others will default it to false,
/// so it's best to be explicit.
pub generate_audio: Option<bool>,
}
@@ -1,3 +0,0 @@
pub mod omni_api_batch_get_job_status;
pub mod omni_api_get_job_status;
pub mod omni_api_job_status_payload;
@@ -1,25 +0,0 @@
use std::collections::HashSet;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Query params for `GET /v1/omni_api/job_status/batch`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct OmniApiBatchGetJobStatusQueryParams {
/// A grab bag of job tokens. Each token family generally has its own prefix,
/// so a flat set is sufficient.
///
/// NB: handlers parse this with `actix_web_lab`'s `Query<T>` because the
/// default actix-web `Query<T>` doesn't decode repeated/sequence params yet.
/// See https://github.com/actix/actix-web/issues/1301
pub tokens: HashSet<String>,
}
/// Success body for `GET /v1/omni_api/job_status/batch`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiBatchGetJobStatusSuccessResponse {
pub success: bool,
pub job_states: Vec<OmniApiJobStatusPayload>,
}
@@ -1,19 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Path params for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusPathInfo {
pub token: InferenceJobToken,
}
/// Success body for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusSuccessResponse {
pub success: bool,
pub state: OmniApiJobStatusPayload,
}
@@ -1,69 +0,0 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
/// Full status payload for a single inference job.
///
/// Shared by both the single (`/v1/omni_api/job_status/job/{token}`) and batch
/// (`/v1/omni_api/job_status/batch`) Omni API status endpoints. Forked from the
/// cookie-authenticated `/v1/jobs/*` response types so the Omni API surface can
/// evolve independently.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusPayload {
pub job_token: InferenceJobToken,
pub request: OmniApiJobRequestDetails,
pub status: OmniApiJobStatusDetails,
pub maybe_result: Option<OmniApiJobResultDetails>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Details about what the user requested for generation.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobRequestDetails {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
}
/// Details about the ongoing job status.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusDetails {
/// Primary status from the database (a state machine).
pub status: JobStatusPlus,
pub maybe_first_started_at: Option<DateTime<Utc>>,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category: Option<FrontendFailureCategory>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
pub progress_percentage: u8,
}
/// Details about the completed result (if any).
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobResultDetails {
pub entity_type: String,
pub entity_token: String,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
@@ -1,5 +1,3 @@
pub mod generate_requests;
pub mod job_status;
pub mod omni_upload_audio;
pub mod omni_upload_image;
pub mod omni_upload_video;
@@ -35,41 +35,6 @@ pub struct PollOrdersResponse {
// --- Public types ---
/// The status of one order (one generation task — video or image).
#[derive(Debug, Clone)]
pub struct OrderStatus {
pub order_id: String,
pub task_status: TaskStatus,
/// Top-level result URL (video file for video orders, the first image of
/// the 4-image grid for Midjourney image orders). Populated when
/// `task_status` is `Completed`.
pub result_url: Option<String>,
/// Detailed result entries. One entry per video frame (video orders), or
/// four entries per Midjourney task (image orders).
pub results: Vec<MediaResult>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
/// Whether this order produced an image or a video. `None` for older
/// polling responses that didn't include the field — those came from the
/// video-only era and can be treated as video by callers that need to.
pub media_type: Option<OrderMediaType>,
/// The Kinovi credits charged for the order (the API's `totalCredits`).
/// `None` for older polling responses that didn't include the field.
pub total_credits: Option<u32>,
}
/// The lifecycle status of a video generation task.
#[derive(Debug, Clone, PartialEq)]
pub enum TaskStatus {
@@ -105,18 +70,19 @@ impl TaskStatus {
/// orders, or one of Midjourney's 4 generated images for image orders.
/// (Originally named for the video-only days; the underlying shape is shared.)
#[derive(Debug, Clone)]
pub struct MediaResult {
pub struct VideoResult {
pub url: String,
/// Frame width in pixels. `None` when the API returns `null`/omits it
/// (it does so intermittently); callers should persist NULL, not a sentinel.
pub maybe_width: Option<u32>,
/// Frame height in pixels. `None` when the API returns `null`/omits it.
pub maybe_height: Option<u32>,
pub width: u32,
pub height: u32,
// NB: We don't need these.
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null.
// /// Width / height ratio (e.g. 1.777… for 16:9). `None` when the server returns null (e.g. width/height are 0).
// pub ratio: Option<f64>,
}
/// Type-neutral alias for [`VideoResult`]. Prefer this name in new
/// callers that deal with both image and video orders.
pub type MediaResult = VideoResult;
/// Media type of an order. Midjourney orders are `Image`; the various
/// Seedance/keyframe/reference flows are `Video`.
///
@@ -142,6 +108,41 @@ impl OrderMediaType {
pub fn is_video(&self) -> bool { matches!(self, Self::Video) }
}
/// The status of one order (one generation task — video or image).
#[derive(Debug, Clone)]
pub struct OrderStatus {
pub order_id: String,
pub task_status: TaskStatus,
/// Top-level result URL (video file for video orders, the first image of
/// the 4-image grid for Midjourney image orders). Populated when
/// `task_status` is `Completed`.
pub result_url: Option<String>,
/// Detailed result entries. One entry per video frame (video orders), or
/// four entries per Midjourney task (image orders).
pub results: Vec<VideoResult>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
/// Whether this order produced an image or a video. `None` for older
/// polling responses that didn't include the field — those came from the
/// video-only era and can be treated as video by callers that need to.
pub media_type: Option<OrderMediaType>,
/// The Kinovi credits charged for the order (the API's `totalCredits`).
/// `None` for older polling responses that didn't include the field.
pub total_credits: Option<u32>,
}
// --- Implementation ---
pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse, Seedance2ProError> {
@@ -233,10 +234,10 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| MediaResult {
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
maybe_width: r.maybe_width,
maybe_height: r.maybe_height,
width: r.width,
height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
@@ -424,8 +425,8 @@ mod tests {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| MediaResult {
url: r.url, maybe_width: r.maybe_width, maybe_height: r.maybe_height,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url, width: r.width, height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
@@ -475,52 +476,11 @@ mod tests {
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].results.len(), 1);
assert_eq!(orders[0].results[0].maybe_width, Some(569));
assert_eq!(orders[0].results[0].maybe_height, Some(1023));
assert_eq!(orders[0].results[0].width, 569);
assert_eq!(orders[0].results[0].height, 1023);
assert_eq!(orders[0].total_credits, Some(13));
}
/// Regression (paging outage): the Kinovi API started returning result
/// `width`/`height` as `null`, which blew up the whole poll with
/// "invalid type: null, expected a JSON number" and stalled the queue.
/// They must parse as `None` and let the rest of the response parse.
#[test]
fn null_dimensions_parse_as_none() {
let body = r#"[{"result":{"data":{"json":{"orders":[{
"orderId":"ord_foo",
"resultUrl":"https://static.seedance2-pro.com/videos/seedance_video.mp4",
"taskStatus":"COMPLETED",
"results":[{"url":"https://static.seedance2-pro.com/videos/seedance_video.mp4","width":null,"height":null}],
"failReason":null,
"createdAt":"2026-02-19T01:20:50.398Z",
"mediaType":"video"
}],"nextCursor":null}}}}]"#;
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].results.len(), 1);
assert_eq!(orders[0].results[0].maybe_width, None);
assert_eq!(orders[0].results[0].maybe_height, None);
assert_eq!(orders[0].task_status, TaskStatus::Completed);
}
/// Omitted `width`/`height` keys (not just `null`) also parse as `None`.
#[test]
fn missing_dimensions_parse_as_none() {
let body = r#"[{"result":{"data":{"json":{"orders":[{
"orderId":"ord_no_dims",
"resultUrl":"https://example.com/v.mp4",
"taskStatus":"COMPLETED",
"results":[{"url":"https://example.com/v.mp4"}],
"failReason":null,
"createdAt":"2026-02-19T01:20:50.398Z",
"mediaType":"video"
}],"nextCursor":null}}}}]"#;
let orders = parse_orders(body);
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].results[0].maybe_width, None);
assert_eq!(orders[0].results[0].maybe_height, None);
}
/// Unrecognised mediaType values shouldn't crash deserialisation;
/// they end up as `Unknown(_)`.
#[test]
@@ -674,84 +634,4 @@ mod tests {
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
/// Live diagnostic for the paging outage: try EACH Kinovi account's cookies
/// from the job's secrets env file and cursor across several pages, reporting
/// which account(s) parse cleanly and which blow up. Never prints cookies.
///
/// cargo test -p seedance2pro_client live_cursor_all_accounts -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn live_cursor_all_accounts() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
const SECRETS_ENV: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../crates/service/job/seedance2_pro_job/config/seedance2-pro-job.development-secrets.env",
);
const ACCOUNTS: &[&str] = &[
"SEEDANCE2PRO_VOLCENGINE_COOKIES",
"SEEDANCE2PRO_BYTEPLUS_COOKIES",
"SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES",
];
const MAX_PAGES: usize = 5;
let env_contents = std::fs::read_to_string(SECRETS_ENV)
.unwrap_or_else(|e| panic!("could not read secrets env at {}: {}", SECRETS_ENV, e));
for account in ACCOUNTS {
let Some(cookies) = read_env_value(&env_contents, account) else {
println!("[{}] not present in secrets env — skipping", account);
continue;
};
if cookies.is_empty() {
println!("[{}] empty — skipping", account);
continue;
}
let session = Seedance2ProSession::from_cookies_string(cookies);
let mut cursor: Option<u64> = None;
let mut pages = 0usize;
let mut total = 0usize;
let mut ok = true;
for _ in 0..MAX_PAGES {
match poll_orders(PollOrdersArgs { session: &session, cursor, host_override: None }).await {
Ok(result) => {
pages += 1;
total += result.orders.len();
cursor = result.next_cursor;
if cursor.is_none() { break; }
}
Err(err) => {
ok = false;
println!("[{}] FAILED on page {}: {:?}", account, pages + 1, err);
break;
}
}
}
println!(
"[{}] {} — {} page(s), {} order(s) parsed cleanly",
account, if ok { "OK" } else { "ERROR" }, pages, total,
);
}
Ok(())
}
/// Minimal dotenv value reader: returns the value for `KEY=...` from raw env
/// file contents, trimming surrounding quotes. Used so the diagnostic can try
/// each account without depending on process-wide env being loaded.
fn read_env_value(contents: &str, key: &str) -> Option<String> {
for line in contents.lines() {
let line = line.trim();
if line.starts_with('#') { continue; }
let Some((k, v)) = line.split_once('=') else { continue; };
if k.trim() != key { continue; }
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
return Some(v);
}
None
}
}
@@ -1,7 +1,7 @@
use serde_derive::Deserialize;
use crate::utils::number_coercion::{
de_opt_u32_int_or_float, de_opt_u64_int_or_float,
de_opt_u32_int_or_float, de_opt_u64_int_or_float, de_u32_int_or_float,
};
#[derive(Deserialize, Debug)]
@@ -51,11 +51,9 @@ pub(super) struct RawOrder {
#[derive(Deserialize, Debug)]
pub(super) struct RawVideoResult {
pub url: String,
/// The API intermittently returns `null` (or omits) these dimensions, so
/// they're optional rather than coerced to a sentinel.
#[serde(rename = "width", default, deserialize_with = "de_opt_u32_int_or_float")]
pub maybe_width: Option<u32>,
#[serde(rename = "height", default, deserialize_with = "de_opt_u32_int_or_float")]
pub maybe_height: Option<u32>,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub width: u32,
#[serde(deserialize_with = "de_u32_int_or_float")]
pub height: u32,
// pub ratio: Option<f64>,
}
@@ -60,8 +60,8 @@ fn order_to_json(order: &seedance2pro_client::requests::poll_orders::poll_orders
"results": order.results.iter().map(|r| {
serde_json::json!({
"url": r.url,
"width": r.maybe_width,
"height": r.maybe_height,
"width": r.width,
"height": r.height,
})
}).collect::<Vec<_>>(),
})
@@ -1,4 +1,3 @@
use chrono::{DateTime, Utc};
use log::warn;
use sqlx::MySqlPool;
@@ -30,13 +29,9 @@ pub struct PendingSeedance2ProJob {
pub maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
pub maybe_platform_type: Option<PlatformType>,
/// When the job row was created. Useful for surfacing how long the oldest
/// pending job has been waiting.
pub created_at: DateTime<Utc>,
}
#[derive(Debug)]
#[derive(Debug, Default)]
struct RawRecord {
job_token: InferenceJobToken,
order_id: Option<String>,
@@ -47,7 +42,6 @@ struct RawRecord {
maybe_prompt_token: Option<PromptToken>,
maybe_wallet_ledger_entry_token: Option<WalletLedgerEntryToken>,
maybe_platform_type: Option<PlatformType>,
created_at: DateTime<Utc>,
}
/// Returns all non-terminal Seedance2Pro video jobs that have an associated order_id.
@@ -68,8 +62,7 @@ SELECT
jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`,
jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`,
jobs.created_at
jobs.platform_type as `maybe_platform_type: enums::common::platform_type::PlatformType`
FROM generic_inference_jobs as jobs
@@ -107,7 +100,6 @@ LIMIT 25000
maybe_prompt_token: record.maybe_prompt_token,
maybe_wallet_ledger_entry_token: record.maybe_wallet_ledger_entry_token,
maybe_platform_type: record.maybe_platform_type,
created_at: record.created_at,
})
})
.collect();
@@ -142,10 +142,6 @@ async fn walk_orders(
.map(|job| (job.order_id.clone(), job))
.collect();
let pending_db_job_count = job_by_order_id.len();
let oldest_pending_created_at = job_by_order_id.values().map(|job| job.created_at).min();
let iteration_start = Instant::now();
let mut result = WalkResult::default();
let mut cursor: Option<u64> = None;
let mut pages_since_head_recheck: u32 = 0;
@@ -156,34 +152,9 @@ async fn walk_orders(
break;
}
// Log the iteration state up front (before the poll) so that, if the poll
// itself fails, we still have the surrounding context in the logs.
let iteration_elapsed = iteration_start.elapsed();
let pending_db_jobs_display = match oldest_pending_created_at {
Some(oldest) if pending_db_job_count > 0 => format!(
"{} (oldest: {} ago)",
pending_db_job_count,
format_duration_ago(Utc::now() - oldest),
),
_ => pending_db_job_count.to_string(),
};
info!(
"Requesting Kinovi page:\n \
kinovi page: {page}\n \
kinovi cursor: {cursor:?}\n \
orders seen so far: {orders_seen}\n \
pending DB jobs: {pending_db}\n \
reconciler backlog: {backlog} order(s) awaiting processing\n \
iteration elapsed: {secs}s ({millis}ms)",
page = result.pages_seen,
cursor = cursor,
orders_seen = result.orders_seen,
pending_db = pending_db_jobs_display,
backlog = deps.order_reconciler.len(),
secs = iteration_elapsed.as_secs(),
millis = iteration_elapsed.as_millis(),
"Requesting Kinovi page {} (cursor: {:?}, orders seen so far: {})",
result.pages_seen, cursor, result.orders_seen,
);
let response = poll_orders_with_retry(deps, cursor).await?;
@@ -319,17 +290,6 @@ fn stage_finished_orders(
PageStageSummary { staged }
}
/// Format a positive elapsed duration as `"{h} hours {m} minutes {s} seconds"`
/// (hours are unbounded; e.g. a 50-hour-old job reads `"50 hours ..."`).
/// Negative durations clamp to zero.
fn format_duration_ago(elapsed: chrono::Duration) -> String {
let total_seconds = elapsed.num_seconds().max(0);
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{} hours {} minutes {} seconds", hours, minutes, seconds)
}
/// Poll orders from Kinovi with retries. On transient failures, waits with
/// increasing delay (attempt × 2s, capped at `poll_retry_max_delay_millis`).
/// After exhausting retries, alerts the pager and returns an error.
@@ -14,7 +14,7 @@ use mysql_queries::queries::generic_inference::api_providers::seedance2pro::list
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{mark_generic_inference_job_successfully_done_by_token_with_executor, MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, MediaResult};
use seedance2pro_client::requests::poll_orders::poll_orders::{OrderStatus, VideoResult};
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -175,7 +175,7 @@ async fn download_and_store_one_image(
deps: &JobDependencies,
job: &PendingSeedance2ProJob,
order: &OrderStatus,
result: &MediaResult,
result: &VideoResult,
index: usize,
maybe_batch_token: Option<&BatchGenerationToken>,
) -> AnyhowResult<MediaFileToken> {
@@ -224,8 +224,8 @@ async fn download_and_store_one_image(
.maybe_batch_generation_token(maybe_batch_token)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_frame_height(result.maybe_height)
.maybe_frame_width(result.maybe_width)
.maybe_frame_height(Some(result.height))
.maybe_frame_width(Some(result.width))
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.maybe_platform_type(job.maybe_platform_type)
@@ -118,9 +118,8 @@ pub async fn process_successful_video_job(
);
// Optionally extract frame dimensions from the order results.
// Persist the integer dimensions when the API provides them, NULL otherwise.
let maybe_frame_width = order.results.first().and_then(|r| r.maybe_width);
let maybe_frame_height = order.results.first().and_then(|r| r.maybe_height);
let maybe_frame_width = order.results.first().map(|r| r.width);
let maybe_frame_height = order.results.first().map(|r| r.height);
// Insert media file record.
let media_file_result = MediaFileInsertBuilder::new()
@@ -339,8 +339,6 @@ use artcraft_api_defs::characters::edit_character::*;
use artcraft_api_defs::characters::get_character::*;
use artcraft_api_defs::characters::list_characters::*;
use artcraft_api_defs::moderation::alerts::moderation_send_alert::*;
use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::*;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::*;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::*;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::*;
use artcraft_api_defs::omni_gen::cost_response::omni_gen_image_cost_response::*;
@@ -349,9 +347,6 @@ use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_resp
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*;
use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*;
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*;
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::*;
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::*;
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::*;
use artcraft_api_defs::omni_api::omni_upload_audio::*;
use artcraft_api_defs::omni_api::omni_upload_image::*;
use artcraft_api_defs::omni_api::omni_upload_video::*;
@@ -576,8 +571,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler,
crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler,
crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler,
crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler,
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
// Moderation
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
@@ -831,14 +824,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GetCharacterResponse,
ListCharactersEntry,
ListCharactersResponse,
OmniApiBatchGetJobStatusSuccessResponse,
OmniApiGetJobStatusSuccessResponse,
OmniApiImageGenerateRequest,
OmniApiJobRequestDetails,
OmniApiJobResultDetails,
OmniApiJobStatusDetails,
OmniApiJobStatusPayload,
OmniApiVideoGenerateRequest,
OmniGenImageCostAndGenerateRequest,
OmniGenImageCostResponse,
OmniGenImageGenerateResponse,
@@ -1,21 +0,0 @@
use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::shared_utils::check_url_inputs::{check_mutually_exclusive, check_url_format};
/// Validate the URL/media-token preconditions before any billable or
/// DB-mutating work:
/// 1. A media-token field and its paired URL field are mutually exclusive.
/// 2. Every supplied URL starts with `http://` or `https://`.
pub fn check_request(request: &OmniApiImageGenerateRequest) -> Result<(), CommonWebError> {
check_mutually_exclusive(
"image_media_tokens",
request.image_media_tokens.is_some(),
"image_urls",
request.image_urls.is_some(),
)?;
check_url_format(request.image_urls.as_deref())?;
Ok(())
}
@@ -1,4 +1,4 @@
use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
use artcraft_router::api::router_image_model::RouterImageModel;
use artcraft_router::api::router_quality::RouterQuality;
@@ -15,7 +15,7 @@ use enums::common::generation::common_resolution::CommonResolution as CommonReso
use crate::http_server::common_responses::common_web_error::CommonWebError;
pub fn hydrate_to_router_request(
request: &OmniApiImageGenerateRequest,
request: &OmniGenImageCostAndGenerateRequest,
) -> Result<GenerateImageRequestBuilder, CommonWebError> {
let api_model = request.model.as_ref()
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage(
@@ -1,31 +0,0 @@
use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::shared_utils::media_uploads::upload_url_to_media_file::{upload_url_to_media_file, MediaUploadKind};
use crate::state::server_state::ServerState;
/// Download any URL image inputs, store them as media files owned by the API
/// user, and fold the resulting tokens into the request's media-token fields so
/// the rest of the handler can treat them as ordinary media tokens.
///
/// `check_request` has already guaranteed the URL and media-token fields are
/// mutually exclusive, so populating the token field here is always safe.
pub async fn ingest_url_inputs(
request: &mut OmniApiImageGenerateRequest,
server_state: &ServerState,
user_token: &UserToken,
ip_address: &str,
) -> Result<(), CommonWebError> {
if let Some(urls) = request.image_urls.take() {
let mut tokens = Vec::with_capacity(urls.len());
for url in &urls {
tokens.push(
upload_url_to_media_file(server_state, user_token, ip_address, url, MediaUploadKind::Image).await?,
);
}
request.image_media_tokens = Some(tokens);
}
Ok(())
}
@@ -1,7 +1,5 @@
pub mod omni_api_image_generate_handler;
pub(crate) mod check_request;
pub(crate) mod hydrate_to_router_request;
pub(crate) mod ingest_url_inputs;
pub(crate) mod insert_db_job;
pub(crate) mod pipeline_result;
pub mod pipeline_v2;
@@ -5,7 +5,7 @@ use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use artcraft_api_defs::omni_api::generate_requests::omni_api_image_generate_request::OmniApiImageGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
@@ -28,9 +28,7 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::omni_api::generate::image::check_request::check_request;
use crate::http_server::endpoints::omni_api::generate::image::hydrate_to_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_api::generate::image::ingest_url_inputs::ingest_url_inputs;
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
@@ -46,7 +44,7 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_medi
post,
tag = "Omni API",
path = "/v1/omni_api/generate/image",
request_body = OmniApiImageGenerateRequest,
request_body = OmniGenImageCostAndGenerateRequest,
responses(
(status = 200, description = "Success", body = OmniGenImageGenerateResponse),
(status = 400, description = "Bad input"),
@@ -57,15 +55,12 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_medi
)]
pub async fn omni_api_image_generate_handler(
http_request: HttpRequest,
mut request: Json<OmniApiImageGenerateRequest>,
request: Json<OmniGenImageCostAndGenerateRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenImageGenerateResponse>, CommonWebError> {
info!("request: {:?}", request);
// Validate URL/media-token preconditions before any billable or DB-mutating work.
check_request(&request)?;
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
let debug_log_event_token = DebugLogEventToken::generate();
@@ -105,19 +100,6 @@ pub async fn omni_api_image_generate_handler(
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
let ip_address = get_request_ip(&http_request);
// ==================== INGEST URL INPUTS ==================== //
// Download any URL image inputs into media files owned by this user, then
// treat them as media tokens. Release the pooled connection first so we don't
// hold a pool slot during the (network) downloads.
if request.image_urls.is_some() {
drop(mysql_connection);
ingest_url_inputs(&mut request, &server_state, user_token, &ip_address).await?;
mysql_connection = server_state.mysql_pool.acquire().await?;
}
// ==================== RESOLVE MEDIA TOKENS ==================== //
// Look up media file tokens BEFORE distilling. Pipeline execution should not do I/O.
@@ -180,6 +162,7 @@ pub async fn omni_api_image_generate_handler(
// ==================== WRITE RESULT ==================== //
let ip_address = get_request_ip(&http_request);
// Omni API requests are always API-key authenticated; hardcode the platform type.
let maybe_platform_type = Some(PlatformType::ApiKey);
@@ -1,53 +0,0 @@
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::shared_utils::check_url_inputs::{check_mutually_exclusive, check_single_url_format, check_url_format};
/// Validate the URL/media-token preconditions before any billable or
/// DB-mutating work:
/// 1. Each media-token field and its paired URL field are mutually exclusive.
/// 2. Every supplied URL starts with `http://` or `https://`.
pub fn check_request(request: &OmniApiVideoGenerateRequest) -> Result<(), CommonWebError> {
check_mutually_exclusive(
"start_frame_image_media_token",
request.start_frame_image_media_token.is_some(),
"start_frame_image_url",
request.start_frame_image_url.is_some(),
)?;
check_mutually_exclusive(
"end_frame_image_media_token",
request.end_frame_image_media_token.is_some(),
"end_frame_image_url",
request.end_frame_image_url.is_some(),
)?;
check_mutually_exclusive(
"reference_image_media_tokens",
request.reference_image_media_tokens.is_some(),
"reference_image_urls",
request.reference_image_urls.is_some(),
)?;
check_mutually_exclusive(
"reference_video_media_tokens",
request.reference_video_media_tokens.is_some(),
"reference_video_urls",
request.reference_video_urls.is_some(),
)?;
check_mutually_exclusive(
"reference_audio_media_tokens",
request.reference_audio_media_tokens.is_some(),
"reference_audio_urls",
request.reference_audio_urls.is_some(),
)?;
if let Some(url) = request.start_frame_image_url.as_deref() {
check_single_url_format(url)?;
}
if let Some(url) = request.end_frame_image_url.as_deref() {
check_single_url_format(url)?;
}
check_url_format(request.reference_image_urls.as_deref())?;
check_url_format(request.reference_video_urls.as_deref())?;
check_url_format(request.reference_audio_urls.as_deref())?;
Ok(())
}
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_router::api::audio_list_ref::AudioListRef;
use artcraft_router::api::character_list_ref::CharacterListRef;
use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
@@ -18,7 +18,7 @@ use enums::common::generation::common_resolution::CommonResolution as CommonReso
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
pub fn hydrate_to_router_request(
request: &OmniApiVideoGenerateRequest,
request: &OmniGenVideoCostAndGenerateRequest,
) -> Result<GenerateVideoRequestBuilder, CommonWebError> {
let api_model = request.model
.as_ref()
@@ -124,7 +124,7 @@ mod tests {
#[test]
fn high_is_hydrated() {
let request = OmniApiVideoGenerateRequest {
let request = OmniGenVideoCostAndGenerateRequest {
bitrate: Some(CommonBitrateEnum::High),
..base_request()
};
@@ -134,7 +134,7 @@ mod tests {
#[test]
fn normal_is_hydrated() {
let request = OmniApiVideoGenerateRequest {
let request = OmniGenVideoCostAndGenerateRequest {
bitrate: Some(CommonBitrateEnum::Normal),
..base_request()
};
@@ -149,22 +149,17 @@ mod tests {
}
}
fn base_request() -> OmniApiVideoGenerateRequest {
OmniApiVideoGenerateRequest {
fn base_request() -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: None,
model: Some(CommonVideoModelEnum::Seedance2p0),
prompt: None,
negative_prompt: None,
start_frame_image_media_token: None,
start_frame_image_url: None,
end_frame_image_media_token: None,
end_frame_image_url: None,
reference_image_media_tokens: None,
reference_image_urls: None,
reference_video_media_tokens: None,
reference_video_urls: None,
reference_audio_media_tokens: None,
reference_audio_urls: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
@@ -1,63 +0,0 @@
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::shared_utils::media_uploads::upload_url_to_media_file::{upload_url_to_media_file, MediaUploadKind};
use crate::state::server_state::ServerState;
/// Download any URL media inputs, store them as media files owned by the API
/// user, and fold the resulting tokens into the request's media-token fields so
/// the rest of the handler can treat them as ordinary media tokens.
///
/// `check_request` has already guaranteed each URL and media-token field is
/// mutually exclusive, so populating the token field here is always safe.
pub async fn ingest_url_inputs(
request: &mut OmniApiVideoGenerateRequest,
server_state: &ServerState,
user_token: &UserToken,
ip_address: &str,
) -> Result<(), CommonWebError> {
if let Some(url) = request.start_frame_image_url.take() {
request.start_frame_image_media_token = Some(
upload_url_to_media_file(server_state, user_token, ip_address, &url, MediaUploadKind::Image).await?,
);
}
if let Some(url) = request.end_frame_image_url.take() {
request.end_frame_image_media_token = Some(
upload_url_to_media_file(server_state, user_token, ip_address, &url, MediaUploadKind::Image).await?,
);
}
if let Some(urls) = request.reference_image_urls.take() {
request.reference_image_media_tokens =
Some(upload_all(server_state, user_token, ip_address, &urls, MediaUploadKind::Image).await?);
}
if let Some(urls) = request.reference_video_urls.take() {
request.reference_video_media_tokens =
Some(upload_all(server_state, user_token, ip_address, &urls, MediaUploadKind::Video).await?);
}
if let Some(urls) = request.reference_audio_urls.take() {
request.reference_audio_media_tokens =
Some(upload_all(server_state, user_token, ip_address, &urls, MediaUploadKind::Audio).await?);
}
Ok(())
}
async fn upload_all(
server_state: &ServerState,
user_token: &UserToken,
ip_address: &str,
urls: &[String],
kind: MediaUploadKind,
) -> Result<Vec<MediaFileToken>, CommonWebError> {
let mut tokens = Vec::with_capacity(urls.len());
for url in urls {
tokens.push(upload_url_to_media_file(server_state, user_token, ip_address, url, kind).await?);
}
Ok(tokens)
}
@@ -1,7 +1,5 @@
pub mod omni_api_video_generate_handler;
pub mod check_request;
pub mod helpers;
pub mod ingest_url_inputs;
pub mod pipeline_v2;
pub mod insert_db_job;
pub mod kinovi_account;
@@ -6,7 +6,7 @@ use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
@@ -32,8 +32,6 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::omni_api::generate::video::check_request::check_request;
use crate::http_server::endpoints::omni_api::generate::video::ingest_url_inputs::ingest_url_inputs;
use crate::http_server::endpoints::omni_api::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_kinovi_character_ids::resolve_kinovi_character_ids;
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
@@ -55,7 +53,7 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_medi
post,
tag = "Omni API",
path = "/v1/omni_api/generate/video",
request_body = OmniApiVideoGenerateRequest,
request_body = OmniGenVideoCostAndGenerateRequest,
responses(
(status = 200, description = "Success", body = OmniGenVideoGenerateResponse),
(status = 400, description = "Bad input"),
@@ -66,15 +64,12 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_medi
)]
pub async fn omni_api_video_generate_handler(
http_request: HttpRequest,
mut request: Json<OmniApiVideoGenerateRequest>,
request: Json<OmniGenVideoCostAndGenerateRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenVideoGenerateResponse>, CommonWebError> {
info!("request: {:?}", request);
// Validate URL/media-token preconditions before any billable or DB-mutating work.
check_request(&request)?;
// Reject doomed combos (e.g. grok_imagine_video_1p5 without an image)
// before any billable or DB-mutating work — see helper for the rules.
validate_video_request(&request)?;
@@ -118,25 +113,6 @@ pub async fn omni_api_video_generate_handler(
CommonWebError::BadInputWithSimpleMessage("repeated idempotency token".to_string())
})?;
let ip_address = get_request_ip(&http_request);
// ==================== INGEST URL INPUTS ==================== //
// Download any URL media inputs into media files owned by this user, then
// treat them as media tokens. Release the pooled connection first so we don't
// hold a pool slot during the (network) downloads.
let has_url_inputs = request.start_frame_image_url.is_some()
|| request.end_frame_image_url.is_some()
|| request.reference_image_urls.is_some()
|| request.reference_video_urls.is_some()
|| request.reference_audio_urls.is_some();
if has_url_inputs {
drop(mysql_connection);
ingest_url_inputs(&mut request, &server_state, user_token, &ip_address).await?;
mysql_connection = server_state.mysql_pool.acquire().await?;
}
// ==================== RESOLVE MEDIA TOKENS ==================== //
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
@@ -262,6 +238,7 @@ pub async fn omni_api_video_generate_handler(
// ==================== WRITE RESULT ==================== //
let ip_address = get_request_ip(&http_request);
// Omni API requests are always API-key authenticated; hardcode the platform type.
let maybe_platform_type = Some(PlatformType::ApiKey);
@@ -477,7 +454,7 @@ pub async fn omni_api_video_generate_handler(
}))
}
fn determine_generation_mode(request: &OmniApiVideoGenerateRequest) -> CommonGenerationMode {
fn determine_generation_mode(request: &OmniGenVideoCostAndGenerateRequest) -> CommonGenerationMode {
let has_keyframe = request.start_frame_image_media_token.is_some()
|| request.end_frame_image_media_token.is_some();
@@ -1,127 +0,0 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web_lab::extract::Query;
use log::{error, warn};
use redis::Commands;
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::{OmniApiBatchGetJobStatusQueryParams, OmniApiBatchGetJobStatusSuccessResponse};
use mysql_queries::queries::generic_inference::web::batch_get_inference_job_status::batch_get_inference_job_status;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use redis_common::redis_keys::RedisKeys;
use server_environment::ServerEnvironment;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::state::server_state::ServerState;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
/// navigate away. Premium users have accounts and can always return to the site, so they
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
/// Get job statuses for a batch of multiple job tokens (Omni API, API-key authenticated).
///
/// Mirrors `/v1/jobs/batch` but reads the caller's identity from the
/// `Authorization` header API key instead of a session cookie.
#[utoipa::path(
get,
tag = "Omni API",
path = "/v1/omni_api/job_status/batch",
responses(
(status = 200, body = OmniApiBatchGetJobStatusSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 500),
),
params(
OmniApiBatchGetJobStatusQueryParams
)
)]
pub async fn omni_api_batch_get_job_status_handler(
http_request: HttpRequest,
query: Query<OmniApiBatchGetJobStatusQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniApiBatchGetJobStatusSuccessResponse>, CommonWebError> {
// ==================== API KEY USER ==================== //
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
// lookups below so we don't hold a pool slot idle.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
require_api_key_user(&http_request, &mut *mysql_connection).await?;
drop(mysql_connection);
let tokens = query.tokens.iter()
.map(|token| token.trim())
// NB: A bunch of Python clients use our API and can fail in this manner.
.filter(|token| !token.is_empty() && *token != "None")
.map(|token| InferenceJobToken::new_from_str(token))
.collect::<Vec<_>>();
if tokens.is_empty() {
// NB: MediaDomain doesn't matter since it's an empty list.
return Ok(records_to_response(
Vec::new(),
server_state.server_environment,
MediaDomain::Storyteller,
));
}
let records = batch_get_inference_job_status(
&tokens,
&server_state.mysql_pool,
).await
.map_err(|err| {
warn!("Batch job query error: {:?}", err);
CommonWebError::from_anyhow_error(err)
})?;
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
CommonWebError::from_error(e)
})?;
// TODO(bt,2024-04-22): Look up the extra redis statuses per item.
let keepalive_keys = records.iter()
.filter(|record| record.is_keepalive_required)
.map(|record| RedisKeys::generic_inference_keepalive(record.job_token.as_str()))
.collect::<Vec<_>>();
for key in keepalive_keys.iter() {
// TODO(bt,2024-04-22): There is no msetex. We'll need to run a Redis pipeline here.
let _: Option<String> = match redis.set_ex(key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
error!("redis error setting job keepalive: {:?}", e);
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
}
};
}
let media_domain = get_media_domain(&http_request);
Ok(records_to_response(records, server_state.server_environment, media_domain))
}
fn records_to_response(
records: Vec<GenericInferenceJobStatus>,
server_environment: ServerEnvironment,
media_domain: MediaDomain,
) -> Json<OmniApiBatchGetJobStatusSuccessResponse> {
Json(OmniApiBatchGetJobStatusSuccessResponse {
success: true,
job_states: records.into_iter()
.map(|record| record_to_payload(record, server_environment, media_domain))
.collect::<Vec<_>>(),
})
}
@@ -1,107 +0,0 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::{error, warn};
use redis::Commands;
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::{OmniApiGetJobStatusPathInfo, OmniApiGetJobStatusSuccessResponse};
use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status;
use redis_common::redis_keys::RedisKeys;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::state::server_state::ServerState;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
/// navigate away. Premium users have accounts and can always return to the site, so they
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
/// Get the status for a single job (Omni API, API-key authenticated).
///
/// Mirrors `/v1/jobs/job/{token}` but reads the caller's identity from the
/// `Authorization` header API key instead of a session cookie.
#[utoipa::path(
get,
tag = "Omni API",
path = "/v1/omni_api/job_status/job/{token}",
params(
("path" = OmniApiGetJobStatusPathInfo, description = "Path params for Request")
),
responses(
(status = 200, body = OmniApiGetJobStatusSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 404),
(status = 500),
),
)]
pub async fn omni_api_get_job_status_handler(
http_request: HttpRequest,
path: Path<OmniApiGetJobStatusPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniApiGetJobStatusSuccessResponse>, CommonWebError> {
// ==================== API KEY USER ==================== //
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
// lookups below so we don't hold a pool slot idle.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
require_api_key_user(&http_request, &mut *mysql_connection).await?;
drop(mysql_connection);
if path.token.as_str().trim() == "None" {
// NB: A bunch of Python clients use our API and can fail in this manner.
return Err(CommonWebError::NotFound);
}
let maybe_status = get_inference_job_status(
&path.token,
&server_state.mysql_pool,
).await;
let record = match maybe_status {
Ok(Some(record)) => record,
Ok(None) => return Err(CommonWebError::NotFound),
Err(err) => {
warn!("Job query error for token {}: {:?}", path.token.as_str(), err);
return Err(CommonWebError::from_anyhow_error(err));
}
};
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
CommonWebError::from_error(e)
})?;
if record.is_keepalive_required {
// TODO(bt,2023-05-21): Make async.
let extra_status_key = RedisKeys::generic_inference_extra_status_info(path.token.as_str());
let _: Option<String> = match redis.set_ex(&extra_status_key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
error!("redis error setting job keepalive: {:?}", e);
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
}
};
}
let media_domain = get_media_domain(&http_request);
let state = record_to_payload(
record,
server_state.server_environment,
media_domain,
);
Ok(Json(OmniApiGetJobStatusSuccessResponse {
success: true,
state,
}))
}
@@ -1,3 +0,0 @@
pub mod batch_get_job_status_handler;
pub mod get_job_status_handler;
pub mod record_to_payload;
@@ -1,127 +0,0 @@
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::{
OmniApiJobRequestDetails, OmniApiJobResultDetails, OmniApiJobStatusDetails, OmniApiJobStatusPayload,
};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use bucket_paths::legacy::typified_paths::public::voice_conversion_results::bucket_file_path::VoiceConversionResultOriginalFilePath;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use server_environment::ServerEnvironment;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
/// Convert a DB job-status record into the Omni API response payload.
///
/// This is the same mapping the cookie-authenticated `/v1/jobs/*` handlers use;
/// it's shared by both the single and batch Omni API status handlers.
#[allow(deprecated)] // We match deprecated `InferenceCategory` variants in the result-path mapping.
pub fn record_to_payload(
record: GenericInferenceJobStatus,
server_environment: ServerEnvironment,
media_domain: MediaDomain,
) -> OmniApiJobStatusPayload {
let inference_category = record.request_details.inference_category;
// NB: Fail open. We don't want to fail the request if we can't extract the args.
let maybe_polymorphic_args = extract_polymorphic_inference_args(&record)
.ok()
.flatten();
let progress_percentage = estimate_job_progress(&record, maybe_polymorphic_args.as_ref());
OmniApiJobStatusPayload {
job_token: record.job_token,
request: OmniApiJobRequestDetails {
inference_category: record.request_details.inference_category,
maybe_prompt_token: record.request_details.maybe_prompt_token,
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
maybe_model_token: record.request_details.maybe_model_token,
},
status: OmniApiJobStatusDetails {
status: record.status,
maybe_first_started_at: record.maybe_first_started_at,
maybe_failure_category: record.maybe_frontend_failure_category,
progress_percentage,
},
maybe_result: record.maybe_result_details.map(|result_details| {
// NB: Be careful here, because this varies based on the type of inference result.
let public_bucket_media_path = match inference_category {
// NB: TTS has to be special cased due to legacy behavior
InferenceCategory::TextToSpeech
| InferenceCategory::F5TTS => {
match result_details.entity_type.as_str() {
"media_file" => {
// NB: We're migrating TTS to media_files.
// Zero shot TTS uses media files.
// Legacy TT2 uses old pathing.
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
_ => {
// NB: TTS results receive the legacy treatment where their table only reports the full bucket path
result_details.public_bucket_location_or_hash
}
}
}
// NB: Voice conversion has to be special cased due to legacy behavior
InferenceCategory::VoiceConversion | InferenceCategory::SeedVc => {
match result_details.entity_type.as_str() {
"media_file" => {
// NB: We're migrating voice conversion to media_files.
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
_ => {
// NB: This is the old voice conversion result pathing.
VoiceConversionResultOriginalFilePath::from_object_hash(&result_details.public_bucket_location_or_hash)
.get_full_object_path_str()
.to_string()
}
}
}
// Unsupported media files.
InferenceCategory::FormatConversion |
InferenceCategory::ConvertBvhToWorkflow => {
"".to_string()
}
// Deprecated
InferenceCategory::DeprecatedField => {
"".to_string() // TODO(bt,2024-07-16): Read job type instead
}
// The blessed path that modern media files use.
_ => {
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
};
OmniApiJobResultDetails {
entity_type: result_details.entity_type,
entity_token: result_details.entity_token,
media_links: MediaLinksBuilder::from_rooted_path_and_env(
media_domain,
server_environment,
&public_bucket_media_path,
),
maybe_successfully_completed_at: result_details.maybe_successfully_completed_at,
}
}),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
@@ -1,4 +1,3 @@
pub mod generate;
pub mod job_status;
pub mod shared_utils;
pub mod upload;
@@ -1,37 +0,0 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Reject a request where a media-token field and its paired URL field are both
/// set. Exactly one of the two may be provided.
pub fn check_mutually_exclusive(
media_token_field_name: &str,
media_token_is_set: bool,
url_field_name: &str,
url_is_set: bool,
) -> Result<(), CommonWebError> {
if media_token_is_set && url_is_set {
return Err(CommonWebError::BadInputWithSimpleMessage(format!(
"Either {} or {} must be set, not both",
media_token_field_name, url_field_name,
)));
}
Ok(())
}
/// Reject any URL that does not start with `http://` or `https://`.
pub fn check_url_format(urls: Option<&[String]>) -> Result<(), CommonWebError> {
for url in urls.unwrap_or(&[]) {
check_single_url_format(url)?;
}
Ok(())
}
/// Single-URL variant of [`check_url_format`] for non-list fields.
pub fn check_single_url_format(url: &str) -> Result<(), CommonWebError> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(CommonWebError::BadInputWithSimpleMessage(format!(
"URL must start with http:// or https://, bad URL: {}",
url,
)));
}
Ok(())
}
@@ -1 +0,0 @@
pub mod upload_url_to_media_file;
@@ -1,216 +0,0 @@
use std::collections::HashSet;
use log::{info, warn};
use once_cell::sync::Lazy;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use mimetypes::mimetype_to_extension::mimetype_to_extension;
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
/// The kind of media we expect at a given URL. Determines the permitted mime
/// types, the bucket path prefix, and how the `media_files` row is classified.
#[derive(Clone, Copy, Debug)]
pub enum MediaUploadKind {
Image,
Video,
Audio,
}
/// Download a user-supplied URL, store its bytes in the public R2 bucket, and
/// insert a `media_files` record owned by the API user. Returns the new
/// `MediaFileToken` so the caller can treat the URL input as a media token from
/// here on.
///
/// NB: this performs a network download followed by an R2 upload — do NOT call
/// it while holding a pooled MySQL connection (the insert acquires its own).
pub async fn upload_url_to_media_file(
server_state: &ServerState,
user_token: &UserToken,
ip_address: &str,
url: &str,
kind: MediaUploadKind,
) -> Result<MediaFileToken, CommonWebError> {
// ==================== DOWNLOAD ==================== //
let file_bytes = http_download_url_to_bytes(url)
.await
.map_err(|err| {
warn!("Failed to download media URL {}: {:?}", url, err);
CommonWebError::BadInputWithSimpleMessage(format!("Failed to download URL: {}", url))
})?;
// ==================== MIME VALIDATION ==================== //
let mimetype = get_mimetype_for_bytes(file_bytes.as_ref())
.map(|mimetype| mimetype.to_string())
.ok_or_else(|| {
warn!("Could not determine mimetype for URL: {}", url);
CommonWebError::BadInputWithSimpleMessage(
format!("Could not determine file type for URL: {}", url))
})?;
if !kind.allowed_mime_types().contains(mimetype.as_str()) {
// NB: Don't let our error message echo back malicious strings.
let filtered_mimetype = mimetype
.chars()
.filter(|c| c.is_ascii())
.filter(|c| c.is_alphanumeric() || *c == '/')
.collect::<String>();
return Err(CommonWebError::BadInputWithSimpleMessage(
format!("Unpermitted mime type for URL {}: {}", url, filtered_mimetype)));
}
// ==================== OTHER FILE METADATA ==================== //
let extension = mimetype_to_extension(&mimetype)
.ok_or_else(|| {
warn!("Could not determine file extension for mimetype: {}", &mimetype);
CommonWebError::server_error_with_message("Could not determine file extension")
})?;
let extension = format!(".{extension}"); // NB: needs dot prefix
let file_size_bytes = file_bytes.len() as u64;
let hash = sha256_hash_bytes(file_bytes.as_ref())
.map_err(|err| {
warn!("Problem hashing bytes for URL {}: {:?}", url, err);
CommonWebError::server_error_with_message("Could not hash downloaded file")
})?;
// ==================== UPLOAD TO R2 ==================== //
let prefix = kind.bucket_prefix();
let public_upload_path = MediaFileBucketPath::generate_new(Some(prefix), Some(&extension));
info!("Uploading URL media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
&mimetype,
)
.await
.map_err(|err| {
warn!("Upload URL media bytes to bucket error: {:?}", err);
CommonWebError::server_error_with_message("Could not store downloaded file")
})?;
// ==================== INSERT MEDIA FILE ==================== //
let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs {
maybe_media_class: Some(kind.media_file_class()),
media_file_type: kind.media_file_type(&mimetype),
maybe_creator_user_token: Some(user_token),
// NB: AVT (anonymous visitor) tokens are a web-session concept; API-key callers have none.
maybe_creator_anonymous_visitor_token: None,
creator_ip_address: ip_address,
creator_set_visibility: Visibility::default(),
upload_type: UploadType::Filesystem,
maybe_engine_category: None,
maybe_animation_type: None,
maybe_mime_type: Some(&mimetype),
maybe_prompt_token: None,
maybe_batch_token: None,
file_size_bytes,
// NB: Duration would require probing the file on disk; URL inputs skip it.
maybe_duration_millis: None,
sha256_checksum: &hash,
maybe_title: None,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false,
maybe_generation_provider: None,
public_bucket_directory_hash: public_upload_path.get_object_hash(),
maybe_public_bucket_prefix: Some(prefix),
maybe_public_bucket_extension: Some(&extension),
pool: &server_state.mysql_pool,
})
.await
.map_err(|err| {
warn!("Failed to insert media file from URL {}: {:?}", url, err);
CommonWebError::server_error_with_message("Could not save downloaded file")
})?;
info!("New media file from URL id: {} token: {:?}", record_id, &token);
Ok(token)
}
static IMAGE_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
])
});
static VIDEO_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"video/mp4",
])
});
static AUDIO_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"audio/aac",
"audio/m4a",
"audio/mpeg",
"audio/ogg",
"audio/opus",
"audio/x-flac",
"audio/x-wav",
"audio/mp4",
"video/mp4",
"video/webm",
])
});
impl MediaUploadKind {
fn allowed_mime_types(&self) -> &'static HashSet<&'static str> {
match self {
MediaUploadKind::Image => &IMAGE_MIME_TYPES,
MediaUploadKind::Video => &VIDEO_MIME_TYPES,
MediaUploadKind::Audio => &AUDIO_MIME_TYPES,
}
}
fn bucket_prefix(&self) -> &'static str {
match self {
MediaUploadKind::Image => "image_",
MediaUploadKind::Video => "video_",
MediaUploadKind::Audio => "aud_",
}
}
fn media_file_class(&self) -> MediaFileClass {
match self {
MediaUploadKind::Image => MediaFileClass::Image,
MediaUploadKind::Video => MediaFileClass::Video,
MediaUploadKind::Audio => MediaFileClass::Audio,
}
}
fn media_file_type(&self, mimetype: &str) -> MediaFileType {
match self {
MediaUploadKind::Image => match mimetype {
"image/jpeg" => MediaFileType::Jpg,
"image/png" => MediaFileType::Png,
"image/gif" => MediaFileType::Gif,
_ => MediaFileType::Image,
},
MediaUploadKind::Video => MediaFileType::Video,
MediaUploadKind::Audio => MediaFileType::Audio,
}
}
}
@@ -1,4 +1,2 @@
pub mod check_url_inputs;
pub mod map_seedance2pro_router_error;
pub mod media_uploads;
pub mod video;
@@ -1,10 +1,10 @@
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_when_image_required::validate_when_image_required;
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
/// Validate requests before they incur user costs or send API requests
pub fn validate_video_request(
request: &OmniApiVideoGenerateRequest,
request: &OmniGenVideoCostAndGenerateRequest,
) -> Result<(), CommonWebError> {
validate_when_image_required(request)?;
Ok(())
@@ -1,4 +1,4 @@
use artcraft_api_defs::omni_api::generate_requests::omni_api_video_generate_request::OmniApiVideoGenerateRequest;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::http_server::common_responses::common_web_error::CommonWebError;
@@ -8,7 +8,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError;
/// a clean 400 with an actionable message and we don't charge their wallet
/// for an inference job we know will fail.
pub (super) fn validate_when_image_required(
request: &OmniApiVideoGenerateRequest,
request: &OmniGenVideoCostAndGenerateRequest,
) -> Result<(), CommonWebError> {
match request.model {
Some(CommonVideoModel::GrokImagineVideo1p5) => {}, // Fall-through
@@ -21,20 +21,12 @@ pub (super) fn validate_when_image_required(
return Ok(());
}
// NB: A start frame / reference image may be supplied either as a media token
// or as a URL (URLs are converted to media files later in the handler), so
// both satisfy the image requirement here.
let has_start_frame = request.start_frame_image_media_token.is_some()
|| request.start_frame_image_url.is_some();
let has_start_frame = request.start_frame_image_media_token.is_some();
let has_reference_images = request
.reference_image_media_tokens
.as_ref()
.is_some_and(|v| !v.is_empty())
|| request
.reference_image_urls
.as_ref()
.is_some_and(|v| !v.is_empty());
.is_some_and(|v| !v.is_empty());
if has_start_frame || has_reference_images {
return Ok(());
@@ -50,22 +42,17 @@ mod tests {
use super::*;
use tokens::tokens::media_files::MediaFileToken;
fn base_request() -> OmniApiVideoGenerateRequest {
OmniApiVideoGenerateRequest {
fn base_request() -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()),
model: None,
prompt: Some("test".to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
start_frame_image_url: None,
end_frame_image_media_token: None,
end_frame_image_url: None,
reference_image_media_tokens: None,
reference_image_urls: None,
reference_video_media_tokens: None,
reference_video_urls: None,
reference_audio_media_tokens: None,
reference_audio_urls: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
@@ -90,7 +77,7 @@ mod tests {
#[test]
fn passes_for_other_models_without_image() {
// Only v1.5 needs the gate; other models can do text-to-video.
let req = OmniApiVideoGenerateRequest {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::Seedance2p0),
..base_request()
};
@@ -99,7 +86,7 @@ mod tests {
#[test]
fn rejects_v1p5_with_no_image_inputs() {
let req = OmniApiVideoGenerateRequest {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
..base_request()
};
@@ -114,7 +101,7 @@ mod tests {
#[test]
fn rejects_v1p5_with_empty_reference_list_and_no_start_frame() {
let req = OmniApiVideoGenerateRequest {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
reference_image_media_tokens: Some(vec![]),
..base_request()
@@ -127,7 +114,7 @@ mod tests {
#[test]
fn accepts_v1p5_with_start_frame_only() {
let req = OmniApiVideoGenerateRequest {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
start_frame_image_media_token: Some(token("mf_start")),
..base_request()
@@ -137,7 +124,7 @@ mod tests {
#[test]
fn accepts_v1p5_with_reference_images_only() {
let req = OmniApiVideoGenerateRequest {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
reference_image_media_tokens: Some(vec![token("mf_a"), token("mf_b")]),
..base_request()
@@ -147,7 +134,7 @@ mod tests {
#[test]
fn accepts_v1p5_with_both_start_frame_and_reference_images() {
let req = OmniApiVideoGenerateRequest {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
start_frame_image_media_token: Some(token("mf_start")),
reference_image_media_tokens: Some(vec![token("mf_a")]),
@@ -161,7 +148,7 @@ mod tests {
// end_frame_image_media_token is technically an image, but xAI's v1.5
// wire shape only uses start_frame / reference_images. end_frame
// shouldn't satisfy the gate.
let req = OmniApiVideoGenerateRequest {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
end_frame_image_media_token: Some(token("mf_end")),
..base_request()
@@ -5,8 +5,6 @@ use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler;
use crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler;
use crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler;
use crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler;
@@ -51,15 +49,5 @@ where
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/job_status")
.service(web::resource("/job/{token}")
.route(web::get().to(omni_api_get_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/batch")
.route(web::get().to(omni_api_batch_get_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
)
}
@@ -2,17 +2,10 @@ use bytes::Bytes;
use log::warn;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::redirect::Policy;
use wreq::{Client, StatusCode};
const USER_AGENT: &str = "storyteller-web/1.0";
/// Follow up to this many redirects. Many legitimate file URLs (CDN edges,
/// Wikimedia's `Special:FilePath`, S3 pre-sign indirections) answer with a 3xx
/// before serving the bytes, so we must follow them rather than treat the 3xx
/// as a download failure.
const MAX_REDIRECTS: usize = 10;
// TODO(bt, 2025-06-03): Don't load the entire file into memory!
#[derive(Debug)]
@@ -29,7 +22,6 @@ pub enum DownloadFileToBytesError {
pub async fn http_download_url_to_bytes(url: &str) -> Result<Bytes, DownloadFileToBytesError> {
let client = Client::builder()
.gzip(true)
.redirect(Policy::limited(MAX_REDIRECTS))
.build()?;
let response = client.get(url) // NB: No IntoUrl for &Url.
-227
View File
@@ -1,227 +0,0 @@
# Artcraft Moodboard — Design & Flow
> How the moodboard looks, feels, and flows. Written through a high-end product-design lens
> (Linear/Apple-tier restraint: haptic depth, spring-physics motion, calm surfaces) and grounded
> in the shipped implementation (`@storyteller/ui-moodboard`). Companion to `DESIGN_SYSTEM.md`
> (tokens/components), `FEATURE_PLAN.md` (roadmap), and `FEATURE_BACKLOG.md` (what's next).
---
## 1. Design philosophy
Three convictions drive every decision:
1. **The board is an input to generation, not a graveyard.** Generic moodboards (Pinterest,
Milanote) are dead ends — references go in, nothing comes out. Artcraft owns the engine, so the
board is one gesture away from a generation and vice-versa. Every surface keeps that loop in
reach without ever shouting about it.
2. **Native, then elevated.** The moodboard lives *inside* Artcraft, so it speaks the app's visual
language (the `--st-*` theme tokens, Fira Sans, the `glass` material, the brand blue). Elevation
comes from *composition* — nested "double-bezel" enclosures, floating glass islands, motion with
mass — never from a louder palette. The board should feel like it was always part of the app.
3. **The imagery is the interface.** Chrome recedes; references dominate. No like-counts, no
notification noise, no decorative gradients competing with the user's work. Calm by default
(the Cosmos/Are.na lesson), dense where it counts.
The felt quality we're after: **a precise, quiet instrument** — fast, tactile, and a little bit
joyful, where heavy operations feel weightless and nothing ever teleports.
---
## 2. Visual language (the short version)
The full token/component spec is in `DESIGN_SYSTEM.md`; the design *intent* behind it:
- **Double-bezel enclosures.** Cards, the inspector, and toolbars are never flat on the canvas.
A subtle outer shell (plane-1, hairline ring, `rounded-[18px]`, `p-1`) cradles an inner core
(plane-2, concentric `rounded-[14px]`, an inset top-highlight). It reads like a glass plate
seated in a machined tray — physical, considered, expensive.
- **Floating glass islands.** The toolbar and the view switch are detached pills, not edge-glued
bars (`glass` + hairline + ambient shadow, `backdrop-blur` only on these fixed elements — never
on the scrolling grid, where blur would torch scroll performance).
- **Spring-physics motion.** Custom cubic-beziers, never `linear`/`ease-in-out`. A hover lift, a
card landing on drop with a touch of overshoot, a palette fading up. Animate only `transform`,
`opacity`, `filter` — GPU-safe. `prefers-reduced-motion` collapses springs to fades.
- **Concentric squircle radii** and **ambient, layered shadows** (never a harsh `shadow-md`).
- **One display moment.** Body/UI is Fira Sans (the app font, for zero context-switch); the only
"display" flourish is the editorial empty-state heading and presentation mode.
---
## 3. Information architecture
```
Board (durable model, localStorage today → backend later)
├── items: image · video · text · link · color (each: tags, rating, deterministic aspect)
├── sections (model-ready; UI is a near-term add)
└── rendered three ways — ALL views are projections of the same board:
├── GRID virtualized masonry — the default; scan / search / triage at volume
├── CANVAS freeform Konva stage — arrange / compare / plan spatially
└── PRESENT full-bleed deck — review & share
```
**Why grid-first.** 90% of moodboard time is scanning, searching, and triaging — the grid wins
those high-frequency jobs and stays at 60fps (canvas-only tools like Milanote lag at a few hundred
items; we virtualize from item aspect ratios so only the viewport mounts). The freeform canvas is
the *delighter* for spatial thinking, not the foundation. **The board, not the view, is what plugs
into generation** — "use as reference" works regardless of how you're looking at it.
---
## 4. Core flows
Each flow below lists the **gesture**, then the **design rationale**.
### 4.1 Capture — get references in with zero friction
The fastest path wins, so we offer every path and make the empty state *say so*.
- **Drag images** from the desktop onto the board → a dashed primary-tinted drop overlay confirms
the target; files land at the cursor.
- **Paste** — an image from the clipboard drops instantly; a **URL** becomes a link card
(hostname title today; rich OG preview is a backlog item).
- **Upload** (toolbar `↑` / empty-state CTA) → the image appears immediately from a blob, then
uploads in the background to capture a durable media token (so it becomes reference-capable).
- **From library** → the shared gallery picker (Artcraft's own `GalleryModal`), so muscle memory
transfers from the rest of the app. Not signed in? The picker now shows a **Log in** button
(not a dead "Retry"), routed per-platform via the adapter.
- **Note / Color** → a sticky note or an OS-picked swatch, one click each.
*Rationale:* friction is the enemy of collection. The empty state is an **editorial hero**
("Start collecting ideas") that doubles as the drop affordance — it teaches the gestures while
looking like a finished product, not a blank slate.
### 4.2 Organize — keep a growing board legible
- **Density** — a segmented control (Compact / Cozy / Comfortable) re-flows the masonry; the
layout is target-width-driven, so it adapts to any container (Savee's adjustable-density bar).
- **Search + tag chips** — text query over captions/tags/links; click a chip to filter.
- **Multi-select** — click, shift/⌘-click, or marquee-drag on bare canvas; a floating glass
**selection bar** rises from the bottom with the count + actions.
### 4.3 Triage — sift keepers from a batch, fast
Lightroom is the gold standard for high-volume culling, so we borrow its muscle memory:
- With items selected, **`1`–`5`** rate, **`0`** clears, **`P`** picks (5 stars). A rating badge
persists on the tile.
- The toolbar **star filter** cycles "All → 1+ … → 5+ → All" to winnow to keepers.
- The selection bar surfaces the shortcuts inline (`1–5 rate · 0 clear`) so they're *discoverable*,
not hidden lore.
- **Delete is deliberate** — `Delete`/`Backspace` only (we removed `x` as a delete alias: it sits
in the number row and the board has no undo, so a stray press must not destroy work).
*Rationale:* triage is keyboard-first because it's repetitive and high-volume; every key saves a
trip to the mouse. Destructive actions get friction; constructive ones get none.
### 4.4 The board → generate loop — the differentiator
This is the move no generic moodboard can make.
- **"Use as reference"** — on a card's hover rail, in the selection bar, or in the inspector.
Selected board images are pushed into the image-generation prompt as style references (via their
media tokens) and the user lands on the Create-Image surface, refs pre-filled. Krea/Firefly
proved board→generate; Artcraft closes it natively.
- The seam is the **adapter** (§6): desktop seeds `usePromptImageStore` + switches to the Image
tab; web seeds `useCreateImageStore` + navigates to `/create-image`. Same gesture, same result,
two platforms.
*Rationale:* the highest-leverage feature is the one that turns a passive collection into momentum.
It's always one click from any view, and it never blocks — it sends and gets out of the way.
### 4.5 Inspect — the lightbox as a workbench
Double-click or the rail's expand icon opens a full-screen **inspector**:
- Large media on a glass-blurred backdrop (blur is fine here — it's a fixed overlay), a
double-bezel side panel, `←/→` to walk the board, `Esc` to close, background scroll locked,
`role="dialog"`.
- The panel is a small workbench: **rating** stars, a **tag editor**, an **extracted palette**
(client-side color quantization — click a swatch to copy, or "add swatches to board"), and
**Use as reference**. The palette is keyed on the image src, so editing tags never re-decodes
the image or flickers.
### 4.6 Canvas — think spatially
The freeform Konva stage for arranging, comparing, and planning:
- Pan/zoom, marquee + lasso select, 8-handle transform, grouping, undo/redo, a shortcut cheatsheet,
and auto-layout helpers (pack / fit-to-grid / cluster-by-proximity) that already exist and want
better surfacing. Alignment/snapping + a background grid are the next polish (backlog).
- Drop a generation onto the canvas, set images side by side, group a coherent set, then send that
set to generation. It's the planning room; the grid is the library.
### 4.7 Present — review & share
A distraction-free, full-bleed deck: one item at a time, `←/→/space` to navigate, `Esc` to exit,
launched from the view switch as a transient overlay (never persisted, so a reload never reopens
into a slideshow). For client/team review and screen-sharing.
---
## 5. Micro-UX niceties (the details that read as "expensive")
- **Hover action rail** — a glass pill on each card fades *up* on hover (translate + opacity), with
Open / Use-as-reference / Delete. Buttons stop propagation so they never double as a select.
- **Card states choreographed** — hover lifts the shell 2px and deepens the shadow; selected swaps
to a 2px brand ring; drag scales to 1.03; drop lands with a snap-overshoot.
- **Selection check + rating** — solid scrims (not blur) inside the scrolling grid for legibility
without GPU cost.
- **Focus-visible rings everywhere** — keyboard users see where they are, not just hover users.
- **The Login button** — the gallery picker now invites action ("Log in to browse your library" +
a real primary button) instead of a confusing "Retry" link.
- **Stagger on entry** — grid items and presentation reveals come in with a short staggered
fade-up, so the board feels assembled, not dumped.
---
## 6. Cross-platform via the adapter
One library, two hosts, identical experience. The lib owns everything platform-agnostic; each app
injects a `MoodboardAdapter` for its three seams:
```ts
interface MoodboardAdapter {
uploadImage?: (file) => Promise<mediaToken | null>; // capture a durable token
sendToGeneration: (refs) => void; // board → prompt
renderLibraryPicker?: (props) => ReactNode; // the gallery picker (render-prop)
}
```
- **Desktop (Tauri):** upload via `@storyteller/api`, picker via `GalleryModal`, send → prompt
store + tab switch, login → the login modal.
- **Web (artcraft-webapp):** upload via the webapp uploader, picker via the same `GalleryModal`,
send → create-image store + `/create-image`, login → navigate `/login`.
*Rationale:* the adapter is the seam where platform reality lives, so the *experience* stays a
single, consistent design across surfaces — and the same polish ships everywhere at once.
---
## 7. Motion & performance contract
- **Curves:** spring (`cubic-bezier(0.32,0.72,0,1)`) for settle/zoom; smooth
(`cubic-bezier(0.4,0,0.2,1)`, ~200ms) for hover/selection; snap with slight overshoot on drop.
- **Only `transform`/`opacity`/`filter`** animate — never layout properties.
- **`backdrop-blur` only on fixed chrome** (toolbar, view switch, inspector/present backdrops);
the virtualized scrolling grid uses solid scrims so scrolling stays at 60fps.
- **Virtualize aggressively** — deterministic masonry from item aspects means only viewport-visible
cards mount; the grid holds thousands of items without lag (the competitor failure mode).
- **Respect `prefers-reduced-motion`** — springs become instant, fades remain.
---
## 8. Accessibility & polish checklist
- [x] Theme-correct: consumes `--st-*` only, so light/dark/gray/black/aurora themes work for free.
- [x] Focus-visible rings on interactive controls; inspector is a labelled `role="dialog"` with
scroll-lock and keyboard nav.
- [x] Color is never the only signal (drop state pairs a tint with a dashed outline + icon).
- [x] Destructive delete is deliberate (no single-printable-key delete); no silent data loss.
- [ ] Cards are not yet keyboard-focusable/selectable (pointer-first) — a known a11y gap.
- [ ] Toolbar/inspector don't yet collapse gracefully below ~768px (desktop-first) — responsive
pass pending.
---
## 9. Where the design goes next
The research-backed backlog (`FEATURE_BACKLOG.md`) extends this flow without changing its spine —
client-side ML (in-browser captioning, auto-tagging, find-similar, palette/color search) makes the
board *understand itself*; canvas snapping/alignment and a region→palette tool deepen planning; and
the generate-into-a-board / remix loop tightens the differentiator. Collaboration & sharing are
deferred behind a flag (they need the backend). The throughline stays constant: **collect with no
friction, organize effortlessly, and turn the board into the next generation — calmly.**
-101
View File
@@ -1,101 +0,0 @@
# Moodboard — UX Change Plan
> Derived from the recommendations in `DESIGN_AND_FLOW.md` (the high-end design/UX doc), checked
> against the **actual implementation**. This is a *design-and-UX polish* plan — closing the gap
> between the experience the doc describes and what's shipped. (AI/ML features live separately in
> `FEATURE_BACKLOG.md`; backend-dependent work like sharing stays deferred.)
## Audit — doc says vs. reality
| Recommendation (DESIGN_AND_FLOW.md) | Status today | Gap |
|---|---|---|
| §5 "Stagger on entry — items fade-up assembled" | **Not built** — grid renders the virtualized slice with no entry motion | Yes |
| §8 "Focus-visible rings on interactive controls" | **Partial** — only card action-rail + toolbar add-buttons | Yes |
| §8 "Cards not yet keyboard-focusable/selectable" | **Not built** — pointer-only | Yes (acknowledged) |
| §8 "Toolbar/inspector don't collapse < ~768px" | **Not built** — fixed horizontal island; inspector is a fixed 2-pane | Yes (acknowledged) |
| §4.2 "Sections" | Model-ready, **no UI** | Yes |
| §4.1 "Paste URL → link card" | Hostname-only, **no OG preview** | Yes (needs proxy) |
| §4.6 "Auto-layout helpers want better surfacing" | Helpers exist (`pack`/`fitToGrid`/`cluster`), **under-exposed** | Yes |
| §5 double-bezel, glass islands, spring motion, hover rail, card states, scrims, login button, triage hint, inspector dialog | **Built** ✓ | — |
Everything in the bottom row is genuinely shipped; the rows above are the work.
---
## The plan (prioritized by impact ÷ effort)
Each item: **what + UX**, **why** (design rationale), **effort (S/M/L)**, **acceptance**.
### P1 — Motion & accessibility foundation (do first)
**1.1 Entry motion — staggered fade-up on the grid.** *(the doc claims it; it isn't there)*
- **What:** as cards mount into the viewport, fade-up from `translate-y-2 opacity-0 blur-[2px]` → rest over ~320ms on `--mb-ease-smooth`, with an index-based delay capped at ~12 items so a full screen feels *assembled*, not dumped. Must be virtualization-safe: animate only on first mount, key the animation so re-renders/scroll don't re-trigger; respect `prefers-reduced-motion`.
- **Why:** the single biggest "expensive feel" lever the doc names; the board currently pops in flatly.
- **Effort:** **S–M.** **Acceptance:** opening a board / switching density plays one clean stagger; scrolling does **not** re-animate; reduced-motion shows instant.
**1.2 Keyboard-navigable cards.**
- **What:** card root becomes focusable (`tabIndex=0`, `role="button"`, `aria-label`); `Enter`/`Space` opens the inspector, `X`/`Delete` deletes the focused card (with the same deliberate-delete rule), and arrow keys move focus across the masonry (geometry-aware: left/right within a row, up/down to the nearest card). Selection follows focus with `Space`; `Shift`+arrows extends.
- **Why:** the doc's headline a11y gap; also a power-user speed win (no mouse round-trips during triage).
- **Effort:** **M.** **Acceptance:** a keyboard-only user can tab into the grid, move with arrows, open/rate/delete, and select — with a visible focus ring on each card.
**1.3 Complete focus-visible coverage.**
- **What:** add focus-visible rings to every remaining interactive control — `SelectionBar` buttons, `SmartSearchBar` input + clear + tag chips, density segmented control, rating-filter toggle, the view switch, and all `ItemInspector` controls (rating stars, tag input, palette swatches, actions).
- **Why:** §8 says "everywhere"; today it's two components.
- **Effort:** **S.** **Acceptance:** tabbing through the whole moodboard always shows where focus is.
### P2 — Responsive & micro-UX completeness
**2.1 Responsive toolbar.**
- **What:** below `md`, the floating island stops being one wide row — it becomes a compact glass bar: board name + a `+`-menu (collapsing the 4 add-buttons), density, rating, and a search **icon** that expands into a sheet; the whole thing `overflow-x-auto` as a last resort so it never breaks the layout.
- **Why:** §8 gap; the webapp can be viewed narrow, and today the island overflows.
- **Effort:** **M.** **Acceptance:** at 375px wide the toolbar is usable and doesn't clip; at ≥`md` it's unchanged.
**2.2 Responsive inspector.**
- **What:** below `md`, the inspector stacks (media on top, the panel as a bottom sheet) instead of the fixed media-left / 320px-panel split; `h-[100dvh]`; nav arrows move to the corners.
- **Why:** §8 gap; the current side-by-side is unusable on phones.
- **Effort:** **S–M.** **Acceptance:** inspector is fully usable at 375px.
**2.3 Sections UI.**
- **What:** group items into named, collapsible sections in the grid (the model already supports `sections` + `sectionId`). A section header row with a name (inline-rename), count, collapse toggle, and "new section"; drag a selection into a section; an "Ungrouped" default lane.
- **Why:** §4.2 calls it a near-term add; big boards spiral without it (Pinterest Sections / MJ folder-groups).
- **Effort:** **M.** **Acceptance:** create/rename/collapse sections; assign items; persists.
**2.4 Surface canvas auto-layout helpers.**
- **What:** a one-click **Tidy / Arrange** menu on the canvas toolbar exposing the already-built `pack` (justified rows), `fitToGrid`, and `cluster-by-proximity`, plus "align selection."
- **Why:** §4.6 — the logic exists but is hidden; cheap, high-delight.
- **Effort:** **S.** **Acceptance:** the three layouts run from a visible menu; undoable.
**2.5 "Button-in-button" primary CTAs.**
- **What:** apply the doc's nested trailing-icon pattern (already in the empty-state CTA) to the primary actions — "Use as reference" and the inspector's primary button — with the magnetic hover physics (icon translates diagonally + scales on hover, button `active:scale-[0.98]`).
- **Why:** §5 haptic consistency; makes the differentiator action feel premium.
- **Effort:** **S.** **Acceptance:** primary CTAs share one polished button anatomy.
### P3 — Larger canvas & capture polish
**2.6 Canvas alignment + snapping + background grid.**
- **What:** snap a dragged node to other nodes' edges/centers with guide lines (DIY on Konva `dragmove`), plus a toggleable background grid + snap-to-grid.
- **Why:** §4.6/§9 — makes the freeform canvas feel professional (PureRef 2.1).
- **Effort:** **M.** (also tracked in `FEATURE_BACKLOG.md` 3.1/3.2.) **Acceptance:** dragging shows guides + snaps; grid toggles.
**2.7 Rich URL preview (link cards).**
- **What:** paste a URL → fetch OG title + image for a real preview card (vs hostname today).
- **Why:** §4.1 — link cards look unfinished without a thumbnail.
- **Effort:** **S (client) / M (proxy).** **Dependency:** OG fetch needs a CORS unfurl proxy → **flag as backend-dependent / defer the image; ship title now.**
**2.8 Drag-from-library onto the board.**
- **What:** let a gallery item be dragged directly onto the grid/canvas (not only picked via the modal), reusing Artcraft's existing gallery-drag event.
- **Why:** §4.1 — the lowest-friction capture path for already-owned media.
- **Effort:** **M.** **Acceptance:** drag a library thumbnail onto the board → it lands as a tokened item.
---
## Sequencing
- **Sprint 1 (feel + a11y):** 1.1 entry motion, 1.2 keyboard cards, 1.3 focus coverage. *One cohesive "the board feels finished and is fully keyboard-able" release.*
- **Sprint 2 (responsive + organize):** 2.1 toolbar, 2.2 inspector, 2.3 sections, 2.5 CTAs.
- **Sprint 3 (canvas + capture):** 2.4 surface auto-layout, 2.6 snapping/grid, 2.8 drag-from-library, 2.7 link previews (title now, image when a proxy exists).
## Recommended first move
**Sprint 1** — it's the highest "expensive feel ÷ effort" and closes the doc's two explicit a11y
gaps + the one motion claim that isn't yet true. All client-side, no backend, fits the shared lib
cleanly, and verifiable headlessly (focus order, reduced-motion, DOM-on-mount).
-8
View File
@@ -1,8 +0,0 @@
# The admin dashboard is intentionally isolated from the Nx/npm workspace.
#
# It pins its own incompatible toolchain (React 19, Vite 7, Tailwind 4) and
# installs with pnpm into its own node_modules. Nx must NOT infer it as a
# project — otherwise `nx run-many`/`nx affected` would try to build it with the
# workspace's React 18 toolchain and root node_modules. It builds standalone
# (see apps/artcraft-dashboard/netlify.toml).
apps/artcraft-dashboard
@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
@@ -1,2 +0,0 @@
# ArtCraft Admin Dashboard
Admin dashboard for managing ArtCraft
@@ -1,24 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"context": "@/context"
},
"registries": {}
}
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
@@ -1,19 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/artcraft-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap"
rel="stylesheet"
/>
<title>ArtCraft Admin Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -1,38 +0,0 @@
# Netlify configuration for the ArtCraft admin dashboard.
#
# Set up the Netlify site exactly like the other apps in this workspace:
# * Base directory: frontend (the Nx workspace root)
# * Build command: bash ./apps/artcraft-dashboard/script/netlify_build.sh
# * Publish dir: apps/artcraft-dashboard/dist
#
# (These match what's configured in the Netlify UI; paths are relative to the
# `frontend` base directory.)
#
# REQUIRED env var (set in the Netlify UI → Site config → Environment):
# * NODE_VERSION = 22
# Vite 7 (used by this app) requires Node >= 20.19 / 22.12, which is newer than
# the rest of the workspace needs. The base directory is shared with the other
# apps, so this can't be pinned via a committed .node-version without affecting
# them — set it per-site in the UI instead. Netlify does not read this
# netlify.toml at base `frontend`, so a [build.environment] block here would NOT
# take effect; it must be a UI env var.
#
# The dashboard is deliberately ISOLATED from the Nx/npm workspace: it pins its
# own React 19 + Vite 7 + Tailwind 4 toolchain and installs with pnpm. The build
# script builds it standalone inside its own folder. Because the dashboard is
# excluded from the root npm `workspaces` globs, Netlify's automatic
# `npm install` at the base directory does NOT pull in its (incompatible) deps —
# that's what prevents the multi-minute install hangs we saw when it was a
# workspace member.
[build]
command = "bash ./apps/artcraft-dashboard/script/netlify_build.sh"
publish = "apps/artcraft-dashboard/dist/"
# Vite emits content-hashed assets under /assets/*, so they can be cached
# forever — a new deploy produces new filenames.
[[headers]]
for = "/assets/*"
[headers.values]
"Cache-Control" = "public, max-age=31536000, immutable"
@@ -1,55 +0,0 @@
{
"name": "artcraft-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dicebear/collection": "^9.4.0",
"@dicebear/core": "^9.4.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tabler/icons-react": "^3.38.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.15",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.11.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"shadcn": "^3.8.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
},
"packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321"
}
File diff suppressed because it is too large Load Diff
@@ -1,2 +0,0 @@
/api/* https://api.storyteller.ai/:splat 200
/* /index.html 200
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72.22 65.68">
<defs>
<style>
.cls-1 {
fill: #2d81ff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M69.38,45.46c-3.18-6.24-6.58-12.49-9.86-18.53-3.31-6.08-6.72-12.37-9.91-18.63l-.05-.09C46.44,3.07,41.24,0,35.66,0s-10.34,3-13.13,8.23L.97,48.96l-.03.07c-1.51,3.8-1.2,8.03.82,11.31,1.9,3.08,5.01,4.96,8.76,5.28.38.03.75.05,1.1.05,2.94,0,4.98-1.12,7.33-2.41,7.13-3.91,19.45-11.21,27.61-16.04,2.6-1.54,4.95-2.93,6.31-3.73l10.71,20.43c.2.39.54.7.95.86.46.19.98.2,1.43,0,1.39-.66,3.05-2.47,3.07-2.49,5.22-5.9,3.03-11.55.34-16.83ZM34.53,14.8c.31-.46.77-.99,1.32-.99.58,0,1.29.57,1.85,1.49,1.57,2.56,2.99,5.44,4.35,8.22,1.34,2.73,2.72,5.55,4.29,8.15l.02.05-29.6,16.99L34.53,14.8Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 885 B

@@ -1,34 +0,0 @@
#!/bin/bash
set -euxo pipefail
# Build the ArtCraft admin dashboard.
#
# Like the other apps, this runs from the Netlify base directory (= the Nx
# workspace root, `frontend/`). But unlike them, the dashboard is intentionally
# ISOLATED from the Nx/npm workspace: it pins its own React 19 + Vite 7 +
# Tailwind 4 toolchain and installs with pnpm into its own node_modules.
#
# We build it entirely inside its own folder with pnpm, so it never depends on
# (or conflicts with) the workspace's React 18 install. The dashboard is
# excluded from the root npm `workspaces` globs, so Netlify's automatic
# `npm install` at the base directory ignores it — that exclusion is what keeps
# the workspace install fast and prevents the multi-minute dependency-resolution
# hangs we hit when the dashboard was a workspace member.
echo "Building artcraft-dashboard (standalone pnpm build)"
pushd apps/artcraft-dashboard
# Use the pnpm version pinned in package.json's `packageManager` field, via
# corepack (bundled with Node on Netlify). Disable the interactive download
# prompt so CI never blocks waiting for confirmation.
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
corepack pnpm install --frozen-lockfile
corepack pnpm build
echo "Final build files:"
find dist/
popd
@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
@@ -1,103 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation, useSearchParams } from "react-router-dom";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { DashboardLayout } from "@/layouts/DashboardLayout";
import { LoginPage } from "@/pages/Login";
import { DashboardHome } from "@/pages/DashboardHome";
import { UserSearch } from "@/pages/UserSearch";
import { UserProfile } from "@/pages/UserProfile";
import { WalletHistory } from "@/pages/WalletHistory";
import { StripeLookup } from "@/pages/StripeLookup";
import { JobHistory } from "@/pages/JobHistory";
import { UserCreations } from "@/pages/UserCreations";
import { UserSignups } from "@/pages/UserSignups";
import { SubscriberSignups } from "@/pages/SubscriberSignups";
import { ExploreMedia } from "@/pages/ExploreMedia";
import { Impersonation } from "@/pages/Impersonation";
import { StaffAuditLogs } from "@/pages/StaffAuditLogs";
import { SendPager } from "@/pages/SendPager";
import { FeatureFlags } from "@/pages/FeatureFlags";
import { UserEmailChanges } from "@/pages/UserEmailChanges";
import { ReferralsAnalytics } from "@/pages/ReferralsAnalytics";
import { UserReferrals } from "@/pages/UserReferrals";
import { JobInfo } from "@/pages/JobInfo";
import { JobTokenSearch } from "@/pages/JobTokenSearch";
import { DebugLogs } from "@/pages/DebugLogs";
import { DebugLogsSearch } from "@/pages/DebugLogsSearch";
import { NotFoundPage } from "@/pages/NotFound";
import { Spinner } from "./components/ui/spinner";
import { ThemeProvider } from "@/components/theme-provider";
function AppRoutes() {
const { user, isLoading } = useAuth();
const location = useLocation();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirect") || "/";
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center">
<Spinner className="size-12 opacity-60" />
</div>
);
}
return (
<Routes>
<Route
path="/login"
element={user ? <Navigate to={redirectTo} replace /> : <LoginPage />}
/>
<Route
path="/"
element={user ? <DashboardLayout /> : <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />}
>
<Route index element={<DashboardHome />} />
<Route path="user/search" element={<UserSearch />} />
<Route path="user/profile/:username" element={<UserProfile />} />
<Route path="user/profile/:username/wallet" element={<WalletHistory />} />
<Route path="user/profile/:username/jobs" element={<JobHistory />} />
<Route path="user/profile/:username/creations" element={<UserCreations />} />
<Route path="user/profile/:username/referrals" element={<UserReferrals />} />
<Route path="stripe-lookup" element={<StripeLookup />} />
<Route path="user-signups" element={<UserSignups />} />
<Route path="subscriber-signups" element={<SubscriberSignups />} />
<Route path="impersonation" element={<Impersonation />} />
<Route path="staff-audit-logs" element={<StaffAuditLogs />} />
<Route path="send-pager" element={<SendPager />} />
<Route path="feature-flags" element={<FeatureFlags />} />
<Route path="email-changes" element={<UserEmailChanges />} />
<Route path="referrals" element={<ReferralsAnalytics />} />
<Route path="explore/media" element={<ExploreMedia />} />
<Route path="explore/media/:mediaToken" element={<ExploreMedia />} />
<Route path="moderation/job-search" element={<JobTokenSearch />} />
<Route path="moderation/job/:jobToken" element={<JobInfo />} />
<Route
path="moderation/debug-logs-search"
element={<DebugLogsSearch />}
/>
<Route
path="moderation/debug-logs/:eventToken"
element={<DebugLogs />}
/>
</Route>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
function App() {
return (
<ThemeProvider defaultTheme="dark" attribute="class">
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</ThemeProvider>
);
}
export default App;
@@ -1,124 +0,0 @@
export interface ApiResponse<T, P = undefined> {
success: boolean;
errorMessage?: string;
data?: T;
pagination?: P;
}
const getApiHost = () => {
return import.meta.env.VITE_API_HOST || "/api";
};
export class ApiManager {
protected getApiSchemeAndHost(): string {
return getApiHost();
}
public async fetch<B, T>(
endpoint: string,
{
method,
query,
body,
}: {
method: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
},
): Promise<T> {
const queryInString =
query &&
Object.entries(query).reduce(
(allOptions, [key, value]) => {
if (!value) {
return allOptions;
}
allOptions[key] = value.toString();
return allOptions;
},
{} as Record<string, string>,
);
const endpointWithQueries = queryInString
? endpoint + "?" + new URLSearchParams(queryInString)
: endpoint;
const bodyInString = body ? JSON.stringify(body) : undefined;
const response = await fetch(endpointWithQueries, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
credentials: "include",
body: bodyInString,
});
const text = await response.text();
try {
return JSON.parse(text);
} catch {
console.error("API Error Response:", text);
throw new Error(text || `HTTP ${response.status}`);
}
}
protected get<T>({
endpoint,
query,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
}): Promise<T> {
return this.fetch<null, T>(endpoint, { method: "GET", query });
}
protected post<B, T>({
endpoint,
query,
body,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
}): Promise<T> {
return this.fetch<B, T>(endpoint, {
method: "POST",
query,
body,
});
}
protected put<B, T>({
endpoint,
query,
body,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
}): Promise<T> {
return this.fetch<B, T>(endpoint, {
method: "PUT",
query,
body,
});
}
protected delete<B, T>({
endpoint,
query,
body,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
}): Promise<T> {
return this.fetch<B, T>(endpoint, {
method: "DELETE",
query,
body,
});
}
}
@@ -1,305 +0,0 @@
import { ApiManager, type ApiResponse } from "./ApiManager";
export interface Pagination {
total_items: number;
total_pages: number;
current_page: number;
items_per_page: number;
}
export interface ListUserMediaQuery {
page_index?: number;
page_size?: number;
filter_media_classes?: string[];
filter_media_type?: string[];
filter_engine_categories?: string[];
}
export interface ListMediaFilesQuery {
sort_ascending?: boolean;
page_size?: number;
cursor?: string;
cursor_is_reversed?: boolean;
filter_media_classes?: string;
filter_media_type?: string;
filter_engine_categories?: string;
include_user_uploads?: boolean;
}
export interface CursorPagination {
cursor_is_reversed: boolean;
maybe_next: string | null;
maybe_previous: string | null;
}
export interface ExploreMediaFile {
token: string;
media_class: string;
media_type: string;
created_at: string;
updated_at: string;
maybe_title: string | null;
maybe_text_transcript: string | null;
creator_set_visibility: string;
is_user_upload: boolean;
is_intermediate_system_file: boolean;
media_links: {
cdn_url: string;
maybe_thumbnail_template: string | null;
maybe_video_previews: {
animated?: string;
thumbnail_template?: string;
} | null;
};
cover_image: {
default_cover: {
color_index: number;
image_index: number;
};
maybe_cover_image_public_bucket_url: string | null;
maybe_links: {
cdn_url: string;
maybe_thumbnail_template: string | null;
} | null;
};
maybe_creator: {
default_avatar: { color_index: number; image_index: number };
display_name: string;
gravatar_hash: string;
user_token: string;
username: string;
} | null;
maybe_duration_millis: number | null;
maybe_engine_category: string | null;
origin: {
origin_category: string;
product_category: string;
};
stats: {
bookmark_count: number;
positive_rating_count: number;
};
}
export interface MediaInfo {
token: string;
media_class: string;
media_type: string;
public_bucket_url?: string;
media_links?: {
cdn_url?: string;
maybe_thumbnail_template?: string;
maybe_video_previews?: {
animated?: string;
thumbnail_template?: string;
} | null;
};
maybe_title?: string;
maybe_text_transcript?: string;
created_at?: string;
updated_at?: string;
creator_set_visibility?: string;
// include arbitrary keys
[key: string]: any;
}
export function getMediaThumbnailUrl(
item: MediaInfo | ExploreMediaFile,
width = 512,
): string | undefined {
const widthStr = width.toString();
// Videos: use animated preview
if (item.media_class === "video") {
if (item.media_links?.maybe_video_previews?.animated) {
return item.media_links.maybe_video_previews.animated;
}
}
// 3D/dimensional: use cover image URL
if (item.media_class === "dimensional") {
if ("cover_image" in item && item.cover_image) {
if (item.cover_image.maybe_cover_image_public_bucket_url) {
return item.cover_image.maybe_cover_image_public_bucket_url;
}
if (item.cover_image.maybe_links?.maybe_thumbnail_template) {
return item.cover_image.maybe_links.maybe_thumbnail_template.replace(
"{WIDTH}",
widthStr,
);
}
if (item.cover_image.maybe_links?.cdn_url) {
return item.cover_image.maybe_links.cdn_url;
}
}
}
// Images (and fallback): use thumbnail template
if (item.media_links?.maybe_thumbnail_template) {
return item.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
widthStr,
);
}
if (item.media_links?.cdn_url) return item.media_links.cdn_url;
if ("public_bucket_url" in item && item.public_bucket_url)
return item.public_bucket_url as string;
return undefined;
}
export interface MediaFileDetail {
token: string;
maybe_prompt_token: string | null;
media_class: string;
media_type: string;
maybe_title: string | null;
maybe_text_transcript: string | null;
[key: string]: any;
}
export interface PromptInfo {
token: string;
maybe_model_type: string | null;
maybe_model_class: string | null;
maybe_positive_prompt: string | null;
maybe_negative_prompt: string | null;
maybe_aspect_ratio: string | null;
maybe_resolution: string | null;
maybe_generation_provider: string | null;
maybe_generation_mode: string | null;
maybe_duration_seconds: number | null;
maybe_inference_duration_millis: number | null;
}
export class MediaApi extends ApiManager {
public async ListMediaFiles(
query: ListMediaFilesQuery = {},
): Promise<ApiResponse<ExploreMediaFile[], CursorPagination>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list`;
const queryParams: Record<string, string | number | boolean> = {};
if (query.page_size !== undefined) queryParams.page_size = query.page_size;
if (query.cursor) queryParams.cursor = query.cursor;
if (query.sort_ascending !== undefined)
queryParams.sort_ascending = query.sort_ascending;
if (query.cursor_is_reversed !== undefined)
queryParams.cursor_is_reversed = query.cursor_is_reversed;
if (query.filter_media_classes)
queryParams.filter_media_classes = query.filter_media_classes;
if (query.filter_media_type)
queryParams.filter_media_type = query.filter_media_type;
if (query.filter_engine_categories)
queryParams.filter_engine_categories = query.filter_engine_categories;
if (query.include_user_uploads)
queryParams.include_user_uploads = query.include_user_uploads;
return await this.get<{
success: boolean;
results: ExploreMediaFile[];
pagination: CursorPagination;
error_message?: string;
}>({ endpoint, query: queryParams })
.then((response) => ({
success: response.success,
data: response.results ?? [],
pagination: response.pagination,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserMediaFiles(
username: string,
query: ListUserMediaQuery = {},
): Promise<ApiResponse<MediaInfo[], Pagination>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list/user/${username}`;
// Convert to fully stringified dict
const queryWithStrings: Record<string, string | number | boolean> = {
include_user_uploads: true,
};
if (query.page_index !== undefined)
queryWithStrings.page_index = query.page_index;
if (query.page_size !== undefined)
queryWithStrings.page_size = query.page_size;
if (query.filter_media_classes) {
queryWithStrings.filter_media_classes =
query.filter_media_classes.join(",");
}
if (query.filter_media_type) {
queryWithStrings.filter_media_type = query.filter_media_type.join(",");
}
if (query.filter_engine_categories) {
queryWithStrings.filter_engine_categories =
query.filter_engine_categories.join(",");
}
return await this.get<{
success: boolean;
results: MediaInfo[];
pagination?: Pagination;
error_message?: string;
}>({ endpoint, query: queryWithStrings })
.then((response) => ({
success: response.success,
data: response.results ?? [],
pagination: response.pagination,
errorMessage: response.error_message,
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
public async GetMediaFile(
token: string,
): Promise<ApiResponse<MediaFileDetail>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/file/${token}`;
return await this.get<{
success: boolean;
media_file: MediaFileDetail;
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: response.media_file,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async GetPrompt(
token: string,
): Promise<ApiResponse<PromptInfo>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/prompts/${token}`;
return await this.get<{
success: boolean;
prompt: PromptInfo;
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: response.prompt,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
}
@@ -1,798 +0,0 @@
import { ApiManager, type ApiResponse } from "./ApiManager";
import type {
Wallet,
WalletWithOwner,
WalletLedgerEntry,
WalletLedgerEntryWithWallet,
UserJob,
SignupUser,
SubscriberUser,
ImpersonationRequest,
StaffAuditLog,
ModerationJob,
DebugLog,
UserEmailChange,
} from "@/types";
export interface FeatureFlagDescriptor {
key: string;
full_name: string;
description: string;
}
export interface UserFeatureFlagsResponse {
user_token: string;
username: string;
display_name: string;
feature_flags: string[];
}
export type EditUserFeatureFlagsAction =
| { AddFlags: { flags: string[] } }
| { RemoveFlags: { flags: string[] } }
| { KeepFlags: { flags: string[] } }
| { SetExactFlags: { flags: string[] } }
| "ClearAllFlags";
export interface UserReferralInvitedUser {
token: string;
username: string;
display_name: string;
email_address: string;
}
export interface UserReferralReferrerUser {
token: string;
username: string;
display_name: string;
}
export interface UserReferralListItem {
created_at: string;
invited_user: UserReferralInvitedUser;
referrer_user: UserReferralReferrerUser;
maybe_landing_url: string | null;
maybe_referral_url: string | null;
maybe_referral_code_token: string | null;
}
/** Which table the customer id was found in. */
export type ModeratorStripeCustomerIdSource = "customer_link" | "subscription";
export interface ModeratorUserStripeCustomerIdEntry {
stripe_customer_id: string;
payments_namespace: string;
source: ModeratorStripeCustomerIdSource;
}
export interface ModeratorUserLookupResponse {
display_name: string;
email_address: string;
email_confirmed: boolean;
email_gravatar_hash: string;
email_is_synthetic: boolean;
ip_address_creation: string;
ip_address_last_login: string;
is_banned: boolean;
is_temporary: boolean;
is_without_password: boolean;
maybe_avatar_media_file_token?: string;
created_at?: string;
token: string;
username: string;
username_is_generated: boolean;
username_is_not_customized: boolean;
}
export class ModerationApi extends ApiManager {
public async UserLookup(
search: string,
): Promise<ApiResponse<{ maybe_user?: ModeratorUserLookupResponse }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/lookup`;
return await this.post<
{ search: string },
{
success: boolean;
maybe_user?: ModeratorUserLookupResponse;
error_message?: string;
}
>({
endpoint,
body: { search },
})
.then((response) => ({
success: response.success,
data: {
maybe_user: response.maybe_user,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async LookupByStripeCustomerId(stripe_customer_id: string): Promise<
ApiResponse<{
users: Array<{
display_name: string;
email_address: string;
maybe_stripe_subscription_id?: string | null;
subscription_namespace: string;
token: string;
username: string;
}>;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/lookup_by_stripe_customer_id`;
return await this.post<
{ stripe_customer_id: string },
{
success: boolean;
users: Array<{
display_name: string;
email_address: string;
maybe_stripe_subscription_id?: string | null;
subscription_namespace: string;
token: string;
username: string;
}>;
error_message?: string;
}
>({
endpoint,
body: { stripe_customer_id },
})
.then((response) => ({
success: response.success,
data: {
users: response.users || [],
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async GetUserStripeCustomerIds(
user_token: string,
): Promise<
ApiResponse<{ customer_ids: ModeratorUserStripeCustomerIdEntry[] }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_stripe_data/${encodeURIComponent(user_token)}/customer_ids`;
return await this.get<{
success: boolean;
customer_ids: ModeratorUserStripeCustomerIdEntry[];
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { customer_ids: response.customer_ids || [] },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ImpersonateUser(params: {
username?: string;
user_token?: string;
email_address?: string;
username_email_or_token?: string;
}): Promise<ApiResponse<{ password_token: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonate`;
return await this.post<
typeof params,
{
success: boolean;
password_token?: string;
error_message?: string;
}
>({
endpoint,
body: params,
})
.then((response) => ({
success: response.success,
data: response.password_token
? { password_token: response.password_token }
: undefined,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async SendAlert(params: {
title?: string | null;
description?: string | null;
urgency?: "high" | "medium" | "low" | null;
}): Promise<ApiResponse<void>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/alerts/send`;
return await this.post<
typeof params,
{ success: boolean; error_message?: string }
>({ endpoint, body: params })
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ManageUserBan(params: {
username: string;
is_banned: boolean;
mod_notes: string;
}): Promise<ApiResponse<void>> {
const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_bans/manage_ban`;
return await this.post<
typeof params,
{ success: boolean; error_message?: string }
>({ endpoint, body: params })
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListStaffAuditLogs(
cursor?: string | null,
limit?: number,
): Promise<
ApiResponse<{
audit_logs: StaffAuditLog[];
next_cursor: string | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/staff_audit_logs/list`;
return await this.get<{
success: boolean;
audit_logs: StaffAuditLog[];
maybe_cursor: string | null;
error_message?: string;
}>({
endpoint,
query: {
cursor: cursor ?? undefined,
limit,
},
})
.then((response) => ({
success: response.success,
data: {
audit_logs: response.audit_logs || [],
next_cursor: response.maybe_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListAllImpersonationRequests(
cursor?: string | null,
limit?: number,
): Promise<
ApiResponse<{
impersonation_requests: ImpersonationRequest[];
next_cursor: string | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonation_requests/list`;
return await this.get<{
success: boolean;
impersonation_requests: ImpersonationRequest[];
maybe_cursor: string | null;
error_message?: string;
}>({
endpoint,
query: {
cursor: cursor ?? undefined,
limit,
},
})
.then((response) => ({
success: response.success,
data: {
impersonation_requests: response.impersonation_requests || [],
next_cursor: response.maybe_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserImpersonationRequests(
user_token: string,
cursor?: string | null,
limit?: number,
): Promise<
ApiResponse<{
impersonation_requests: ImpersonationRequest[];
next_cursor: string | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonation_requests/user/${user_token}`;
return await this.get<{
success: boolean;
impersonation_requests: ImpersonationRequest[];
maybe_cursor: string | null;
error_message?: string;
}>({
endpoint,
query: {
cursor: cursor ?? undefined,
limit,
},
})
.then((response) => ({
success: response.success,
data: {
impersonation_requests: response.impersonation_requests || [],
next_cursor: response.maybe_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserWallets(
user_token: string,
): Promise<ApiResponse<{ wallets: Wallet[] }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallets/user/${user_token}/list`;
return await this.get<{
success: boolean;
wallets: Wallet[];
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { wallets: response.wallets || [] },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async GetWallet(
wallet_token: string,
): Promise<ApiResponse<{ maybe_wallet: WalletWithOwner | null }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/${wallet_token}`;
return await this.get<{
success: boolean;
maybe_wallet: WalletWithOwner | null;
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { maybe_wallet: response.maybe_wallet },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async AddBankedBalance(
wallet_token: string,
credits: number,
): Promise<ApiResponse<void>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/${wallet_token}/add_banked_balance`;
return await this.post<
{ credits: number },
{ success: boolean; error_message?: string }
>({ endpoint, body: { credits } })
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListWalletLedgerEntries(
wallet_token: string,
): Promise<ApiResponse<{ entries: WalletLedgerEntryWithWallet[] }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet_ledger_entries/wallet/${wallet_token}/list`;
return await this.get<{
success: boolean;
entries: WalletLedgerEntryWithWallet[];
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { entries: response.entries || [] },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async GetWalletLedgerEntry(
wallet_ledger_entry_token: string,
): Promise<ApiResponse<{ maybe_entry: WalletLedgerEntry | null }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet_ledger_entry/${wallet_ledger_entry_token}`;
return await this.get<{
success: boolean;
maybe_entry: WalletLedgerEntry | null;
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { maybe_entry: response.maybe_entry },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async CreateWalletForUser(
user_token: string,
payments_namespace?: string,
): Promise<ApiResponse<void>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/create_for_user`;
return await this.post<
{ user_token: string; payments_namespace?: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: { user_token, payments_namespace },
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async GetJobByToken(
job_token: string,
): Promise<ApiResponse<{ maybe_job: ModerationJob | null }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/job/${encodeURIComponent(job_token)}`;
return await this.get<{
success: boolean;
job?: ModerationJob | null;
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { maybe_job: response.job ?? null },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListDebugLogs(
event_token: string,
limit?: number,
): Promise<ApiResponse<{ debug_logs: DebugLog[] }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/debug_logs/list/${encodeURIComponent(event_token)}`;
return await this.get<{
success: boolean;
debug_logs: DebugLog[];
error_message?: string;
}>({
endpoint,
query: { limit },
})
.then((response) => ({
success: response.success,
data: { debug_logs: response.debug_logs || [] },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserJobs(
user_token: string,
): Promise<ApiResponse<{ jobs: UserJob[] }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/jobs/user/${user_token}/list`;
return await this.get<{
success: boolean;
jobs: UserJob[];
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { jobs: response.jobs || [] },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListAllUsersBySignupDate(
id_cursor?: number | null,
): Promise<
ApiResponse<{ users: SignupUser[]; next_cursor: number | null }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/list_all_by_signup_date`;
return await this.post<
{ id_cursor?: number | null },
{
success: boolean;
users: SignupUser[];
next_cursor: number | null;
error_message?: string;
}
>({
endpoint,
body: { id_cursor: id_cursor ?? null },
})
.then((response) => ({
success: response.success,
data: {
users: response.users || [],
next_cursor: response.next_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListAvailableFeatureFlags(): Promise<
ApiResponse<{ feature_flags: FeatureFlagDescriptor[] }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/list`;
return await this.get<{
success: boolean;
feature_flags: FeatureFlagDescriptor[];
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: { feature_flags: response.feature_flags || [] },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserFeatureFlags(
username_or_token: string,
): Promise<ApiResponse<UserFeatureFlagsResponse>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/user/${encodeURIComponent(username_or_token)}`;
return await this.get<
{ success: boolean; error_message?: string } & UserFeatureFlagsResponse
>({ endpoint })
.then((response) => ({
success: response.success,
data: {
user_token: response.user_token,
username: response.username,
display_name: response.display_name,
feature_flags: response.feature_flags || [],
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async EditUserFeatureFlags(
username_or_token: string,
action: EditUserFeatureFlagsAction,
): Promise<ApiResponse<void>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/user/${encodeURIComponent(username_or_token)}`;
return await this.post<
{ action: EditUserFeatureFlagsAction },
{ success: boolean; error_message?: string }
>({ endpoint, body: { action } })
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListGlobalUserReferrals(
cursor?: string | null,
limit?: number,
): Promise<
ApiResponse<{
referrals: UserReferralListItem[];
next_cursor: string | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_referrals/list`;
return await this.get<{
success: boolean;
referrals: UserReferralListItem[];
maybe_cursor: string | null;
error_message?: string;
}>({
endpoint,
query: {
cursor: cursor ?? undefined,
limit,
},
})
.then((response) => ({
success: response.success,
data: {
referrals: response.referrals || [],
next_cursor: response.maybe_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserReferralsForUser(
username: string,
cursor?: string | null,
limit?: number,
): Promise<
ApiResponse<{
referrals: UserReferralListItem[];
next_cursor: string | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_referrals/user/${encodeURIComponent(username)}/list`;
return await this.get<{
success: boolean;
referrals: UserReferralListItem[];
maybe_cursor: string | null;
error_message?: string;
}>({
endpoint,
query: {
cursor: cursor ?? undefined,
limit,
},
})
.then((response) => ({
success: response.success,
data: {
referrals: response.referrals || [],
next_cursor: response.maybe_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListSubscribersBySignupDate(
id_cursor?: number | null,
): Promise<
ApiResponse<{ users: SubscriberUser[]; next_cursor: number | null }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/list_subscribers_by_signup_date`;
return await this.post<
{ id_cursor?: number | null },
{
success: boolean;
users: SubscriberUser[];
next_cursor: number | null;
error_message?: string;
}
>({
endpoint,
body: { id_cursor: id_cursor ?? null },
})
.then((response) => ({
success: response.success,
data: {
users: response.users || [],
next_cursor: response.next_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ChangeUserEmail(
user_token: string,
new_email: string,
): Promise<ApiResponse<void>> {
const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_emails/change`;
return await this.post<
{ user_token: string; new_email: string },
{ success: boolean; error_message?: string }
>({ endpoint, body: { user_token, new_email } })
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ListUserEmailChanges(
username: string,
cursor?: string | null,
limit?: number,
): Promise<
ApiResponse<{ changes: UserEmailChange[]; next_cursor: string | null }>
> {
const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_emails/list/${encodeURIComponent(username)}`;
return await this.get<{
success: boolean;
changes: UserEmailChange[];
maybe_cursor: string | null;
error_message?: string;
}>({
endpoint,
query: {
cursor: cursor ?? undefined,
limit,
},
})
.then((response) => ({
success: response.success,
data: {
changes: response.changes || [],
next_cursor: response.maybe_cursor ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
}
@@ -1,299 +0,0 @@
import { ApiManager, type ApiResponse } from "./ApiManager";
import type { UserInfo, AppStateResponse } from "@/types";
interface SignupRequest {
username: string;
email_address: string;
password: string;
password_confirmation: string;
signup_source?: string;
}
export interface OnboardingData {
email_not_set: boolean;
email_not_confirmed: boolean;
password_not_set: boolean;
username_not_customized: boolean;
}
export class UsersApi extends ApiManager {
public GetAppState(): Promise<ApiResponse<AppStateResponse>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/app_state`;
return this.get<AppStateResponse>({ endpoint })
.then((response) => ({
success: response.success,
data: response,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public GetUserProfile(username: string): Promise<
ApiResponse<{
user?: UserInfo;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/user/${username}/profile`;
return this.get<{
success: boolean;
user?: {
user_token: string;
username: string;
display_name: string;
email_gravatar_hash: string;
disable_gravatar: boolean;
created_at: string;
[key: string]: any;
};
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: {
user: response.user
? {
id: response.user.user_token,
username: response.user.username,
display_name: response.user.display_name,
email: "",
gravatar_hash: response.user.email_gravatar_hash,
disable_gravatar: response.user.disable_gravatar,
created_at: response.user.created_at,
}
: undefined,
},
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async Login({
usernameOrEmail,
password,
}: {
usernameOrEmail: string;
password: string;
}): Promise<ApiResponse<{ signedSession?: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/login`;
const body = {
username_or_email: usernameOrEmail,
password,
};
return await this.post<
{ username_or_email: string; password: string },
{
success: boolean;
signed_session?: string;
error_message?: string;
error_type?: string;
}
>({
endpoint,
body,
})
.then((response) => ({
success: response.success,
data: { signedSession: response.signed_session },
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public Logout(): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/logout`;
return this.post<null, { success: boolean; error_message?: string }>({
endpoint,
})
.then((response) => ({
success: response.success,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async Signup({
username,
email,
password,
passwordConfirmation,
signupSource,
}: {
username: string;
email: string;
password: string;
passwordConfirmation: string;
signupSource?: string;
}): Promise<ApiResponse<{ signedSession?: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/create_account`;
const body: SignupRequest = {
email_address: email,
password,
password_confirmation: passwordConfirmation,
username,
};
if (signupSource) {
body.signup_source = signupSource;
}
return await this.post<
SignupRequest,
{
success: boolean;
signed_session?: string;
error_fields?: Record<string, string>;
error_message?: string;
error_type?: string;
}
>({
endpoint,
body,
})
.then((response) => ({
success: response.success,
data: { signedSession: response.signed_session },
errorMessage:
response.error_message ||
Object.values(response.error_fields ?? {}).join(", "),
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async RequestPasswordReset({
email,
}: {
email: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/request_password_reset`;
return await this.post<
{ email: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: { email },
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ResetPassword({
token,
password,
passwordConfirmation,
}: {
token: string;
password: string;
passwordConfirmation: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/reset_password`;
return await this.post<
{ token: string; password: string; password_confirmation: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: {
token,
password,
password_confirmation: passwordConfirmation,
},
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async ChangePassword({
password,
passwordConfirmation,
}: {
password: string;
passwordConfirmation: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/user/change_password`;
return await this.post<
{ password: string; password_confirmation: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: {
password,
password_confirmation: passwordConfirmation,
},
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async EditEmail({
emailAddress,
}: {
emailAddress: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_email`;
return await this.post<
{ email_address: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: { email_address: emailAddress },
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async EditUsername({
displayName,
}: {
displayName: string;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_username`;
return await this.post<
{ display_name: string },
{ success: boolean; error_message?: string }
>({
endpoint,
body: { display_name: displayName },
})
.then((response) => ({
success: response.success,
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
}
@@ -1,614 +0,0 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72.22 65.68">
<defs>
<style>
.cls-1 {
fill: #2d81ff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M69.38,45.46c-3.18-6.24-6.58-12.49-9.86-18.53-3.31-6.08-6.72-12.37-9.91-18.63l-.05-.09C46.44,3.07,41.24,0,35.66,0s-10.34,3-13.13,8.23L.97,48.96l-.03.07c-1.51,3.8-1.2,8.03.82,11.31,1.9,3.08,5.01,4.96,8.76,5.28.38.03.75.05,1.1.05,2.94,0,4.98-1.12,7.33-2.41,7.13-3.91,19.45-11.21,27.61-16.04,2.6-1.54,4.95-2.93,6.31-3.73l10.71,20.43c.2.39.54.7.95.86.46.19.98.2,1.43,0,1.39-.66,3.05-2.47,3.07-2.49,5.22-5.9,3.03-11.55.34-16.83ZM34.53,14.8c.31-.46.77-.99,1.32-.99.58,0,1.29.57,1.85,1.49,1.57,2.56,2.99,5.44,4.35,8.22,1.34,2.73,2.72,5.55,4.29,8.15l.02.05-29.6,16.99L34.53,14.8Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 885 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,216 +0,0 @@
import { useEffect, useState } from "react";
import { ModerationApi } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@/components/ui/label";
import {
IconAlertCircle,
IconBan,
IconShieldCheck,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
interface BanUserDialogProps {
username: string;
displayName: string;
isBanned: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function BanUserDialog({
username,
displayName,
isBanned,
open,
onOpenChange,
onSuccess,
}: BanUserDialogProps) {
const [modNotes, setModNotes] = useState("");
const [confirmOpen, setConfirmOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) {
setModNotes("");
setConfirmOpen(false);
setIsSubmitting(false);
setError(null);
}
}, [open]);
const trimmedNotes = modNotes.trim();
const isValid = trimmedNotes.length > 0;
const willBan = !isBanned;
const actionLabel = willBan ? "Ban" : "Unban";
const ActionIcon = willBan ? IconBan : IconShieldCheck;
const handleConfirm = async () => {
if (!isValid || isSubmitting) return;
setIsSubmitting(true);
setError(null);
const modApi = new ModerationApi();
const resp = await modApi.ManageUserBan({
username,
is_banned: willBan,
mod_notes: trimmedNotes,
});
setIsSubmitting(false);
if (resp.success) {
setConfirmOpen(false);
onOpenChange(false);
onSuccess?.();
} else {
setError(resp.errorMessage || "Failed to update ban status");
setConfirmOpen(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ActionIcon
className={cn(
"size-5",
willBan ? "text-destructive" : "text-emerald-400",
)}
/>
{actionLabel} User
</DialogTitle>
<DialogDescription>
{willBan ? (
<>
You are about to ban{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}). They will lose access to their account.
</>
) : (
<>
You are about to unban{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}). They will regain access to their account.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mod-notes" className="text-sm font-medium">
Moderator notes
</Label>
<textarea
id="mod-notes"
rows={4}
placeholder="Reason for this action (required)"
value={modNotes}
onChange={(e) => setModNotes(e.target.value)}
className={cn(
"w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none resize-none",
"focus-visible:border-primary transition-colors",
"placeholder:text-muted-foreground dark:bg-input/30",
)}
/>
<p className="text-xs text-muted-foreground">
Recorded in the staff audit log.
</p>
</div>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
variant={willBan ? "destructive" : "default"}
disabled={!isValid || isSubmitting}
onClick={() => setConfirmOpen(true)}
>
<ActionIcon className="size-3.5" />
{actionLabel} User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Confirm {actionLabel}</AlertDialogTitle>
<AlertDialogDescription>
You are about to{" "}
<span className="font-semibold text-foreground">
{actionLabel.toLowerCase()}
</span>{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>{" "}
(@{username}). This action is recorded in the staff audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant={willBan ? "destructive" : "default"}
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<ActionIcon className="size-3.5" />
)}
{isSubmitting ? "Saving..." : `Confirm ${actionLabel}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,207 +0,0 @@
import { useState } from "react";
import { ModerationApi } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { IconAlertCircle, IconMail } from "@tabler/icons-react";
interface ChangeUserEmailDialogProps {
userToken: string;
username: string;
displayName: string;
currentEmail?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (newEmail: string) => void;
}
// Mirrors the backend's lenient format check; the server is authoritative.
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ChangeUserEmailDialog({
userToken,
username,
displayName,
currentEmail,
open,
onOpenChange,
onSuccess,
}: ChangeUserEmailDialogProps) {
const [newEmail, setNewEmail] = useState("");
const [confirmOpen, setConfirmOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset on close — covers Cancel, escape, outside-click and success paths,
// all of which flow through onOpenChange(false).
const handleOpenChange = (next: boolean) => {
if (!next) {
setNewEmail("");
setConfirmOpen(false);
setIsSubmitting(false);
setError(null);
}
onOpenChange(next);
};
const trimmedEmail = newEmail.trim().toLowerCase();
const isValidFormat = EMAIL_REGEX.test(trimmedEmail);
const isUnchanged =
!!currentEmail && trimmedEmail === currentEmail.trim().toLowerCase();
const canSubmit = isValidFormat && !isUnchanged;
const handleConfirm = async () => {
if (!canSubmit || isSubmitting) return;
setIsSubmitting(true);
setError(null);
const modApi = new ModerationApi();
const resp = await modApi.ChangeUserEmail(userToken, trimmedEmail);
setIsSubmitting(false);
if (resp.success) {
handleOpenChange(false);
onSuccess?.(trimmedEmail);
} else {
setError(resp.errorMessage || "Failed to change email address");
setConfirmOpen(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconMail className="size-5 text-muted-foreground" />
Change Email Address
</DialogTitle>
<DialogDescription>
Set a new email address for{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}).
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{currentEmail && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
Current email
</Label>
<span className="text-sm font-mono break-all">
{currentEmail}
</span>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-email" className="text-sm font-medium">
New email address
</Label>
<Input
id="new-email"
type="email"
autoComplete="off"
placeholder="new.address@example.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && canSubmit) setConfirmOpen(true);
}}
/>
<p className="text-xs text-muted-foreground">
Recorded in the email-change history and the staff audit log.
</p>
</div>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
disabled={!canSubmit || isSubmitting}
onClick={() => setConfirmOpen(true)}
>
<IconMail className="size-3.5" />
Change Email
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Confirm email change</AlertDialogTitle>
<AlertDialogDescription>
You are about to change the email address for{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>{" "}
(@{username}) to{" "}
<span className="font-semibold text-foreground break-all">
{trimmedEmail}
</span>
. This action is recorded in the staff audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconMail className="size-3.5" />
)}
{isSubmitting ? "Saving..." : "Confirm Change"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,174 +0,0 @@
import { useState } from "react";
import { ModerationApi } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { IconCoin } from "@tabler/icons-react";
interface GiveCreditsDialogProps {
walletToken: string;
displayName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function GiveCreditsDialog({
walletToken,
displayName,
open,
onOpenChange,
onSuccess,
}: GiveCreditsDialogProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [amount, setAmount] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const parsedAmount = parseInt(amount, 10);
const isValid = !isNaN(parsedAmount) && parsedAmount > 0;
const dollarValue = isValid ? (parsedAmount / 100).toFixed(2) : null;
const reset = () => {
setAmount("");
setConfirmOpen(false);
setIsSubmitting(false);
};
const handleConfirm = async () => {
if (!isValid) return;
setIsSubmitting(true);
const modApi = new ModerationApi();
const resp = await modApi.AddBankedBalance(walletToken, parsedAmount);
if (resp.success) {
onSuccess();
}
reset();
onOpenChange(false);
};
return (
<>
<Dialog
open={open}
onOpenChange={(o) => {
onOpenChange(o);
if (!o) reset();
}}
>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle>Give Banked Credits</DialogTitle>
<DialogDescription>
Add purchased (banked) credits to{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>
's wallet.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Input
type="number"
min={1}
placeholder="Amount of credits"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
100 credits = $1.00 (1 credit = 1&cent;)
{dollarValue && (
<span className="ml-1 text-foreground font-medium">
&middot; {parsedAmount.toLocaleString()} credits = $
{dollarValue} USD
</span>
)}
</p>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
size="sm"
disabled={!isValid}
onClick={() => setConfirmOpen(true)}
>
<IconCoin className="size-3.5" />
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Confirm Credit Addition</AlertDialogTitle>
<AlertDialogDescription>
You are about to add{" "}
<span className="font-semibold text-foreground">
{isValid ? parsedAmount.toLocaleString() : 0}
</span>{" "}
banked credits{" "}
{dollarValue && (
<>
(
<span className="font-semibold text-foreground">
${dollarValue}
</span>
){" "}
</>
)}
to{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>
. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconCoin className="size-3.5" />
)}
{isSubmitting ? "Adding..." : "Confirm"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,163 +0,0 @@
import { useEffect, useState } from "react";
import { ModerationApi } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
IconAlertCircle,
IconCheck,
IconCopy,
IconKey,
} from "@tabler/icons-react";
interface ImpersonateUserDialogProps {
userToken: string;
username: string;
displayName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ImpersonateUserDialog({
userToken,
username,
displayName,
open,
onOpenChange,
}: ImpersonateUserDialogProps) {
const [isLoading, setIsLoading] = useState(true);
const [impersonationToken, setImpersonationToken] = useState<string | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
useEffect(() => {
if (!open) {
setImpersonationToken(null);
setError(null);
setCopiedField(null);
setIsLoading(true);
return;
}
let cancelled = false;
setIsLoading(true);
setError(null);
setImpersonationToken(null);
const modApi = new ModerationApi();
modApi.ImpersonateUser({ user_token: userToken }).then((resp) => {
if (cancelled) return;
if (resp.success && resp.data) {
setImpersonationToken(resp.data.password_token);
} else {
setError(resp.errorMessage || "Failed to generate impersonation token");
}
setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [open, userToken]);
const copy = (text: string, field: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconKey className="size-5" />
Impersonate User
</DialogTitle>
<DialogDescription>
Use the credentials below to log in as{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>
.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Spinner className="size-6 opacity-60" />
</div>
)}
{error && !isLoading && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{impersonationToken && !isLoading && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Username
</span>
<Button
variant="outline"
size="sm"
className="h-9 justify-start text-xs font-mono px-2 overflow-hidden"
onClick={() => copy(username, "username")}
>
{copiedField === "username" ? (
<IconCheck className="size-3.5 shrink-0 text-green-500" />
) : (
<IconCopy className="size-3.5 shrink-0" />
)}
<span className="truncate">{username}</span>
</Button>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Password (Impersonation Token)
</span>
<Button
variant="outline"
size="sm"
className="h-9 justify-start text-xs font-mono px-2 overflow-hidden"
onClick={() => copy(impersonationToken, "token")}
>
{copiedField === "token" ? (
<IconCheck className="size-3.5 shrink-0 text-green-500" />
) : (
<IconCopy className="size-3.5 shrink-0" />
)}
<span className="truncate">{impersonationToken}</span>
</Button>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,298 +0,0 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { UserJob } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { IconBriefcase, IconExternalLink, IconCopy, IconCheck } from "@tabler/icons-react";
export function getJobStatusBadgeProps(jobStatus: string): {
variant: "default" | "secondary" | "destructive" | "outline";
className: string;
label: string;
isFailed: boolean;
isComplete: boolean;
} {
const status = jobStatus.toLowerCase();
const isFailed =
status.includes("dead") ||
status.includes("fail") ||
status.includes("error");
const isComplete = status.includes("complete") || status.includes("success");
if (isFailed) {
return {
variant: "secondary",
className: "bg-destructive/10 text-destructive border-transparent",
label: "Failed",
isFailed,
isComplete,
};
}
if (isComplete) {
return {
variant: "secondary",
className: "bg-emerald-500/10 text-emerald-400 border-transparent",
label: "Completed",
isFailed,
isComplete,
};
}
return {
variant: "outline",
className: "text-muted-foreground",
label: "Pending",
isFailed,
isComplete,
};
}
interface JobsTableProps {
jobs: UserJob[];
isLoading?: boolean;
skeletonRows?: number;
maxHeight?: number;
}
export function JobsTable({
jobs,
isLoading = false,
skeletonRows = 5,
maxHeight,
}: JobsTableProps) {
const [copiedToken, setCopiedToken] = useState<string | null>(null);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedToken(text);
setTimeout(() => setCopiedToken(null), 2000);
});
};
if (isLoading) {
return (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Token</TableHead>
<TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
<TableHead className="text-xs">Failure Reason</TableHead>
<TableHead className="text-xs">Refund</TableHead>
<TableHead className="text-xs">Result</TableHead>
<TableHead className="text-xs">3rd Party</TableHead>
<TableHead className="text-xs">3rd Party ID</TableHead>
<TableHead className="text-xs">Created</TableHead>
<TableHead className="text-xs">Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: skeletonRows }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20 rounded-full" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-14 ml-auto" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
if (jobs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconBriefcase className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No jobs found for this user.</p>
</div>
);
}
return (
<Table containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]" containerStyle={{ maxHeight: maxHeight ?? "60vh" }}>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Token</TableHead>
<TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
<TableHead className="text-xs">Failure Reason</TableHead>
<TableHead className="text-xs">Refund</TableHead>
<TableHead className="text-xs">Result</TableHead>
<TableHead className="text-xs">3rd Party</TableHead>
<TableHead className="text-xs">3rd Party ID</TableHead>
<TableHead className="text-xs">Created</TableHead>
<TableHead className="text-xs">Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => {
const badge = getJobStatusBadgeProps(job.job_status);
return (
<TableRow key={job.job_token} className="group">
<TableCell>
<div className="flex items-center gap-0.5">
<Link
to={`/moderation/job/${encodeURIComponent(job.job_token)}`}
className="text-[10px] font-mono text-muted-foreground hover:text-foreground hover:underline truncate max-w-[120px]"
title="Open job info"
>
{job.job_token}
</Link>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground opacity-60 group-hover:opacity-100 transition-opacity"
onClick={() => copyToClipboard(job.job_token)}
title="Copy job token"
>
{copiedToken === job.job_token ? (
<IconCheck className="size-3 text-emerald-400" />
) : (
<IconCopy className="size-3" />
)}
</Button>
</div>
</TableCell>
<TableCell>
<Badge variant={badge.variant} className={badge.className}>
{badge.label}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{job.credits_delta != null ? (
<span
className={
job.credits_delta > 0
? "text-emerald-400"
: job.credits_delta < 0
? "text-destructive"
: "text-muted-foreground"
}
>
{job.credits_delta > 0 ? "+" : ""}
{job.credits_delta.toLocaleString()}
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs max-w-xs">
{job.job_failure_reason ? (
<span className="text-destructive line-clamp-2">
{job.job_failure_reason}
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
{job.maybe_linked_refund_ledger_token ? (
<Badge
variant="outline"
className="text-muted-foreground text-[10px]"
>
Refunded
</Badge>
) : badge.isFailed &&
job.credits_delta != null &&
job.credits_delta < 0 ? (
<span className="text-[10px] text-amber-400 font-medium uppercase tracking-wider">
Needs refund
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
{job.on_success_result_media_token ? (
<a
href={`https://getartcraft.com/media/${job.on_success_result_media_token}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-foreground/80 hover:underline"
>
<IconExternalLink className="size-3" />
View
</a>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
{job.maybe_external_third_party ? (
<span className="font-medium">{job.maybe_external_third_party}</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell>
{job.maybe_external_third_party_id ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px] font-mono text-muted-foreground hover:text-foreground opacity-60 group-hover:opacity-100 transition-opacity"
onClick={() => copyToClipboard(job.maybe_external_third_party_id!)}
title="Copy 3rd party ID"
>
{copiedToken === job.maybe_external_third_party_id ? (
<IconCheck className="size-3 text-emerald-400" />
) : (
<IconCopy className="size-3" />
)}
<span className="truncate max-w-[120px] inline-block align-middle">{job.maybe_external_third_party_id}</span>
</Button>
) : (
<span className="text-muted-foreground/30 text-xs">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(job.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}{" "}
<span className="text-muted-foreground/50">{new Date(job.created_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(job.updated_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}{" "}
<span className="text-muted-foreground/50">{new Date(job.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -1,214 +0,0 @@
import { useState, useCallback } from "react";
import { Link } from "react-router-dom";
import {
IconPhoto,
IconVideo,
IconCube,
IconCloudUpload,
} from "@tabler/icons-react";
import { Spinner } from "@/components/ui/spinner";
import type { MediaInfo, ExploreMediaFile } from "@/api/MediaApi";
import { getMediaThumbnailUrl } from "@/api/MediaApi";
import { MediaDetailModal } from "@/components/MediaDetailModal";
export type MediaCardCreator = {
username: string;
display_name?: string;
gravatar_hash?: string;
};
interface MediaCardProps {
item: MediaInfo | ExploreMediaFile;
showCreator?: boolean;
/** Override or fallback when the item doesn't embed a creator (e.g. on a user profile page). */
creator?: MediaCardCreator | null;
onSelect?: (item: MediaInfo | ExploreMediaFile) => void;
}
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
};
function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, "0")}`;
}
function formatCreatedAt(iso: string): { short: string; full: string } | null {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const short =
d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}) +
" " +
d.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const full = d.toLocaleString();
return { short, full };
}
export function MediaCard({
item,
showCreator,
creator: creatorOverride,
onSelect,
}: MediaCardProps) {
const thumbnailUrl = getMediaThumbnailUrl(item, 512);
const mediaClass = item.media_class;
const FallbackIcon = classIcons[mediaClass] ?? IconPhoto;
const embeddedCreator =
"maybe_creator" in item ? item.maybe_creator : undefined;
const creator: MediaCardCreator | null | undefined =
creatorOverride ?? embeddedCreator;
const durationMs =
"maybe_duration_millis" in item ? item.maybe_duration_millis : undefined;
const createdAt = item.created_at ? formatCreatedAt(item.created_at) : null;
const [imgLoaded, setImgLoaded] = useState(!thumbnailUrl);
const [retryCount, setRetryCount] = useState(0);
const [modalOpen, setModalOpen] = useState(false);
const imgSrc = thumbnailUrl
? thumbnailUrl +
(retryCount
? `${thumbnailUrl.includes("?") ? "&" : "?"}_r=${retryCount}`
: "")
: undefined;
const handleError = () => {
setTimeout(() => setRetryCount((c) => c + 1), 5000);
};
const handleClick = useCallback(() => {
if (onSelect) {
onSelect(item);
} else {
setModalOpen(true);
}
}, [onSelect, item]);
const isUserUpload = Boolean(
(item as { is_user_upload?: boolean }).is_user_upload,
);
const hoverLabel = !isUserUpload
? item.maybe_title || item.maybe_text_transcript
: undefined;
const hasMetaFooter = showCreator && creator;
return (
<>
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleClick();
}}
draggable={false}
className="group relative rounded-xl overflow-hidden border bg-muted/30 cursor-pointer aspect-square"
>
{/* Top-left: media class + upload indicator */}
<div className="absolute top-1.5 left-1.5 z-10 flex items-center gap-1 flex-wrap">
<span className="px-2 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase">
{mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
</span>
{isUserUpload && (
<span
className="flex items-center gap-1 px-1.5 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase"
title="User upload"
>
<IconCloudUpload className="size-3" />
Uploaded
</span>
)}
</div>
{/* Top-right: created date/time + (optional) duration */}
<div className="absolute top-1.5 right-1.5 z-10 flex flex-col items-end gap-1">
{createdAt && (
<span
className="px-1.5 py-0.5 rounded-md bg-black/50 text-[10px] font-medium text-white backdrop-blur-sm tabular-nums whitespace-nowrap"
title={createdAt.full}
>
{createdAt.short}
</span>
)}
{durationMs != null && durationMs > 0 && (
<span className="px-1.5 py-0.5 rounded-md bg-black/50 text-[10px] font-medium text-white backdrop-blur-sm tabular-nums">
{formatDuration(durationMs)}
</span>
)}
</div>
{/* Thumbnail or fallback */}
{imgSrc ? (
<>
{!imgLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-5 opacity-40" />
</div>
)}
<img
src={imgSrc}
alt={item.maybe_title || item.token}
className={`w-full h-full transition-transform group-hover:scale-105 object-contain ${!imgLoaded ? "opacity-0" : "opacity-100"}`}
loading="lazy"
onLoad={() => setImgLoaded(true)}
onError={handleError}
/>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground">
<FallbackIcon className="size-14 opacity-30" />
</div>
)}
{/* Hover overlay — title / transcript (sits above the always-visible meta strip) */}
{hoverLabel && (
<div className="absolute inset-x-0 top-0 bottom-14 bg-linear-to-b from-black/70 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-start p-3 pointer-events-none z-10">
<div className="text-xs text-white/95 line-clamp-4">
{hoverLabel}
</div>
</div>
)}
{/* Always-visible metadata overlay (bottom): creator + token */}
<div className="absolute inset-x-0 bottom-0 z-20 px-2.5 pb-2 pt-8 bg-linear-to-t from-black/85 via-black/55 to-transparent flex flex-col gap-0.5 min-w-0">
{hasMetaFooter && (
<Link
to={`/user/profile/${creator!.username}`}
onClick={(e) => e.stopPropagation()}
className="text-sm font-medium text-white truncate hover:underline"
title={creator!.display_name || creator!.username}
>
@{creator!.username}
</Link>
)}
<span
className="font-mono text-[10px] text-white/55 truncate"
title={item.token}
>
{item.token}
</span>
</div>
</div>
{!onSelect && (
<MediaDetailModal
item={item}
creator={creatorOverride ?? undefined}
open={modalOpen}
onOpenChange={setModalOpen}
/>
)}
</>
);
}
@@ -1,457 +0,0 @@
import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import {
IconDownload,
IconPhoto,
IconVideo,
IconCube,
IconExternalLink,
IconPencil,
IconInfoCircle,
IconSettings,
IconChevronDown,
IconCopy,
IconCheck,
} from "@tabler/icons-react";
import { getIdenticonUrl } from "@/lib/identicon";
import type { MediaInfo, ExploreMediaFile, PromptInfo } from "@/api/MediaApi";
import { MediaApi, getMediaThumbnailUrl } from "@/api/MediaApi";
type MediaItem = MediaInfo | ExploreMediaFile;
export type MediaModalCreator = {
username: string;
display_name?: string;
gravatar_hash?: string;
};
interface MediaDetailModalProps {
item: MediaItem | null;
/** Fallback/override creator when the item itself doesn't embed one (e.g. from ListUserMediaFiles). */
creator?: MediaModalCreator;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const classLabels: Record<string, string> = {
image: "Image",
video: "Video",
dimensional: "3D",
};
const classIcons: Record<string, typeof IconPhoto> = {
video: IconVideo,
dimensional: IconCube,
};
function formatModelType(model: string): string {
return model
.replace(/_/g, " ")
.replace(/(\d)p(\d)/g, "$1.$2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
const aspectRatioLabels: Record<string, string> = {
auto: "Auto",
square: "1:1",
wide_three_by_two: "3:2",
wide_four_by_three: "4:3",
wide_five_by_four: "5:4",
wide_sixteen_by_nine: "16:9",
wide_twenty_one_by_nine: "21:9",
tall_two_by_three: "2:3",
tall_three_by_four: "3:4",
tall_four_by_five: "4:5",
tall_nine_by_sixteen: "9:16",
tall_nine_by_twenty_one: "9:21",
wide: "Wide",
tall: "Tall",
auto_2k: "Auto 2K",
auto_4k: "Auto 4K",
square_hd: "Square HD",
};
function getFullMediaUrl(item: MediaItem): string | undefined {
if ("media_links" in item && item.media_links?.cdn_url) {
return item.media_links.cdn_url;
}
if ("public_bucket_url" in item && item.public_bucket_url) {
return item.public_bucket_url as string;
}
return undefined;
}
function getEmbeddedCreator(item: MediaItem) {
return "maybe_creator" in item
? (item as ExploreMediaFile).maybe_creator
: undefined;
}
export function MediaDetailModal({
item,
creator: creatorOverride,
open,
onOpenChange,
}: MediaDetailModalProps) {
const navigate = useNavigate();
const [imgLoaded, setImgLoaded] = useState(false);
const [promptInfo, setPromptInfo] = useState<PromptInfo | null>(null);
const [promptFailed, setPromptFailed] = useState(false);
const [promptExpanded, setPromptExpanded] = useState(false);
const [isPromptOverflowing, setIsPromptOverflowing] = useState(false);
const promptRef = useRef<HTMLDivElement>(null);
const [additionalOpen, setAdditionalOpen] = useState(false);
const [copied, setCopied] = useState(false);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
setImgLoaded(false);
setPromptInfo(null);
setPromptFailed(false);
setPromptExpanded(false);
setAdditionalOpen(false);
setCopied(false);
}
onOpenChange(next);
},
[onOpenChange],
);
useEffect(() => {
if (!open || !item) return;
let cancelled = false;
const api = new MediaApi();
api.GetMediaFile(item.token).then((res) => {
if (cancelled) return;
const promptToken = res.data?.maybe_prompt_token;
if (!promptToken) {
setPromptFailed(true);
return;
}
api.GetPrompt(promptToken).then((promptRes) => {
if (cancelled) return;
if (promptRes.success && promptRes.data) {
setPromptInfo(promptRes.data);
} else {
setPromptFailed(true);
}
});
});
return () => {
cancelled = true;
};
}, [open, item?.token]);
const promptText =
promptInfo?.maybe_positive_prompt ||
item?.maybe_text_transcript ||
item?.maybe_title;
useLayoutEffect(() => {
if (promptExpanded) return;
const el = promptRef.current;
if (!el || !promptText) {
setIsPromptOverflowing(false);
return;
}
setIsPromptOverflowing(el.scrollHeight > el.clientHeight + 1);
}, [promptText, promptExpanded, open]);
if (!item) return null;
const thumbnailUrl = getMediaThumbnailUrl(item, 1024);
const fullUrl = getFullMediaUrl(item);
const creator = getEmbeddedCreator(item) ?? creatorOverride;
const FallbackIcon = classIcons[item.media_class] ?? IconPhoto;
const title = item.maybe_title || item.maybe_text_transcript || "Untitled";
const isVideo = item.media_class === "video";
const videoPoster = isVideo
? item.media_links?.maybe_video_previews?.thumbnail_template?.replace(
"{WIDTH}",
"1024",
) ||
item.media_links?.maybe_thumbnail_template?.replace("{WIDTH}", "1024") ||
undefined
: undefined;
const externalUrl = `https://getartcraft.com/media/${item.token}`;
const handleDownload = () => {
const url = fullUrl || thumbnailUrl;
if (url) window.open(url, "_blank");
};
const handleCreatorClick = () => {
if (!creator) return;
handleOpenChange(false);
navigate(`/user/profile/${creator.username}`);
};
const modelDisplay = promptInfo?.maybe_model_type
? formatModelType(promptInfo.maybe_model_type)
: null;
const handleCopyPrompt = async () => {
if (!promptText) return;
try {
await navigator.clipboard.writeText(promptText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
};
const generationMode = promptInfo?.maybe_generation_mode
? formatModelType(promptInfo.maybe_generation_mode)
: null;
const resolution = promptInfo?.maybe_resolution
? formatModelType(promptInfo.maybe_resolution)
: null;
const aspectRatio = promptInfo?.maybe_aspect_ratio
? aspectRatioLabels[promptInfo.maybe_aspect_ratio] || formatModelType(promptInfo.maybe_aspect_ratio)
: null;
const createdDate = item.created_at
? new Date(item.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-7xl p-0 gap-0 overflow-hidden"
overlayClassName="bg-black/80"
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<div className="flex flex-col md:flex-row h-[75vh]">
{/* Left: Media preview */}
<div className="relative flex-1 min-h-[300px] md:min-h-[400px] bg-black flex items-center justify-center overflow-hidden">
{isVideo && fullUrl ? (
<video
src={fullUrl}
controls
autoPlay
className="w-full h-full object-contain max-h-[85vh]"
poster={videoPoster}
/>
) : thumbnailUrl ? (
<>
{!imgLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-8 opacity-40" />
</div>
)}
<img
src={thumbnailUrl}
alt={title}
className={`w-full h-full object-contain max-h-[85vh] transition-opacity ${!imgLoaded ? "opacity-0" : "opacity-100"}`}
onLoad={() => setImgLoaded(true)}
/>
</>
) : (
<FallbackIcon className="size-24 opacity-20 text-white" />
)}
</div>
{/* Right: Details panel */}
<div className="w-full md:w-80 flex flex-col border-l border-border bg-background max-h-[85vh]">
{/* Creator header - sticky top */}
{creator && (
<button
type="button"
onClick={handleCreatorClick}
className="shrink-0 flex items-center gap-2.5 px-4 py-3 border-b border-border hover:bg-muted/50 transition-colors text-left cursor-pointer"
>
<Avatar>
<AvatarImage
src={
creator.gravatar_hash
? `https://gravatar.com/avatar/${creator.gravatar_hash}?s=64&d=404`
: undefined
}
/>
<AvatarFallback className="p-0">
<img
src={getIdenticonUrl(creator.username)}
alt={creator.username}
className="h-full w-full"
/>
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{creator.display_name || creator.username}
</div>
<div className="text-xs text-muted-foreground">Author</div>
</div>
</button>
)}
{/* Scrollable details */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* Prompt section */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<IconPencil className="size-3.5" />
Prompt
</div>
{promptText && (
<button
type="button"
onClick={handleCopyPrompt}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<IconCheck className="size-3.5 text-green-500" />
) : (
<IconCopy className="size-3.5" />
)}
</button>
)}
</div>
<div className="relative">
<div
ref={promptRef}
className={`text-sm bg-muted/50 p-2.5 leading-relaxed ${promptText && (isPromptOverflowing || promptExpanded) ? "rounded-t-lg" : "rounded-lg"} ${!promptExpanded ? "max-h-18 overflow-hidden" : ""}`}
>
{promptText ||
(promptFailed ? "No prompt available" : "Loading...")}
</div>
{promptText && (isPromptOverflowing || promptExpanded) && (
<button
type="button"
onClick={() => setPromptExpanded((v) => !v)}
className="flex items-center justify-between w-full text-xs text-muted-foreground hover:text-foreground mt-0 px-2.5 py-1.5 bg-muted/50 rounded-b-lg border-t border-border transition-colors cursor-pointer"
>
{promptExpanded ? "Collapse" : "See all"}
<IconChevronDown
className={`size-3.5 transition-transform ${promptExpanded ? "rotate-180" : ""}`}
/>
</button>
)}
</div>
</div>
{/* Information section */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
<IconInfoCircle className="size-3.5" />
Information
</div>
<div className="bg-muted/50 rounded-lg divide-y divide-border">
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Feature</span>
<span className="text-sm font-medium">
{generationMode ||
(classLabels[item.media_class]
? `Create ${classLabels[item.media_class]}`
: item.media_class)}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Model</span>
<span className="text-sm font-medium">
{modelDisplay ?? (
<span className="text-muted-foreground">
{promptFailed ? "Unknown" : "Loading..."}
</span>
)}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Type</span>
<span className="text-sm font-medium">{item.media_type}</span>
</div>
</div>
</div>
{/* Additional section */}
<div className="px-4 py-3">
<button
type="button"
onClick={() => setAdditionalOpen((v) => !v)}
className="flex items-center justify-between w-full"
>
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<IconSettings className="size-3.5" />
Additional
</div>
<IconChevronDown
className={`size-4 text-muted-foreground transition-transform ${additionalOpen ? "rotate-180" : ""}`}
/>
</button>
{additionalOpen && (
<div className="mt-3 bg-muted/50 rounded-lg divide-y divide-border">
{resolution && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Resolution</span>
<span className="text-sm font-medium">{resolution}</span>
</div>
)}
{aspectRatio && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Aspect Ratio</span>
<span className="text-sm font-medium">{aspectRatio}</span>
</div>
)}
{promptInfo?.maybe_generation_provider && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Provider</span>
<span className="text-sm font-medium">
{formatModelType(promptInfo.maybe_generation_provider)}
</span>
</div>
)}
{createdDate && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm font-medium">{createdDate}</span>
</div>
)}
{!resolution && !aspectRatio && !promptInfo?.maybe_generation_provider && !createdDate && (
<div className="px-3 py-2.5 text-sm text-muted-foreground">No additional details</div>
)}
</div>
)}
</div>
</div>
{/* Actions - sticky bottom */}
<div className="shrink-0 px-4 py-3 border-t border-border">
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleDownload}
>
<IconDownload className="size-4" />
Download
</Button>
<Button variant="outline" className="flex-1" asChild>
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconExternalLink className="size-4" />
View on Artcraft
</a>
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,22 +0,0 @@
import { cn } from "@/lib/utils";
interface RawTagProps {
value: string;
className?: string;
}
// Renders a raw backend enum value (e.g. "complete_success") in a small muted
// monospace pill. Pair next to a humanized label so debugging is unambiguous.
export function RawTag({ value, className }: RawTagProps) {
return (
<span
title="Raw backend value"
className={cn(
"text-[10px] font-mono text-muted-foreground/60 leading-none",
className,
)}
>
{value}
</span>
);
}
@@ -1,78 +0,0 @@
import { useState, type ComponentType, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardHeader } from "@/components/ui/card";
import { IconArrowRight, IconSearch } from "@tabler/icons-react";
interface TokenSearchFormProps {
title: string;
description: string;
inputLabel: string;
placeholder: string;
buttonLabel: string;
/** Builds the destination route given a trimmed token. */
buildHref: (token: string) => string;
icon: ComponentType<{ className?: string }>;
}
export function TokenSearchForm({
title,
description,
inputLabel,
placeholder,
buttonLabel,
buildHref,
icon: Icon,
}: TokenSearchFormProps) {
const navigate = useNavigate();
const [token, setToken] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = token.trim();
if (!trimmed) return;
navigate(buildHref(trimmed));
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<Icon className="size-6 text-muted-foreground" />
{title}
</h1>
<p className="text-muted-foreground">{description}</p>
</div>
<Card className="max-w-2xl">
<CardHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<label
htmlFor="token"
className="text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
{inputLabel}
</label>
<div className="flex gap-2">
<Input
id="token"
type="text"
placeholder={placeholder}
value={token}
onChange={(e) => setToken(e.target.value)}
className="flex-1 font-mono text-sm"
autoFocus
/>
<Button type="submit" disabled={!token.trim()}>
<IconSearch className="size-4" />
{buttonLabel}
<IconArrowRight className="size-4" />
</Button>
</div>
</form>
</CardHeader>
</Card>
</div>
);
}
@@ -1,378 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
ModerationApi,
type FeatureFlagDescriptor,
} from "@/api/ModerationApi";
import { getFeatureFlagIcon } from "@/lib/feature-flag-icon";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
IconAlertCircle,
IconFlag,
IconSearch,
IconTrash,
IconDeviceFloppy,
} from "@tabler/icons-react";
interface UserFeatureFlagsDialogProps {
usernameOrToken: string;
displayName: string;
username: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function UserFeatureFlagsDialog({
usernameOrToken,
displayName,
username,
open,
onOpenChange,
onSuccess,
}: UserFeatureFlagsDialogProps) {
const [availableFlags, setAvailableFlags] = useState<FeatureFlagDescriptor[]>(
[],
);
const [initialUserFlags, setInitialUserFlags] = useState<string[]>([]);
const [selectedFlags, setSelectedFlags] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
useEffect(() => {
if (!open) {
setFilter("");
setError(null);
setClearConfirmOpen(false);
return;
}
let cancelled = false;
setIsLoading(true);
setError(null);
setAvailableFlags([]);
setInitialUserFlags([]);
setSelectedFlags(new Set());
const api = new ModerationApi();
Promise.all([
api.ListAvailableFeatureFlags(),
api.ListUserFeatureFlags(usernameOrToken),
])
.then(([allResp, userResp]) => {
if (cancelled) return;
if (!allResp.success) {
setError(allResp.errorMessage || "Failed to load available flags");
return;
}
if (!userResp.success) {
setError(userResp.errorMessage || "Failed to load user flags");
return;
}
const all = allResp.data?.feature_flags ?? [];
const user = userResp.data?.feature_flags ?? [];
setAvailableFlags(all);
setInitialUserFlags(user);
setSelectedFlags(new Set(user));
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [open, usernameOrToken]);
const availableByKey = useMemo(() => {
const map = new Map<string, FeatureFlagDescriptor>();
for (const f of availableFlags) map.set(f.key, f);
return map;
}, [availableFlags]);
const allFlagRows = useMemo(() => {
const rows: FeatureFlagDescriptor[] = availableFlags.map((f) => ({ ...f }));
const seen = new Set(availableFlags.map((f) => f.key));
for (const key of initialUserFlags) {
if (!seen.has(key)) {
rows.push({ key, full_name: key, description: "" });
seen.add(key);
}
}
return rows.sort((a, b) => a.key.localeCompare(b.key));
}, [availableFlags, initialUserFlags]);
const filteredFlags = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return allFlagRows;
return allFlagRows.filter(
(f) =>
f.key.toLowerCase().includes(q) ||
f.full_name.toLowerCase().includes(q) ||
f.description.toLowerCase().includes(q),
);
}, [allFlagRows, filter]);
const hasChanges = useMemo(() => {
if (selectedFlags.size !== initialUserFlags.length) return true;
for (const f of initialUserFlags) {
if (!selectedFlags.has(f)) return true;
}
return false;
}, [selectedFlags, initialUserFlags]);
const toggleFlag = (flag: string, checked: boolean) => {
setSelectedFlags((prev) => {
const next = new Set(prev);
if (checked) next.add(flag);
else next.delete(flag);
return next;
});
};
const handleSave = async () => {
if (isSubmitting || !hasChanges) return;
setIsSubmitting(true);
setError(null);
const api = new ModerationApi();
const resp = await api.EditUserFeatureFlags(usernameOrToken, {
SetExactFlags: { flags: Array.from(selectedFlags).sort() },
});
setIsSubmitting(false);
if (resp.success) {
onSuccess?.();
onOpenChange(false);
} else {
setError(resp.errorMessage || "Failed to update feature flags");
}
};
const handleClearAll = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
setError(null);
const api = new ModerationApi();
const resp = await api.EditUserFeatureFlags(
usernameOrToken,
"ClearAllFlags",
);
setIsSubmitting(false);
setClearConfirmOpen(false);
if (resp.success) {
onSuccess?.();
onOpenChange(false);
} else {
setError(resp.errorMessage || "Failed to clear feature flags");
}
};
const selectedCount = selectedFlags.size;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-lg flex flex-col"
overlayClassName="bg-black/70"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFlag className="size-5 text-muted-foreground" />
Feature Flags
</DialogTitle>
<DialogDescription>
Manage feature flags for{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}).
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="relative">
<IconSearch className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Filter flags..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
disabled={isLoading}
className="pl-9"
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>
{isLoading
? "Loading..."
: `${selectedCount} of ${allFlagRows.length} enabled`}
</span>
{!isLoading && initialUserFlags.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => setClearConfirmOpen(true)}
disabled={isSubmitting}
>
<IconTrash className="size-3" />
Clear all
</Button>
)}
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-md border bg-muted/20">
{isLoading ? (
<div className="flex items-center justify-center py-10">
<Spinner className="size-5 opacity-50" />
</div>
) : filteredFlags.length === 0 ? (
<p className="text-sm text-muted-foreground italic px-3 py-6 text-center">
{allFlagRows.length === 0
? "No feature flags available."
: "No flags match your filter."}
</p>
) : (
<ul className="divide-y divide-border/50">
{filteredFlags.map((flag) => {
const checked = selectedFlags.has(flag.key);
const isUnknown = !availableByKey.has(flag.key);
const Icon = getFeatureFlagIcon(flag);
return (
<li key={flag.key}>
<label className="flex items-start gap-3 px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors">
<Checkbox
className="mt-0.5"
checked={checked}
onCheckedChange={(v) =>
toggleFlag(flag.key, v === true)
}
/>
<Icon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
{!isUnknown && flag.full_name && (
<span className="text-sm font-medium">
{flag.full_name}
</span>
)}
<span className="text-xs font-mono text-muted-foreground break-all">
{flag.key}
</span>
{isUnknown && (
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 opacity-70"
>
Unknown
</Badge>
)}
</div>
{flag.description && (
<p className="text-xs text-muted-foreground">
{flag.description}
</p>
)}
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
disabled={!hasChanges || isSubmitting || isLoading}
onClick={handleSave}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconDeviceFloppy className="size-3.5" />
)}
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Clear all feature flags?</AlertDialogTitle>
<AlertDialogDescription>
This will remove every feature flag currently enabled for{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>{" "}
(@{username}). This action is recorded in the staff audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleClearAll();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconTrash className="size-3.5" />
)}
{isSubmitting ? "Clearing..." : "Clear all flags"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,157 +0,0 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import {
ModerationApi,
type ModeratorUserLookupResponse,
} from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getIdenticonUrl } from "@/lib/identicon";
import {
IconSearch,
IconAlertCircle,
IconArrowRight,
IconUser,
} from "@tabler/icons-react";
function gravatarUrl(hash?: string): string | undefined {
return hash ? `https://gravatar.com/avatar/${hash}?s=60&d=404` : undefined;
}
interface UserLookupPanelProps {
title?: string;
user: ModeratorUserLookupResponse | null;
/** Override the email shown on the result card (e.g. after a change). */
displayEmail?: string | null;
onSelect: (user: ModeratorUserLookupResponse | null) => void;
/** Extra action buttons rendered on the result card, before the profile link. */
actions?: (user: ModeratorUserLookupResponse) => React.ReactNode;
}
export function UserLookupPanel({
title = "Find User",
user,
displayEmail,
onSelect,
actions,
}: UserLookupPanelProps) {
const [searchQuery, setSearchQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const query = searchQuery.trim();
if (!query || isSearching) return;
setIsSearching(true);
setSearchError(null);
const api = new ModerationApi();
const resp = await api.UserLookup(query);
setIsSearching(false);
if (resp.success && resp.data?.maybe_user) {
onSelect(resp.data.maybe_user);
} else {
onSelect(null);
setSearchError(resp.errorMessage || "User not found");
}
};
const email = displayEmail ?? user?.email_address;
return (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5 max-w-2xl">
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-[45px]">
<IconUser className="size-4" />
{title}
</h4>
<form onSubmit={handleSubmit} className="flex items-center gap-2 mb-3">
<Input
type="text"
placeholder="Username, email, or token..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={isSearching || !searchQuery.trim()}>
<IconSearch className="size-4" />
{isSearching ? "Searching..." : "Find"}
</Button>
</form>
{isSearching && (
<div className="rounded-lg border bg-muted/20 p-3 flex items-center gap-3">
<Skeleton className="size-12 rounded-md" />
<div className="flex flex-col gap-1.5 flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-8 w-28 rounded-md" />
</div>
)}
{searchError && !isSearching && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>
{searchError.charAt(0).toUpperCase() + searchError.slice(1)}
</AlertDescription>
</Alert>
)}
{user && !isSearching && (
<div className="rounded-lg border bg-muted/20 p-3 flex items-center gap-3">
<Avatar className="size-12 rounded-md shrink-0">
<AvatarImage
src={gravatarUrl(user.email_gravatar_hash)}
draggable={false}
/>
<AvatarFallback className="rounded-md p-0">
<img
src={getIdenticonUrl(user.username)}
alt={user.username}
className="size-full"
draggable={false}
/>
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold leading-tight font-outfit truncate">
{user.display_name || user.username}
</span>
{user.is_banned && (
<Badge variant="destructive" className="text-[10px]">
Banned
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground truncate">
@{user.username}
</span>
{email && (
<span className="text-xs text-muted-foreground/80 font-mono truncate">
{email}
</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{actions?.(user)}
<Button variant="ghost" size="icon" className="size-8" asChild>
<Link to={`/user/profile/${user.username}`} title="View profile">
<IconArrowRight className="size-4" />
</Link>
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,239 +0,0 @@
import * as React from "react";
import {
IconBrandStripe,
IconBriefcase,
IconBug,
IconCamera,
IconCompass,
IconCreditCard,
IconDashboard,
IconFlag,
IconKey,
IconClipboardList,
IconMail,
IconBellRinging,
IconFileAi,
IconFileDescription,
IconSettings,
IconShare,
IconUserPlus,
IconUsers,
} from "@tabler/icons-react";
//import { NavDocuments } from "@/components/nav-documents";
import { NavMain } from "@/components/nav-main";
import { NavSecondary } from "@/components/nav-secondary";
import { NavUser } from "@/components/nav-user";
import { SettingsDialog } from "@/components/settings-dialog";
import artcraftIcon from "@/assets/artcraft-icon.svg";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Link } from "react-router-dom";
const data = {
navMain: [
{
title: "Dashboard",
url: "/",
icon: IconDashboard,
},
{
title: "Explore Media",
url: "/explore/media",
icon: IconCompass,
},
{
title: "User Search",
url: "/user/search",
icon: IconUsers,
},
{
title: "Stripe Lookup",
url: "/stripe-lookup",
icon: IconBrandStripe,
},
{
title: "Search Job by Token",
url: "/moderation/job-search",
icon: IconBriefcase,
},
{
title: "Search Debug Logs",
url: "/moderation/debug-logs-search",
icon: IconBug,
},
{
title: "User Signups",
url: "/user-signups",
icon: IconUserPlus,
},
{
title: "Subscriber Signups",
url: "/subscriber-signups",
icon: IconCreditCard,
},
{
title: "Impersonation Logs",
url: "/impersonation",
icon: IconKey,
},
{
title: "Staff Audit Logs",
url: "/staff-audit-logs",
icon: IconClipboardList,
},
{
title: "Send Pager",
url: "/send-pager",
icon: IconBellRinging,
},
{
title: "Feature Flags",
url: "/feature-flags",
icon: IconFlag,
},
{
title: "Email Changes",
url: "/email-changes",
icon: IconMail,
},
{
title: "Referrals",
url: "/referrals",
icon: IconShare,
},
],
navClouds: [
{
title: "Capture",
icon: IconCamera,
isActive: true,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Proposal",
icon: IconFileDescription,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Prompts",
icon: IconFileAi,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
],
navSecondary: [
{
title: "Settings",
url: "#",
icon: IconSettings,
},
],
// documents: [
// {
// name: "Data Library",
// url: "#",
// icon: IconDatabase,
// },
// {
// name: "Reports",
// url: "#",
// icon: IconReport,
// },
// {
// name: "Word Assistant",
// url: "#",
// icon: IconFileWord,
// },
// ],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const [settingsOpen, setSettingsOpen] = React.useState(false);
const navSecondaryConfig = React.useMemo(
() =>
data.navSecondary.map((item) => {
if (item.title === "Settings") {
return {
...item,
onClick: () => setSettingsOpen(true),
};
}
return item;
}),
[],
);
return (
<>
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:p-1.5! text-foreground"
>
<Link to="/">
<img
src={artcraftIcon}
className="size-5"
alt="Artcraft Icon"
/>
<span className="text-[1.06rem] font-semibold font-outfit">
ArtCraft Admin
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
{/* <NavDocuments items={data.documents} /> */}
<NavSecondary items={navSecondaryConfig} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
);
}
@@ -1,291 +0,0 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
export const description = "An interactive area chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}
@@ -1,805 +0,0 @@
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type Row,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:bg-transparent"
>
<IconGripVertical className="size-3 text-muted-foreground" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="px-1.5 text-muted-foreground">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="px-1.5 text-muted-foreground">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="h-8 w-16 border-transparent bg-transparent text-right shadow-none hover:bg-input/30 focus-visible:border focus-visible:bg-background dark:bg-transparent dark:hover:bg-input/30 dark:focus-visible:bg-input/30"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="h-8 w-16 border-transparent bg-transparent text-right shadow-none hover:bg-input/30 focus-visible:border focus-visible:bg-background dark:bg-transparent dark:hover:bg-input/30 dark:focus-visible:bg-input/30"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex size-8 text-muted-foreground data-[state=open]:bg-muted"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:bg-muted-foreground/30 **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="w-fit px-0 text-left text-foreground">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
@@ -1,92 +0,0 @@
"use client"
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="rounded-sm data-[state=open]:bg-accent"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
@@ -1,54 +0,0 @@
import { Link, useLocation } from "react-router-dom";
import { type Icon } from "@tabler/icons-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: Icon;
}[];
}) {
const { pathname } = useLocation();
const { isMobile, setOpenMobile } = useSidebar();
const handleClick = () => {
if (isMobile) {
setOpenMobile(false);
}
};
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
onClick={handleClick}
asChild
>
<Link to={item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -1,62 +0,0 @@
"use client";
import * as React from "react";
import { type Icon } from "@tabler/icons-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: Icon;
onClick?: (e: React.MouseEvent) => void;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const { isMobile, setOpenMobile } = useSidebar();
const handleClick = (
e: React.MouseEvent,
itemOnClick?: (e: React.MouseEvent) => void,
) => {
if (itemOnClick) {
e.preventDefault();
itemOnClick(e);
}
if (isMobile) {
setOpenMobile(false);
}
};
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
onClick={(e) => handleClick(e, item.onClick)}
>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -1,112 +0,0 @@
import {
IconDotsVertical,
IconLogout,
IconSun,
IconMoon,
} from "@tabler/icons-react";
import { useTheme } from "next-themes";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getIdenticonUrl } from "@/lib/identicon";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { useAuth } from "@/context/AuthContext";
import { UsersApi } from "@/api/UsersApi";
export function NavUser() {
const { isMobile } = useSidebar();
const { user, logoutState } = useAuth();
const { theme, setTheme } = useTheme();
if (!user) return null;
const displayName = user.display_name || user.username;
const avatarSrc = user.gravatar_url || user.avatar_url;
const identiconSrc = getIdenticonUrl(user.username || user.email);
const handleLogout = async () => {
try {
const api = new UsersApi();
await api.Logout();
} catch {
// proceed with local logout regardless
}
logoutState();
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={avatarSrc} alt={displayName} />
<AvatarFallback className="rounded-lg p-0">
<img src={identiconSrc} alt={displayName} className="h-full w-full" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={avatarSrc} alt={displayName} />
<AvatarFallback className="rounded-lg p-0">
<img src={identiconSrc} alt={displayName} className="h-full w-full" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{theme === "dark" ? <IconSun /> : <IconMoon />}
Toggle {theme === "dark" ? "light mode" : "dark mode"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
@@ -1,102 +0,0 @@
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export function SectionCards() {
return (
<div className="grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <IconTrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
)
}
@@ -1,81 +0,0 @@
import { IconSettings } from "@tabler/icons-react";
import { useTheme } from "next-themes";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
export function SettingsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { theme, setTheme } = useTheme();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-none sm:max-w-[800px] w-full p-0 gap-0 overflow-hidden h-dvh sm:h-[600px] flex flex-col sm:flex-row rounded-none sm:rounded-[12px] border-border bg-background shadow-lg">
<DialogTitle className="sr-only">Settings</DialogTitle>
{/* Left Sidebar / Top Nav on mobile */}
<div className="w-full sm:w-[240px] border-b sm:border-b-0 sm:border-r border-border bg-muted/20 p-2 sm:p-4 flex flex-row sm:flex-col shrink-0 overflow-x-auto sm:overflow-x-visible sm:overflow-y-auto hide-scrollbar sm:show-scrollbar">
<div className="flex flex-row sm:flex-col gap-1 w-full">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1 tracking-wider mb-1 hidden sm:block">
Settings
</div>
<button
className={cn(
"flex items-center whitespace-nowrap gap-2 px-3 sm:px-2 py-1.5 rounded-md text-sm font-medium transition-colors cursor-pointer shrink-0",
"bg-accent/50 text-accent-foreground", // Currently active tab
)}
>
<IconSettings className="size-4 shrink-0" />
General
</button>
</div>
</div>
{/* Right Content */}
<div className="flex-1 overflow-y-auto p-6 bg-background relative">
<div className="max-w-xl flex flex-col gap-6">
<div className="flex flex-col gap-1 border-b border-border pb-6">
<h2 className="text-xl font-bold tracking-tight">General</h2>
<p className="text-muted-foreground text-sm">
Choose how you want your dashboard to look and behave.
</p>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-muted-foreground">
Appearance
</h3>
<div className="flex items-center justify-between py-2">
<div className="flex flex-col gap-1">
<Label htmlFor="dark-mode" className="text-sm font-medium">
Dark Mode
</Label>
<p className="text-xs text-muted-foreground">
Enable dark theme for the dashboard
</p>
</div>
<Switch
id="dark-mode"
checked={theme === "dark"}
onCheckedChange={(checked) =>
setTheme(checked ? "dark" : "light")
}
/>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,30 +0,0 @@
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
export function SiteHeader() {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">Documents</h1>
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
<a
href="https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(examples)/dashboard"
rel="noopener noreferrer"
target="_blank"
className="dark:text-foreground"
>
GitHub
</a>
</Button>
</div>
</div>
</header>
)
}
@@ -1,6 +0,0 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -1,196 +0,0 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
overlayClassName,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
overlayClassName?: string
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay className={overlayClassName} />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
@@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/50 bg-destructive/10 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -1,107 +0,0 @@
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-accent font-semibold text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
};
@@ -1,48 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -1,109 +0,0 @@
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -1,64 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md cursor-pointer text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:hover:bg-destructive/80 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-12 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -1,355 +0,0 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
@@ -1,30 +0,0 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

Some files were not shown because too many files have changed in this diff Show More