commit b4630274682375f87a23b4f00a04600f52b511d9 Author: sharbelxyz Date: Sun Jul 26 09:33:24 2026 +0200 Hermy HQ: self-hostable mission-control template for your Hermes agent A Next.js 16 + Prisma/Postgres dashboard that pairs with a local Hermes agent over a Postgres message bus: dispatch work, approve side-effecting actions, browse the agent's memory, and watch it run. Ships with an agent-onboarding prompt (ONBOARDING.md) so your Hermes can install it for you step by step. All secrets are env-configured; nothing sensitive is bundled. Co-Authored-By: Claude Opus 4.8 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..003ad36 --- /dev/null +++ b/.env.example @@ -0,0 +1,126 @@ +# ============================================================================ +# Hermy HQ — environment template +# ---------------------------------------------------------------------------- +# Copy this file to `.env` and fill in your own values: +# cp .env.example .env +# +# NEVER commit your real .env. The values below are FAKE placeholders — replace +# every one of them. When you deploy, set the same vars in your host (Vercel → +# Project → Settings → Environment Variables). +# ============================================================================ + + +# ─── REQUIRED · Core ───────────────────────────────────────────── +# The dashboard will not run without these. + +# Postgres connection string. This same database is the message bus shared +# with your Hermes bridge. Get one from Neon, Prisma Postgres, Supabase, or +# Vercel Postgres. Keep sslmode=require for hosted databases. +DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" + +# Same Postgres URL again (some tooling reads POSTGRES_URL). Usually identical +# to DATABASE_URL. +POSTGRES_URL="postgresql://user:pass@host:5432/db?sslmode=require" + +# Public URL of the site. Use http://localhost:3000 locally, and your real +# domain in production (e.g. https://your-app.vercel.app). +NEXTAUTH_URL="http://localhost:3000" + +# Secret used to sign NextAuth sessions. Generate one with: +# openssl rand -base64 32 +NEXTAUTH_SECRET="your-generated-secret" + +# Google OAuth credentials for login. Create an OAuth 2.0 Client (type: Web) +# at https://console.cloud.google.com/apis/credentials and add the redirect +# URI: /api/auth/callback/google +GOOGLE_CLIENT_ID="xxxxxxxxxxxx.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="your-google-client-secret" + +# Comma-separated allowlist of emails permitted to sign in. Anyone else is +# rejected even with a valid Google account. +ALLOWED_EMAILS="you@example.com,teammate@example.com" + +# Your display name, shown in the dashboard UI. Public (NEXT_PUBLIC_). +NEXT_PUBLIC_OWNER_NAME="Owner" + +# Public base URL of the site. Usually the same as NEXTAUTH_URL. Public. +NEXT_PUBLIC_BASE_URL="http://localhost:3000" + + +# ─── Hermes / bridge ───────────────────────────────────────────── +# How the website coordinates with your local Hermes agent via the bridge. +# The bridge (hermes-bridge/) reads HERMES_* and DATABASE_URL on your machine; +# the website reads the shared ones below for scheduling and internal calls. + +# Kanban board slug the bridge mirrors into HermesTask. +HERMES_BOARD="default" + +# Path to the `hermes` CLI on the bridge machine, if it is not already on PATH. +HERMES_BIN="hermes" + +# Path to your Hermes memory-wiki root (git-tracked markdown) that the bridge +# mirrors into HermesMemory. Set on the bridge machine. +HERMES_WIKI="/path/to/hermes/wiki" + +# Hour of day (0-23, local time) to generate the Chief-of-Staff daily brief. +BRIEF_HOUR="7" + +# Shared secret required by internal API routes (agent bus, bridge callbacks). +# Generate with: openssl rand -hex 32 +INTERNAL_API_SECRET="your-internal-api-secret" + +# Shared secret required by scheduled/cron endpoints so only your scheduler can +# trigger them. Generate with: openssl rand -hex 32 +CRON_SECRET="your-cron-secret" + + +# ─── OPTIONAL · AI providers ───────────────────────────────────── +# Only needed for features that call an LLM (briefs, scoring, content). Leave +# blank to disable those features. + +# OpenAI API key — https://platform.openai.com/api-keys +OPENAI_API_KEY="sk-xxxxx" + +# OpenRouter API key (multi-model gateway) — https://openrouter.ai/keys +OPENROUTER_API_KEY="sk-or-xxxxx" + +# xAI (Grok) API key — https://console.x.ai +XAI_API_KEY="xai-xxxxx" + +# Brave Search API key, used for web lookups — https://brave.com/search/api +BRAVE_API_KEY="your-brave-api-key" + + +# ─── OPTIONAL · YouTube ────────────────────────────────────────── +# Only needed for the YouTube idea/script features. Get a key from the Google +# Cloud console (enable "YouTube Data API v3"). + +YOUTUBE_API_KEY="your-youtube-api-key" +# The channel ID you want to pull stats/videos for (starts with UC...). +YOUTUBE_CHANNEL_ID="UCxxxxxxxxxxxxxxxxxxxxxx" + + +# ─── OPTIONAL · X / Twitter ────────────────────────────────────── +# Only needed for Content OS metrics. Create an app at +# https://developer.twitter.com and copy the Bearer token. +TWITTER_BEARER_TOKEN="your-twitter-bearer-token" + + +# ─── OPTIONAL · Notion ─────────────────────────────────────────── +# Only needed if you sync clients/data from Notion. Create an integration at +# https://www.notion.so/my-integrations and share the relevant pages with it. +NOTION_API_KEY="secret_xxxxx" + + +# ─── OPTIONAL · Client Pulse ───────────────────────────────────── +# The Client Pulse board reads client-health data from your Postgres. This +# template ships the dashboard + API only — wire up your own ingestion (a cron, +# a bot, a webhook) to populate it. Skip this block if you don't use the board. +# Admin secret for the Client Pulse admin/map-chat routes (falls back to CRON_SECRET). +CLIENT_PULSE_ADMIN_SECRET="your-client-pulse-admin-secret" + + +# ─── OPTIONAL · Trading / Watchlist Radar ──────────────────────── +# Only needed if you use the trading-related widgets. Leave blank otherwise. +# A public wallet address to display balances/positions for (read-only). +HL_WALLET="0xyour-wallet-address" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c34b3c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local +/services/client-pulse-bot/dist/ diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..07ba0a3 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,14 @@ +scanner/ +data/ +scripts/ +*.backup +*.bak +*.fix +*.fix2 +*.fix3 +src/app/globals.css.backup +src/app/page.tsx.backup +src/components/sidebar.tsx.backup +src/components/sidebar.tsx.bak +prisma/migrations/ +.env* diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 0000000..f9ce7e7 --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,137 @@ +# Hermy HQ — Agent Onboarding Prompt + +> **How to use this:** copy everything in the block below and paste it to your +> Hermes agent, in the directory where you cloned this repo. Hermes will walk you +> through the whole install, pausing to ask you for each real secret. It will not +> invent credentials, and it will keep side-effecting actions approval-gated. + +--- + +You are helping your operator install **Hermy HQ**, a self-hostable mission-control +dashboard (Next.js 16, React 19, Tailwind v4, Prisma + Postgres, NextAuth Google +login) that pairs with you, their local Hermes agent, over a shared Postgres database +used as a message bus. A small **bridge** on the operator's machine polls Postgres +for tasks, runs them via your `hermes` CLI, and mirrors your state (kanban, cron, +health, memory) back to the website. + +Guide the operator through the numbered steps below, one at a time. Follow these +rules throughout: + +- **Never fabricate or guess secrets.** Whenever a step needs a real value (a + database URL, an OAuth secret, an API key), STOP and ask the operator to paste it. + Wait for their answer before continuing. If they do not have one yet, tell them + exactly where to get it and pause. +- **Confirm before acting.** Show the command you are about to run and wait for a go + signal before running anything that changes their system or their accounts. +- **Keep side-effecting actions approval-gated.** Do not disable, bypass, or work + around the approval flow. Anything that writes to external services stays gated. +- **Explain results.** After each step, briefly confirm what happened and what comes + next. If something errors, help debug before moving on. +- **Redact secrets** in anything you echo back. Never print full keys or tokens. + +Work through the steps in order: + +### Step 1 — Confirm prerequisites +Verify the operator has each of these; ask them to confirm or help them install: +- **Node.js 20+** (`node -v`) and **git** (`git --version`) +- A **PostgreSQL database URL** they control (Neon, Prisma Postgres, Supabase, or + Vercel Postgres). Ask them to have the connection string ready — do not ask them + to paste it yet. +- A **Vercel account** (for deploying the website) +- A **Google OAuth app** (OAuth 2.0 Web client) for login. If they do not have one, + point them to https://console.cloud.google.com/apis/credentials. +- **You**, Hermes, are installed and reachable — confirm the `hermes` CLI resolves + on this machine (`which hermes`). + +### Step 2 — Clone and install dependencies +If the repo is not already present, clone it, then install: +```sh +git clone hermy-hq && cd hermy-hq +npm install +``` +Confirm `npm install` finished without errors. + +### Step 3 — Create the `.env` file +Copy the template and fill it in together: +```sh +cp .env.example .env +``` +Open `.env` and go through the **Required · Core** group with the operator. For each +of these, ask them to paste the real value, then write it into `.env` for them — +never invent one: +- `DATABASE_URL` and `POSTGRES_URL` (usually the same Postgres URL) +- `NEXTAUTH_URL` — use `http://localhost:3000` for now +- `NEXTAUTH_SECRET` — you MAY generate this one locally with + `openssl rand -base64 32` (it is a random secret, not an account credential), + then show it to the operator +- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` — ask the operator to paste from + their Google OAuth app +- `ALLOWED_EMAILS` — the Google email(s) allowed to sign in +- `NEXT_PUBLIC_OWNER_NAME` and `NEXT_PUBLIC_BASE_URL` + +Then set the **Hermes / bridge** group: `HERMES_BOARD`, `HERMES_BIN`, `HERMES_WIKI`, +`BRIEF_HOUR`, and generate `INTERNAL_API_SECRET` and `CRON_SECRET` +(`openssl rand -hex 32` each). Leave the **Optional** groups blank unless the +operator wants those features now. + +### Step 4 — Create the database tables +Push the Prisma schema to their Postgres: +```sh +npx prisma db push +``` +Confirm it reports the schema is in sync. If it fails, the `DATABASE_URL` is almost +always the cause — recheck it with the operator. + +### Step 5 — Run locally to verify +```sh +npm run dev +``` +Ask the operator to open http://localhost:3000 and sign in with a Google account +listed in `ALLOWED_EMAILS`. Confirm they see the dashboard. Stop the dev server when +they confirm. + +### Step 6 — Deploy to Vercel +Guide them through: +```sh +npm i -g vercel # if needed +vercel # link the project + preview deploy +vercel --prod # production +``` +Then have the operator: +1. Add **every** variable from `.env` in Vercel → Project → Settings → Environment + Variables. Ask them to paste values into Vercel themselves (or read them back so + you can guide) — do not fabricate any. +2. Update `NEXTAUTH_URL` and `NEXT_PUBLIC_BASE_URL` to the production URL, both + locally and in Vercel. +3. In the Google Cloud console, add the authorized redirect URI + `https:///api/auth/callback/google`. +Confirm they can sign in on the production URL. + +### Step 7 — Set up the bridge (connect the bus) +On this machine (where you, Hermes, live), set up the bridge so the website and you +share the Postgres bus: +```sh +cd hermes-bridge +npm install +``` +Do a one-off test run using the **same** `DATABASE_URL` as the website (ask the +operator to confirm it matches): +```sh +DATABASE_URL='postgres://…same as website…' HERMES_BOARD=default node bridge.mjs +``` +You should see `hermes-bridge up …` in the logs. Then make it permanent: +- **macOS:** edit the placeholders in `hermes-bridge/ai.hermyhq.bridge.plist` (path, + `DATABASE_URL`, `PATH`), copy it to `~/Library/LaunchAgents/`, and + `launchctl load` it. +- **Linux:** create an equivalent systemd service that runs + `node /path/to/hermes-bridge/bridge.mjs` with `DATABASE_URL` set, and enable it. +See `hermes-bridge/README.md` for the exact commands. + +### Step 8 — Verify the bridge connected +Ask the operator to open the website's `/hermes` activity feed and confirm a +**"Bridge connected"** event appears. Once it does, the loop is closed: the website +can dispatch requests, and you will pick up `queued`/`approved` ones and mirror your +kanban, cron, health, and memory back. + +Finally, remind the operator: side-effecting requests land in the **Approval Inbox** +and will not run until they approve them — this is intentional, keep it that way. diff --git a/README.md b/README.md new file mode 100644 index 0000000..646f532 --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# Hermy HQ + +**Your own mission control for a self-hosted AI agent.** + +Hermy HQ is a self-hostable dashboard template that pairs with your own local +[Hermes agent](https://github.com/NousResearch/hermes-agent). The website is your +cockpit: dispatch work, review the agent's plans, approve anything with side effects, +browse its memory, and watch it run — all from one place. Your Hermes stays on your +own machine; the website and the agent talk to each other through a shared Postgres +database acting as a message bus. + +It is a **template and starting point**, not a hosted product. You bring your own +database, your own Hermes, and your own API keys. Everything is configured through +environment variables. + +--- + +## 🤖 Easiest setup: let your agent do it + +> **Send this repo to your Hermes agent and paste [`ONBOARDING.md`](./ONBOARDING.md).** +> It is a ready-to-use prompt that walks your operator through the entire install +> step by step — prerequisites, `.env`, database, local run, Vercel deploy, and the +> bridge — pausing to ask *you* for each real secret. It never invents credentials, +> and it keeps every side-effecting action approval-gated. + +Prefer to do it by hand? The [Quick start](#quick-start) below has you covered. + +--- + +## Features + +**Cockpit** +- Dashboard home with an at-a-glance view of the agent, tasks, and the day +- ⌘K command palette to jump anywhere or fire an action +- Chief-of-Staff daily brief, generated on a schedule + +**Hermes control hub** (`/hermes`) +- Dispatch one-shot prompts or kanban tasks to the agent +- Approval inbox — side-effecting requests wait here for your explicit go-ahead +- Live activity feed and run history +- Cron list and health mirrored from the agent + +**Memory** +- Memory Wiki — browse and edit the agent's memory (facts, notes, links) as a wiki + +**Work & content** +- Tasks and Ideas boards +- Content OS — an X / Twitter content pipeline (drafts, scoring, scheduling) +- Longform + YouTube script studios +- Client Pulse — a client-health tracking board (bring your own data ingestion) +- Agents roster, Garden, and Watchlist Radar + +> Some features need your own API keys (YouTube, X/Twitter, OpenAI, etc.). They are +> all optional and configured via env — the core dashboard runs without them. + +--- + +## Architecture + +The deployed website never talks to your agent directly. Both sides read and write +the **same Postgres database**, which acts as a message bus. A small **bridge** +process runs on the machine where Hermes lives. + +``` + ┌───────────────────────┐ ┌────────────────────────┐ + │ Hermy HQ (website) │ │ Your machine / server │ + │ Next.js on Vercel │ │ │ + │ │ │ ┌───────────────┐ │ + │ • dispatch requests │ │ │ hermes-bridge │ │ + │ • approval inbox │ │ │ (bridge.mjs) │ │ + │ • activity / runs │ │ └──────┬────────┘ │ + │ • memory wiki │ │ │ hermes CLI │ + └──────────┬────────────┘ │ ┌──────▼────────┐ │ + │ │ │ Hermes │ │ + │ │ │ agent │ │ + │ │ └───────────────┘ │ + │ └───────────┬────────────┘ + │ │ + ▼ ▼ + ┌────────────────────────────────────────────────────────┐ + │ Postgres (message bus) │ + │ AgentRequest · AgentEvent · HermesTask · HermesMemory │ + │ DataStore · Brief · … │ + └────────────────────────────────────────────────────────┘ +``` + +- **Website → agent:** the site inserts an `AgentRequest`. Safe requests are + `queued`; anything side-effecting is `awaiting_approval` until you approve it in + the inbox. +- **Bridge → agent:** the bridge polls Postgres, runs `queued`/`approved` requests + via the `hermes` CLI, and writes results back. It never runs `awaiting_approval`. +- **Agent → website:** the bridge mirrors the kanban board (`HermesTask`), cron + + health (`DataStore`), memory (`HermesMemory`), and activity (`AgentEvent`) back + into Postgres, where the website reads them. + +Nothing on your machine is exposed to the internet — the bridge only needs outbound +access to Postgres and your local `hermes` CLI. See +[`hermes-bridge/README.md`](./hermes-bridge/README.md) for details. + +--- + +## Tech stack + +- **Framework:** Next.js 16 (App Router) + React 19 +- **Styling:** Tailwind CSS v4 +- **Data:** Prisma ORM + PostgreSQL +- **Auth:** NextAuth with Google login (email allowlist) +- **Charts / DnD:** Recharts, react-dnd +- **Deploy:** Vercel (website) + launchd/systemd (bridge) +- **Agent:** [Hermes](https://github.com/NousResearch/hermes-agent) `hermes` CLI + +--- + +## Quick start + +### Prerequisites + +- **Node.js 20+** (22 recommended) and **git** +- A **PostgreSQL** database URL — e.g. [Neon](https://neon.tech), + [Prisma Postgres](https://www.prisma.io/postgres), [Supabase](https://supabase.com), + or Vercel Postgres +- A **Hermes agent** installed on a machine you control (the `hermes` CLI on PATH) +- **Google OAuth** credentials (Client ID + Secret) for login +- A **Vercel** account (or any Node host) for deploying the website + +### 1. Clone and install + +```sh +git clone hermy-hq +cd hermy-hq +npm install +``` + +### 2. Configure environment + +```sh +cp .env.example .env +``` + +Open `.env` and fill in at least the **required core** vars: `DATABASE_URL`, +`POSTGRES_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `GOOGLE_CLIENT_ID`, +`GOOGLE_CLIENT_SECRET`, `ALLOWED_EMAILS`, and `NEXT_PUBLIC_OWNER_NAME`. +Every variable is documented inline in [`.env.example`](./.env.example). + +Generate a secret with: + +```sh +openssl rand -base64 32 +``` + +### 3. Create the database tables + +```sh +npx prisma db push +``` + +### 4. Run locally + +```sh +npm run dev +``` + +Open and sign in with a Google account whose email is in +`ALLOWED_EMAILS`. + +### 5. Deploy to Vercel + +```sh +npm i -g vercel # if you don't have it +vercel # link + deploy a preview +vercel --prod # production +``` + +Then: + +1. Add **every** env var from your `.env` in **Project → Settings → Environment + Variables**. +2. Set `NEXTAUTH_URL` and `NEXT_PUBLIC_BASE_URL` to your production URL. +3. In the Google Cloud console, add the authorized redirect URI + `https:///api/auth/callback/google`. + +### 6. Connect your Hermes (the bridge) + +On the machine where Hermes lives, set up the bridge so the bus connects: + +```sh +cd hermes-bridge +npm install +DATABASE_URL='postgres://…same as the website…' HERMES_BOARD=default node bridge.mjs +``` + +You should see `hermes-bridge up …` and a **"Bridge connected"** event appear in the +website's activity feed. To keep it running, install it via launchd (macOS) or +systemd (Linux) — full instructions in +[`hermes-bridge/README.md`](./hermes-bridge/README.md). + +--- + +## Project layout + +``` +src/ Next.js App Router (dashboard, /hermes, memory-wiki, content-os, …) +prisma/ schema.prisma (message-bus + feature models) +hermes-bridge/ the bridge that runs on your machine next to Hermes +.env.example every env var, documented +ONBOARDING.md copy-paste prompt to have your Hermes install this for you +``` + +--- + +## Safety + +Hermy HQ is designed so the agent can act, but not surprise you: + +- Requests that have side effects are marked `sideEffecting` and land in the + **Approval Inbox** as `awaiting_approval`. The bridge will not run them until you + approve. +- The bridge lives on your machine and only reaches out to Postgres and the local + `hermes` CLI — nothing inbound. +- Login is Google OAuth gated to the emails in `ALLOWED_EMAILS`. + +--- + +## License + +Provided as a template for you to fork, self-host, and adapt. Add the license of +your choice before publishing your own instance. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/hermes-bridge/README.md b/hermes-bridge/README.md new file mode 100644 index 0000000..e1d5d94 --- /dev/null +++ b/hermes-bridge/README.md @@ -0,0 +1,48 @@ +# Hermes Bridge + +Two-way sync between **Hermy HQ** (the deployed website) and **Hermes** (your local agent on the Mac mini), using the shared Postgres as a message bus. Nothing is exposed to the internet — the bridge only needs outbound access to Postgres and the local `hermes` CLI. + +``` +website ──insert AgentRequest──▶ Postgres ◀──poll & run── bridge ──▶ hermes CLI +website ◀──read HermesTask/──── Postgres ◀──mirror─────── bridge ◀── hermes CLI + AgentEvent/DataStore +``` + +## What it does +- **Pull (Hermes → website):** mirrors the kanban board into `HermesTask`, cron list + health into `DataStore`, and writes activity to `AgentEvent`. +- **Push (website → Hermes):** runs `AgentRequest` rows that are `queued` (safe) or `approved` (you approved a side-effecting one) via the `hermes` CLI, then writes results back. It never runs `awaiting_approval` rows. + +## Setup (on the Mac mini) +1. Copy this folder to the mini (or `git pull` the repo there). +2. Install the one dependency: + ```sh + cd hermes-bridge && npm install + ``` +3. Make sure `hermes` is on PATH: `which hermes` should resolve (e.g. `~/.local/bin/hermes`). +4. Try it once, pointing at your DB: + ```sh + DATABASE_URL='postgres://…' HERMES_BOARD=default node bridge.mjs + ``` + You should see `hermes-bridge up …`, and a "Bridge connected" event appear in the website's activity feed. +5. Run it forever with launchd: + ```sh + # edit the placeholders in ai.hermyhq.bridge.plist first (path, DATABASE_URL, PATH) + cp ai.hermyhq.bridge.plist ~/Library/LaunchAgents/ + launchctl load ~/Library/LaunchAgents/ai.hermyhq.bridge.plist + ``` + Logs: `/tmp/hermes-bridge.out.log`, `/tmp/hermes-bridge.err.log`. + +## Config (env) +| var | default | meaning | +|---|---|---| +| `DATABASE_URL` | — (required) | same Postgres the website uses | +| `HERMES_BOARD` | `default` | kanban board slug to mirror | +| `HERMES_BIN` | `hermes` | path to the CLI if not on PATH | +| `BRIDGE_POLL_MS` | `5000` | how often to check for new requests | +| `BRIDGE_MIRROR_MS` | `30000` | how often to mirror kanban/cron/health | +| `BRIDGE_RUN_TIMEOUT_MS` | `240000` | max time for one agent run | + +## Notes / assumptions +- CLI arg shapes (`hermes kanban create `, `hermes cron create <schedule> <prompt>`) are best-effort for Hermes v0.17.x — if your build differs, tweak `runRequest()` in `bridge.mjs`. +- The bridge writes to Postgres with plain SQL, so it doesn't need Prisma. +- Safe by design: side-effecting work waits for your approval in the website's Approval Inbox before the bridge will touch it. diff --git a/hermes-bridge/ai.hermyhq.bridge.plist b/hermes-bridge/ai.hermyhq.bridge.plist new file mode 100644 index 0000000..6c5ae2a --- /dev/null +++ b/hermes-bridge/ai.hermyhq.bridge.plist @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + launchd service for the Hermy HQ ↔ Hermes bridge. + Edit the CAPITALIZED placeholders, then: + cp ai.hermyhq.bridge.plist ~/Library/LaunchAgents/ + launchctl load ~/Library/LaunchAgents/ai.hermyhq.bridge.plist + Logs: /tmp/hermes-bridge.out.log /tmp/hermes-bridge.err.log +--> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>ai.hermyhq.bridge</string> + + <key>ProgramArguments</key> + <array> + <string>/usr/bin/env</string> + <string>node</string> + <string>/ABSOLUTE/PATH/TO/hermes-bridge/bridge.mjs</string> + </array> + + <key>EnvironmentVariables</key> + <dict> + <key>DATABASE_URL</key> + <string>PASTE_YOUR_DATABASE_URL_HERE</string> + <key>HERMES_BOARD</key> + <string>default</string> + <key>PATH</key> + <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/Users/YOU/.local/bin</string> + </dict> + + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <true/> + <key>StandardOutPath</key> + <string>/tmp/hermes-bridge.out.log</string> + <key>StandardErrorPath</key> + <string>/tmp/hermes-bridge.err.log</string> +</dict> +</plist> diff --git a/hermes-bridge/bridge.mjs b/hermes-bridge/bridge.mjs new file mode 100644 index 0000000..af8b90c --- /dev/null +++ b/hermes-bridge/bridge.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node +/** + * Hermy HQ ↔ Hermes bridge. + * + * Runs on the Mac mini where Hermes lives. Talks to the shared Postgres + * (the same DATABASE_URL the website uses) — nothing is exposed to the + * internet. Two jobs: + * + * PULL (Hermes → website): mirror the kanban board into HermesTask, + * cron list + health into DataStore, and emit activity events. + * PUSH (website → Hermes): pick up AgentRequest rows that are `queued` + * (safe) or `approved` (human-approved side-effecting), run them + * through the `hermes` CLI, and write results back. + * + * Requires: the `hermes` binary on PATH, and env DATABASE_URL. + * Optional env: HERMES_BOARD (default "default"), BRIDGE_POLL_MS (5000), + * BRIDGE_MIRROR_MS (30000), HERMES_BIN (default "hermes"). + */ +import pg from "pg"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const execFileP = promisify(execFile); +const HERMES = process.env.HERMES_BIN || "hermes"; +const BOARD = process.env.HERMES_BOARD || "default"; +const POLL_MS = Number(process.env.BRIDGE_POLL_MS || 5000); +const MIRROR_MS = Number(process.env.BRIDGE_MIRROR_MS || 30000); +const RUN_TIMEOUT_MS = Number(process.env.BRIDGE_RUN_TIMEOUT_MS || 240000); +const WIKI_DIR = process.env.HERMES_WIKI || path.join(os.homedir(), ".hermes", "wiki"); +const BRIEF_HOUR = Number(process.env.BRIEF_HOUR || 8); // local hour to auto-generate the daily brief +const BRIEF_PROMPT = + "You are the operator's chief of staff. Produce today's brief. Read your memory wiki open-loops " + + "(~/.hermes/wiki), the kanban board, and recent activity. Output ONLY valid JSON (no prose, no code fences) " + + 'in exactly this shape: {"greeting":"one warm line","summary":"2-3 sentences on where things stand",' + + '"sections":[{"label":"Needs your decision","items":["..."]},{"label":"Top priorities","items":["..."]},' + + '{"label":"Recently shipped","items":["..."]},{"label":"Next actions","items":["..."]}]}. ' + + "Keep every item short, concrete, and specific. Omit a section if it has nothing."; +let lastBriefDate = null; + +const DB_URL = process.env.DATABASE_URL || ""; +if (!DB_URL) { console.error("DATABASE_URL is required (use the direct postgres:// URL, not a prisma:// Accelerate URL)"); process.exit(1); } +if (DB_URL.startsWith("prisma://") || DB_URL.startsWith("prisma+")) { + console.error("DATABASE_URL is a Prisma Accelerate URL; the bridge needs a DIRECT postgres:// connection string (e.g. POSTGRES_URL)."); + process.exit(1); +} +// Cloud Postgres (Prisma Postgres/Neon/Supabase/RDS) needs SSL; localhost doesn't. +const isLocal = /@(localhost|127\.0\.0\.1)/.test(DB_URL); +const pool = new pg.Pool({ connectionString: DB_URL, max: 4, ssl: isLocal ? undefined : { rejectUnauthorized: false } }); + +const log = (...a) => console.log(new Date().toISOString(), ...a); +const q = (text, params) => pool.query(text, params); + +async function hermes(args, { timeout = 30000 } = {}) { + const { stdout } = await execFileP(HERMES, args, { timeout, maxBuffer: 8 * 1024 * 1024 }); + return stdout; +} + +async function emit(kind, title, { detail = null, agent = "hermes", level = "info", meta = null } = {}) { + await q( + `INSERT INTO "AgentEvent" (id, kind, title, detail, agent, level, meta, "createdAt") + VALUES ($1,$2,$3,$4,$5,$6,$7, now())`, + [randomUUID(), kind, title.slice(0, 200), detail, agent, level, meta ? JSON.stringify(meta) : null] + ); +} + +async function setStore(key, data) { + await q( + `INSERT INTO "DataStore" (key, data, "updatedAt") VALUES ($1,$2, now()) + ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, "updatedAt" = now()`, + [key, JSON.stringify(data)] + ); +} + +/* ─────────────── PULL: mirror Hermes → Postgres ─────────────── */ +async function mirrorKanban() { + let tasks = []; + try { + // NB: this Hermes CLI wants --board BEFORE the subcommand. + const out = await hermes(["kanban", "--board", BOARD, "list", "--json"], { timeout: 15000 }); + const parsed = JSON.parse(out || "[]"); + tasks = Array.isArray(parsed) ? parsed : parsed.tasks || []; + } catch (e) { log("kanban list failed:", e.message.split("\n")[0]); return; } + + const seen = new Set(); + for (const t of tasks) { + const id = String(t.id ?? t.task_id ?? ""); + if (!id) continue; + seen.add(id); + await q( + `INSERT INTO "HermesTask" (id, board, title, assignee, status, priority, result, "updatedAt", "syncedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7, now(), now()) + ON CONFLICT (id) DO UPDATE SET + title=EXCLUDED.title, assignee=EXCLUDED.assignee, status=EXCLUDED.status, + priority=EXCLUDED.priority, result=EXCLUDED.result, "syncedAt"=now()`, + [id, BOARD, String(t.title ?? "untitled").slice(0, 300), t.assignee ?? null, + String(t.status ?? "todo"), t.priority != null ? Number(t.priority) : null, + t.result ? String(t.result).slice(0, 2000) : null] + ); + } + // prune tasks that vanished from the board + if (seen.size) { + await q(`DELETE FROM "HermesTask" WHERE board=$1 AND id <> ALL($2::text[])`, [BOARD, [...seen]]); + } else { + await q(`DELETE FROM "HermesTask" WHERE board=$1`, [BOARD]); + } +} + +async function mirrorCrons() { + try { + const out = await hermes(["cron", "list", "--all"], { timeout: 15000 }); + const lines = out.split("\n").map((l) => l.trimEnd()).filter(Boolean); + await setStore("hermes-crons", { jobs: lines, raw: out.slice(0, 8000), syncedAt: new Date().toISOString() }); + } catch (e) { log("cron list failed:", e.message.split("\n")[0]); } +} + +async function mirrorCost() { + for (const args of [["insights", "--days", "7"], ["insights"]]) { + try { + const out = await hermes(args, { timeout: 15000 }); + await setStore("hermes-cost", { summary: out.slice(0, 4000), syncedAt: new Date().toISOString() }); + return; + } catch { /* try next arg shape */ } + } +} + +async function mirrorHealth() { + let online = false, gateway = "unknown", detail = ""; + try { + const out = await hermes(["status"], { timeout: 12000 }); + detail = out.slice(0, 4000); + online = /online|running|connected/i.test(out); + gateway = /gateway[^\n]*(running|online)/i.test(out) ? "running" : "stopped"; + } catch (e) { detail = e.message.split("\n")[0]; } + await setStore("hermes-health", { online, gateway, detail, lastSeen: new Date().toISOString() }); +} + +/* ─────────────── Memory Wiki (warm tier: git-tracked markdown) ─────────────── */ +function parseEntry(md) { + const m = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + const fm = {}; let body = md; + if (m) { + body = m[2]; + for (const line of m[1].split("\n")) { + const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/); + if (!kv) continue; + const v = kv[2].trim(); + if (v.startsWith("[") && v.endsWith("]")) fm[kv[1]] = v.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean); + else fm[kv[1]] = v === "null" || v === "" ? null : v; + } + } + return { fm, body: body.trim() }; +} +function walkMd(dir, out = []) { + let items = []; + try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const it of items) { + const full = path.join(dir, it.name); + if (it.isDirectory()) { if (it.name !== ".git") walkMd(full, out); } + else if (it.name.endsWith(".md") && it.name !== "INDEX.md") out.push(full); + } + return out; +} +async function mirrorWiki() { + if (!fs.existsSync(WIKI_DIR)) return; + const seen = new Set(); + for (const file of walkMd(WIKI_DIR)) { + const rel = path.relative(WIKI_DIR, file); + const id = rel.replace(/\.md$/, ""); + seen.add(id); + let raw = ""; try { raw = fs.readFileSync(file, "utf8"); } catch { continue; } + const { fm, body } = parseEntry(raw); + await q( + `INSERT INTO "HermesMemory" (id, path, type, title, status, confidence, provenance, tags, links, body, "validFrom", "validTo", "updatedAt", "syncedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now(), now()) + ON CONFLICT (id) DO UPDATE SET path=EXCLUDED.path, type=EXCLUDED.type, title=EXCLUDED.title, + status=EXCLUDED.status, confidence=EXCLUDED.confidence, provenance=EXCLUDED.provenance, + tags=EXCLUDED.tags, links=EXCLUDED.links, body=EXCLUDED.body, + "validFrom"=EXCLUDED."validFrom", "validTo"=EXCLUDED."validTo", "syncedAt"=now()`, + [id, rel, fm.type || "fact", fm.title || id, fm.status || "active", fm.confidence || null, + fm.provenance || null, Array.isArray(fm.tags) ? fm.tags : [], Array.isArray(fm.links) ? fm.links : [], + body, fm.valid_from || null, fm.valid_to || null] + ); + } + if (seen.size) await q(`DELETE FROM "HermesMemory" WHERE id <> ALL($1::text[])`, [[...seen]]); + else await q(`DELETE FROM "HermesMemory"`); +} +function writeWikiEntry(e) { + const rel = e.path || `${e.type || "note"}s/${e.id}.md`; + const full = path.join(WIKI_DIR, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + const now = new Date().toISOString().slice(0, 10); + const lines = [ + "---", `id: ${e.id}`, `type: ${e.type || "note"}`, `title: ${e.title}`, + `status: ${e.status || "active"}`, + e.confidence ? `confidence: ${e.confidence}` : null, + `provenance: ${e.provenance || "dashboard"}`, + `tags: [${(e.tags || []).join(", ")}]`, `links: [${(e.links || []).join(", ")}]`, + `updated: ${now}`, "---", "", e.body || "", "", + ].filter((l) => l !== null); + fs.writeFileSync(full, lines.join("\n"), "utf8"); + return rel; +} +async function gitCommitWiki(msg) { + try { + if (!fs.existsSync(path.join(WIKI_DIR, ".git"))) await execFileP("git", ["-C", WIKI_DIR, "init"]).catch(() => {}); + await execFileP("git", ["-C", WIKI_DIR, "add", "-A"]).catch(() => {}); + await execFileP("git", ["-C", WIKI_DIR, "commit", "-m", msg]).catch(() => {}); + } catch { /* ignore */ } +} + +/* ─────────────── Chief-of-staff daily brief ─────────────── */ +async function generateBriefing() { + const raw = (await hermes(["-z", BRIEF_PROMPT], { timeout: RUN_TIMEOUT_MS })).trim(); + let brief; + try { + const jsonStr = raw.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim(); + const m = jsonStr.match(/\{[\s\S]*\}/); + brief = JSON.parse(m ? m[0] : jsonStr); + } catch { brief = { summary: raw.slice(0, 1500), sections: [] }; } + brief.generatedAt = new Date().toISOString(); + await setStore("hermes-briefing", brief); + await emit("status", "Daily brief generated", { level: "up" }); +} +async function maybeDailyBrief() { + const now = new Date(); + const today = now.toISOString().slice(0, 10); + if (now.getHours() >= BRIEF_HOUR && lastBriefDate !== today) { + lastBriefDate = today; + try { await generateBriefing(); } catch (e) { log("daily brief err", e.message); } + } +} + +/* ─────────────── PUSH: run website requests via Hermes ─────────────── */ +async function runRequest(r) { + await q(`UPDATE "AgentRequest" SET status='running', "startedAt"=now(), "updatedAt"=now() WHERE id=$1`, [r.id]); + await emit("run", `Started: ${r.title}`, { level: "info", meta: { requestId: r.id, kind: r.kind } }); + try { + let result = ""; + if (r.kind === "oneshot" || r.kind === "chat") { + result = (await hermes(["-z", r.prompt || r.title], { timeout: RUN_TIMEOUT_MS })).trim(); + } else if (r.kind === "kanban") { + result = (await hermes(["kanban", "--board", BOARD, "create", "--json", r.title], { timeout: 20000 })).trim(); + } else if (r.kind.startsWith("cron.")) { + const op = r.kind.split(".")[1]; + const a = JSON.parse(r.prompt || "{}"); + const argv = + op === "create" ? ["cron", "create", a.schedule, a.prompt || a.name].filter(Boolean) + : op === "run" ? ["cron", "run", a.id || a.name] + : op === "pause" ? ["cron", "pause", a.id || a.name] + : op === "resume" ? ["cron", "resume", a.id || a.name] + : op === "remove" ? ["cron", "remove", a.id || a.name] + : op === "edit" ? ["cron", "edit", a.id || a.name] + : null; + if (!argv) throw new Error(`unknown cron op ${op}`); + result = (await hermes(argv, { timeout: 20000 })).trim(); + await mirrorCrons(); + } else if (r.kind === "memory.write") { + const e = JSON.parse(r.prompt || "{}"); + const rel = writeWikiEntry(e); + await gitCommitWiki(`wiki: update ${rel} (via dashboard)`); + await mirrorWiki(); + result = `wrote ${rel}`; + } else if (r.kind === "briefing.generate") { + await generateBriefing(); + lastBriefDate = new Date().toISOString().slice(0, 10); + result = "brief updated"; + } else { + throw new Error(`unknown kind ${r.kind}`); + } + await q(`UPDATE "AgentRequest" SET status='done', result=$2, "finishedAt"=now(), "updatedAt"=now() WHERE id=$1`, + [r.id, result.slice(0, 8000)]); + await emit("run", `Done: ${r.title}`, { level: "up", detail: result.slice(0, 400), meta: { requestId: r.id } }); + } catch (e) { + const msg = (e.stderr || e.message || "error").toString().split("\n")[0].slice(0, 600); + await q(`UPDATE "AgentRequest" SET status='failed', error=$2, "finishedAt"=now(), "updatedAt"=now() WHERE id=$1`, [r.id, msg]); + await emit("run", `Failed: ${r.title}`, { level: "down", detail: msg, meta: { requestId: r.id } }); + log("request failed:", r.id, msg); + } +} + +async function processQueue() { + const { rows } = await q( + `SELECT * FROM "AgentRequest" WHERE status IN ('queued','approved') ORDER BY "createdAt" ASC LIMIT 3` + ); + for (const r of rows) await runRequest(r); +} + +/* ─────────────── loops ─────────────── */ +async function mirrorTick() { + try { await mirrorKanban(); } catch (e) { log("mirrorKanban err", e.message); } + try { await mirrorCrons(); } catch (e) { log("mirrorCrons err", e.message); } + try { await mirrorHealth(); } catch (e) { log("mirrorHealth err", e.message); } + try { await mirrorWiki(); } catch (e) { log("mirrorWiki err", e.message); } + try { await mirrorCost(); } catch (e) { log("mirrorCost err", e.message); } + try { await maybeDailyBrief(); } catch (e) { log("maybeDailyBrief err", e.message); } +} + +async function main() { + log(`hermes-bridge up · board=${BOARD} · poll=${POLL_MS}ms · mirror=${MIRROR_MS}ms`); + await emit("status", "Bridge connected", { level: "up" }); + await mirrorTick(); + setInterval(() => mirrorTick().catch((e) => log("mirror loop", e.message)), MIRROR_MS); + // queue loop + const tick = async () => { try { await processQueue(); } catch (e) { log("queue loop", e.message); } finally { setTimeout(tick, POLL_MS); } }; + tick(); +} +main().catch((e) => { console.error("fatal", e); process.exit(1); }); diff --git a/hermes-bridge/package.json b/hermes-bridge/package.json new file mode 100644 index 0000000..ff929ee --- /dev/null +++ b/hermes-bridge/package.json @@ -0,0 +1,10 @@ +{ + "name": "hermes-bridge", + "private": true, + "type": "module", + "version": "1.0.0", + "description": "Two-way bridge between Hermy HQ (website) and Hermes (local agent) via shared Postgres.", + "bin": { "hermes-bridge": "./bridge.mjs" }, + "scripts": { "start": "node bridge.mjs" }, + "dependencies": { "pg": "^8.13.1" } +} diff --git a/hermes-bridge/wiki-skill/SKILL.md b/hermes-bridge/wiki-skill/SKILL.md new file mode 100644 index 0000000..1459738 --- /dev/null +++ b/hermes-bridge/wiki-skill/SKILL.md @@ -0,0 +1,57 @@ +--- +name: wiki +description: > + Long-term memory wiki. Use this whenever you learn, decide, or are corrected on + something durable that is too big or too detailed for MEMORY.md. Read from and + write to ~/.hermes/wiki as your warm, git-tracked, never-forget memory. +version: 1.0.0 +--- + +# Memory Wiki + +`MEMORY.md` and `USER.md` are your tiny hot cache (~1,300 tokens total, always in +context). The **wiki** at `~/.hermes/wiki/` is your warm, unlimited, git-tracked +long-term memory. It is browsed and edited by the operator in Hermy HQ, so keep it clean. + +## When to write to the wiki (not MEMORY.md) +- After any complex task (5+ tool calls): append a line to `log/YYYY-MM.md` and, if you + learned something reusable, create/update a `lessons/` entry. +- When the operator makes a **decision** → a `decisions/` entry with the rationale + date. +- When you learn a durable fact about a **project, person, or the business** that is + bigger than a one-liner → a `projects/`, `people/`, or `facts/` entry. +- When corrected ("no, do it this way") → update the relevant entry; **don't delete the + old value — mark it `status: superseded`** and add the new fact. Preserve history. +- Keep MEMORY.md for only ~10-20 always-true, high-frequency facts, plus a pointer: + `Full long-term memory at ~/.hermes/wiki — grep/read it before answering project questions.` + +## Entry format (one markdown file per entry, YAML frontmatter + body) +``` +--- +id: proj-viralpen +type: project # fact | preference | decision | event | project | contact | lesson | metric | note +title: ViralPen.ai SaaS +status: active # active | superseded | archived +confidence: high # high | medium | low +provenance: user-stated # user-stated | observed | web | session:<id> +tags: [saas, billing] +links: [decision-pricing-99] +updated: 2026-07-23 +--- +Multi-tenant article studio at ~/viralpen. $99/mo. Twitter OAuth. +## Open loops +- [ ] Migrate billing to usage-based (see decision-pricing-99) +``` +Files live under type folders: `projects/`, `people/`, `decisions/`, `lessons/`, +`facts/`, `log/`. Keep `INDEX.md` updated (one line per entry: `id · title · type · updated`). + +## Retrieval (before answering) +1. `INDEX.md` is injected hot — scan it for the right entry id. +2. `search_files ~/.hermes/wiki "<term>"` or read the specific file with `read_file`. +3. If still unsure, `session_search` the conversation history (free, unlimited). +Only pull the 1-2 entries you actually need — don't load the whole wiki. + +## Hygiene +- After writing, `git -C ~/.hermes/wiki add -A && git commit -m "wiki: <what changed>"`. +- Never destroy history — supersede, don't overwrite. +- The nightly `wiki-consolidate` cron merges duplicates, demotes stale entries, and + rebuilds INDEX.md — keep entries small and single-purpose so it can. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..72e0b8f --- /dev/null +++ b/next.config.ts @@ -0,0 +1,16 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + // Vercel-compatible settings + output: undefined, // default — Vercel handles this automatically + images: { + unoptimized: false, + }, + experimental: { + serverActions: { + bodySizeLimit: "4mb", + }, + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9f41307 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10053 @@ +{ + "name": "hermy-hq", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hermy-hq", + "version": "0.1.0", + "hasInstallScript": true, + "dependencies": { + "@auth/prisma-adapter": "^1.6.0", + "@prisma/client": "^6.19.2", + "@types/better-sqlite3": "^7.6.13", + "@types/formidable": "^3.4.6", + "better-sqlite3": "^12.11.1", + "drizzle-orm": "^0.45.2", + "formidable": "^3.5.4", + "googleapis": "^171.4.0", + "grammy": "^1.44.0", + "lucide-react": "^0.564.0", + "next": "16.1.6", + "next-auth": "^4.24.13", + "pg": "^8.19.0", + "prisma": "^6.19.2", + "react": "19.2.3", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "19.2.3", + "recharts": "^3.7.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@tauri-apps/cli": "^2.10.0", + "@types/node": "^20", + "@types/pg": "^8.20.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "tsx": "^4.22.4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@auth/core": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", + "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/core/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.6.0.tgz", + "integrity": "sha512-PQU8/Oi5gfjzb0MkhMGVX0Dg877phPzsQdK54+C7ubukCeZPjyvuSAx1vVtWEYVWp2oQvjgG/C6QiDoeC7S10A==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.29.0" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.29.0.tgz", + "integrity": "sha512-MdfEjU6WRjUnPG1+XeBWrTIlAsLZU6V0imCIqVDDDPxLI6UZWldXVqAA2EsDazGofV78jqiCLHaN85mJITDqdg==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.4.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@grammyjs/types": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.28.0.tgz", + "integrity": "sha512-4JvXCdxRZHCje0M4gHzLwtB4bLno3WD28xd8CNfk4POWIu73BFnSvGeW6OQ5gPem4eYTEwkD9yDaXssixl6tMQ==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", + "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", + "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", + "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", + "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", + "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", + "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz", + "integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.0", + "@tauri-apps/cli-darwin-x64": "2.10.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", + "@tauri-apps/cli-linux-arm64-musl": "2.10.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-musl": "2.10.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", + "@tauri-apps/cli-win32-x64-msvc": "2.10.0" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz", + "integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", + "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", + "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", + "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", + "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", + "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", + "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", + "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", + "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", + "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", + "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/formidable": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.6.tgz", + "integrity": "sha512-LI4Hk+KNsM5q7br4oMVoaWeb+gUqJpz1N8+Y2Q6Cz9cVH33ybahRKUWaRmMboVlkwSbOUGgwc/pEkS7yMSzoWg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.31.tgz", + "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.1.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grammy": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.44.0.tgz", + "integrity": "sha512-gGVykS5+c5f1tPV97LuU6IDRMawE2NzpwM9pNz58HQ35IZXDnYL3VOLvNzYognPSeBIOSzQXRu5w96V0aY8y8A==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.28.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, + "node_modules/grammy/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.564.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz", + "integrity": "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "license": "MIT" + }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/recharts/node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/recharts/node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b5d0805 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "hermy-hq", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "prisma generate && next build", + "postinstall": "prisma generate", + "start": "next start", + "lint": "eslint", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio", + "db:seed": "npx tsx prisma/seed.ts" + }, + "dependencies": { + "@auth/prisma-adapter": "^1.6.0", + "@prisma/client": "^6.19.2", + "@types/better-sqlite3": "^7.6.13", + "@types/formidable": "^3.4.6", + "better-sqlite3": "^12.11.1", + "drizzle-orm": "^0.45.2", + "formidable": "^3.5.4", + "googleapis": "^171.4.0", + "grammy": "^1.44.0", + "lucide-react": "^0.564.0", + "next": "16.1.6", + "next-auth": "^4.24.13", + "pg": "^8.19.0", + "prisma": "^6.19.2", + "react": "19.2.3", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "19.2.3", + "recharts": "^3.7.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@tauri-apps/cli": "^2.10.0", + "@types/node": "^20", + "@types/pg": "^8.20.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "tsx": "^4.22.4", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..730e063 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,519 @@ +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-arm64-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +model Draft { + id String @id @default(uuid()) + text String + title String? + model String? + status String @default("pending") + type String? + category String? + hookType String? + feedbackRating String? + feedbackReason String? + viralScore Json? + impressionScore Json? + scheduledDate String? + scheduledTime String? + visualUrl String? + variantGroup String? + postedAt DateTime? + postedUrl String? + tweetUrl String? + tweetId String? + metrics Json? + editHistory Json? @default("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + quoteUrl String? + quoteText String? + + // Content intelligence fields + source String? // "sage" | "organic" | "csv_import" + engagementScore Float? // time-weighted composite score (updated by scoring engine) + engagementTier String? // "top20" | "bottom20" | null (cleared/set on rescore) + + @@index([status]) + @@index([createdAt]) + @@index([tweetId]) + @@index([source]) + @@index([engagementTier]) +} + +model TweetMetric { + id String @id @default(cuid()) + draftId String + tweetId String? + url String? + postedAt DateTime? + checkpoints Json? + createdAt DateTime @default(now()) + + @@index([draftId]) +} + +model Idea { + id String @id @default(uuid()) + title String + description String? + category String? + type String? + model String? + status String? + timestamp DateTime @default(now()) + + @@index([category]) + @@index([type]) +} + +model ContentCalendar { + id String @id @default(cuid()) + week String @unique + data Json +} + +model YoutubeIdea { + id String @id @default(cuid()) + title String + hook String? + angle String? + hookType String? + funnelStage String? + status String @default("pending") + rejectedReason String? + sourceUrl String? + sourceSummary String? + competitionScore String? // "gap" | "moderate" | "crowded" + competitionData Json? // { resultCount, avgViews, topVideos[], scoredAt } + createdAt DateTime @default(now()) + + @@index([status]) +} + +model YoutubeScript { + id String @id @default(cuid()) + title String + hook String? + storySetup String? + conflict String? + insight String? + cta String? + caption String? + onScreenText String? + hookType String? + funnelStage String? + factCheck Json? + fullScript String? + status String @default("draft") + rejectedReason String? + // SEO Package + seoTitle String? + seoDescription String? + seoTags Json? + seoChapters Json? + titleVariants Json? + thumbnailConcept String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) +} + +model YoutubeFeedback { + id String @id @default(cuid()) + title String + reason String? + type String? + date DateTime @default(now()) +} + +model LongformScript { + id String @id @default(cuid()) + title String + type String? + status String @default("draft") + platform String? + platforms Json? + description String? + hook String? + outline String? + fullScript String? + targetLength String? + factCheck Json? + notes String? + rejectedReason String? + youtubeUrl String? + thumbnailUrl String? + tweetUrl String? + youtubeViews Int? + youtubeLikes Int? + youtubeComments Int? + tweetViews Int? + tweetLikes Int? + tweetBookmarks Int? + tweetRetweets Int? + tweetReplies Int? + postedAt DateTime? + competitionScore String? // "gap" | "moderate" | "crowded" + competitionData Json? // { resultCount, avgViews, topVideos[], scoredAt } + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) +} + +model AgentState { + id String @id + name String + emoji String? + role String? + status String @default("offline") + lastActive DateTime? + tasksCompleted Int @default(0) + totalCost Float @default(0) + currentTask String? + recentActivity Json? @default("[]") + updatedAt DateTime @updatedAt +} + +model AgentBusMessage { + id String @id @default(uuid()) + fromAgent String + toAgent String + type String + content String + metadata Json? + read Boolean @default(false) + timestamp DateTime @default(now()) + + @@index([toAgent, read]) + @@index([fromAgent]) +} + +model BattleRoyaleBot { + id String @id + state Json + updatedAt DateTime @updatedAt +} + +model ContentRequest { + id String @id @default(uuid()) + prompt String + status String @default("pending") + resultDraftIds Json? + createdAt DateTime @default(now()) + completedAt DateTime? +} + +// ─── Generic Key-Value Store ──────────────────────────────────── + +model DataStore { + key String @id + data Json + updatedAt DateTime @updatedAt +} + +// ─── Daily Briefs ─────────────────────────────────────────────── + +model Brief { + id String @id @default(cuid()) + date String @unique + content String @db.Text + source String @default("brief") + createdAt DateTime @default(now()) +} + +// ─── Missions ─────────────────────────────────────────────────── + +model Mission { + id String @id @default(uuid()) + agentId String + title String + description String @db.Text + status String @default("pending") // pending, active, completed, failed + priority String @default("medium") // high, medium, low + result String? @db.Text + createdAt DateTime @default(now()) + completedAt DateTime? + + @@index([agentId]) + @@index([status]) +} + +// ─── Articles ─────────────────────────────────────────────────── + +model Article { + id String @id @default(uuid()) + title String + body String @db.Text + track String // "mega-viral" | "local-viral" | "icp-viral" + status String @default("idea") // "idea" | "draft" | "ready" | "posted" + qtTweet String? @db.Text + qtUrl String? + heroImageUrl String? + heroImageHtml String? @db.Text + inspirationUrls String? @db.Text // JSON array of URLs + themes String? // JSON array of themes + scheduledDate String? + postedAt DateTime? + postedUrl String? + impressions Int? + likes Int? + bookmarks Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([track]) + @@index([scheduledDate]) +} + +model SavedTitle { + id String @id @default(cuid()) + title String + track String + themes String? // JSON array + createdAt DateTime @default(now()) + + @@index([track]) +} + +// ─── Client Pulse Telegram Bot ───────────────────────────────── + +model ClientPulseClient { + id String @id @default(cuid()) + notionPageId String? @unique + clientName String + status String? + services Json @default("[]") + productManagerName String? + renewalDate DateTime? + endDate DateTime? + expectedCheckinHours Int @default(72) + sourceUpdatedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + chats ClientPulseChat[] + analyses ClientPulseAnalysis[] + alerts ClientPulseAlert[] + + @@index([status]) + @@index([clientName]) + @@index([productManagerName]) +} + +model ClientPulseChat { + id String @id @default(cuid()) + telegramChatId String @unique + title String + linkedClientId String? + status String @default("active") + internalUserIds Json @default("[]") + lastSeenAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + linkedClient ClientPulseClient? @relation(fields: [linkedClientId], references: [id], onDelete: SetNull) + messages ClientPulseMessage[] + alerts ClientPulseAlert[] + + @@index([linkedClientId]) + @@index([status]) +} + +model ClientPulseMessage { + id String @id @default(cuid()) + telegramMessageId String + telegramChatId String + chatId String? + senderTelegramId String + senderName String + isInternal Boolean @default(false) + isBot Boolean @default(false) + text String @db.Text + sentAt DateTime + intent String @default("other") + sentiment String @default("neutral") + needsReply Boolean @default(false) + rawJson Json? + createdAt DateTime @default(now()) + chat ClientPulseChat? @relation(fields: [chatId], references: [id], onDelete: SetNull) + + @@unique([telegramChatId, telegramMessageId]) + @@index([telegramChatId, sentAt]) + @@index([chatId, sentAt]) + @@index([needsReply]) +} + +model ClientPulseAnalysis { + id String @id @default(cuid()) + clientId String + windowStart DateTime + windowEnd DateTime + sentimentScore Int + responseScore Int + cadenceScore Int + renewalRiskScore Int + positiveSignalScore Int @default(70) + overallScore Int + category String + reasons Json @default("[]") + evidence Json @default("[]") + recommendedAction String? @db.Text + llmSummary String? @db.Text + analysisSource String @default("fallback") + analyzedAt DateTime @default(now()) + client ClientPulseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + + @@index([clientId, analyzedAt]) + @@index([category]) + @@index([overallScore]) +} + +model ClientPulseAlert { + id String @id @default(cuid()) + clientId String? + chatId String? + type String + severity String + evidence Json @default("[]") + reason String @db.Text + recommendedAction String? @db.Text + sentTo String? + sentAt DateTime? + acknowledgedAt DateTime? + createdAt DateTime @default(now()) + client ClientPulseClient? @relation(fields: [clientId], references: [id], onDelete: SetNull) + chat ClientPulseChat? @relation(fields: [chatId], references: [id], onDelete: SetNull) + + @@index([clientId, createdAt]) + @@index([severity]) + @@index([sentAt]) + @@index([acknowledgedAt]) +} + +// ─── Hermes bus (Hermy HQ website ↔ Hermes on the Mac mini) ───── +// Shared Postgres is the message bus. A bridge on the mini polls +// AgentRequest, runs approved/safe ones via the `hermes` CLI, and +// mirrors kanban tasks + activity back into HermesTask / AgentEvent. + +model AgentRequest { + id String @id @default(cuid()) + origin String @default("web") // "web" | "hermes" + kind String @default("oneshot") // "oneshot" | "kanban" | "chat" + title String + prompt String? + sideEffecting Boolean @default(false) + status String @default("queued") // queued | awaiting_approval | approved | running | done | failed | rejected + result String? + error String? + hermesTaskId String? + decidedAt DateTime? + startedAt DateTime? + finishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([createdAt]) +} + +model AgentEvent { + id String @id @default(cuid()) + kind String @default("activity") // activity | run | status | memory + title String + detail String? + agent String? + level String @default("info") // info | up | warn | down + meta Json? + createdAt DateTime @default(now()) + + @@index([createdAt]) +} + +model HermesTask { + id String @id // Hermes task id + board String @default("default") + title String + assignee String? + status String @default("todo") + priority Int? + result String? + updatedAt DateTime @default(now()) + syncedAt DateTime @updatedAt + + @@index([board]) + @@index([status]) +} + +// ─── Hermes Memory Wiki (warm tier — git-tracked markdown, mirrored) ───── +model HermesMemory { + id String @id // wiki entry slug (relative path w/o .md) + path String // file path relative to wiki root + type String @default("fact") + title String + status String @default("active") + confidence String? + provenance String? + tags String[] + links String[] + body String @default("") + validFrom DateTime? + validTo DateTime? + updatedAt DateTime @default(now()) + syncedAt DateTime @updatedAt + + @@index([type]) + @@index([status]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..8c3f2f6 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,307 @@ +import { PrismaClient } from '@prisma/client' +import * as fs from 'fs' +import * as path from 'path' + +const prisma = new PrismaClient() +const DATA_DIR = './data' + +function readJson(filename: string): unknown { + const p = path.join(DATA_DIR, filename) + if (!fs.existsSync(p)) { console.warn(`⚠️ Missing: ${filename}`); return null } + return JSON.parse(fs.readFileSync(p, 'utf-8')) +} + +function safeDate(val: unknown, fallback = new Date()): Date { + if (!val) return fallback + const d = new Date(val as string) + return isNaN(d.getTime()) ? fallback : d +} + +async function seedDrafts() { + const raw = readJson('drafts.json') as any[] + if (!raw) return + console.log(`📝 Seeding ${raw.length} drafts...`) + + for (const d of raw) { + const feedback = d.feedback ?? {} + await prisma.draft.upsert({ + where: { id: d.id }, + update: {}, + create: { + id: d.id, + text: d.text ?? '', + title: d.title ?? null, + model: d.model ?? null, + status: d.status ?? 'pending', + type: d.type ?? null, + category: d.category ?? null, + hookType: d.hookType ?? null, + feedbackRating: feedback.rating ?? null, + feedbackReason: feedback.reason ?? null, + viralScore: d.viralScore ?? undefined, + impressionScore: d.impressionScore ?? undefined, + scheduledDate: d.scheduledDate ?? null, + scheduledTime: d.scheduledTime ?? null, + visualUrl: d.visualUrl ?? null, + variantGroup: d.variantGroup ?? null, + postedAt: d.postedAt ? safeDate(d.postedAt) : null, + postedUrl: d.postedUrl ?? null, + tweetUrl: d.tweetUrl ?? null, + tweetId: d.tweetId ?? null, + metrics: d.metrics ?? undefined, + editHistory: d.editHistory ?? [], + createdAt: safeDate(d.createdAt), + updatedAt: safeDate(d.updatedAt), + }, + }) + } + console.log(` ✅ ${raw.length} drafts done`) +} + +async function seedIdeas() { + const raw = readJson('ideas.json') as any[] + if (!raw) return + console.log(`💡 Seeding ${raw.length} ideas...`) + + for (const d of raw) { + await prisma.idea.upsert({ + where: { id: d.id }, + update: {}, + create: { + id: d.id, + title: d.title ?? '', + description: d.description ?? null, + category: d.category ?? null, + type: d.type ?? null, + model: d.model ?? null, + status: d.status ?? null, + timestamp: safeDate(d.timestamp), + }, + }) + } + console.log(` ✅ ${raw.length} ideas done`) +} + +async function seedLongformScripts() { + const raw = readJson('longform-scripts.json') as any[] + if (!raw) return + console.log(`📄 Seeding ${raw.length} longform scripts...`) + + for (const d of raw) { + await prisma.longformScript.upsert({ + where: { id: d.id }, + update: {}, + create: { + id: d.id, + title: d.title ?? '', + type: d.type ?? null, + status: d.status ?? 'draft', + platform: d.platform ?? null, + platforms: d.platforms ?? undefined, + description: d.description ?? null, + hook: d.hook ?? null, + outline: d.outline ?? null, + fullScript: d.fullScript ?? null, + targetLength: d.targetLength ?? null, + factCheck: d.factCheck ?? undefined, + notes: d.notes ?? null, + createdAt: safeDate(d.createdAt), + updatedAt: safeDate(d.updatedAt), + }, + }) + } + console.log(` ✅ ${raw.length} longform scripts done`) +} + +async function seedYoutubeScripts() { + const raw = readJson('youtube-scripts.json') as any[] + if (!raw) return + console.log(`🎬 Seeding ${raw.length} YouTube scripts...`) + + for (const d of raw) { + await prisma.youtubeScript.upsert({ + where: { id: d.id }, + update: {}, + create: { + id: d.id, + title: d.title ?? '', + hook: d.hook ?? null, + storySetup: d.storySetup ?? null, + conflict: d.conflict ?? null, + insight: d.insight ?? null, + cta: d.cta ?? null, + caption: d.caption ?? null, + onScreenText: d.onScreenText ?? null, + hookType: d.hookType ?? null, + funnelStage: d.funnelStage ?? null, + factCheck: d.factCheck ?? undefined, + status: d.status ?? 'draft', + rejectedReason: d.rejectedReason ?? null, + createdAt: safeDate(d.createdAt), + updatedAt: safeDate(d.updatedAt), + }, + }) + } + console.log(` ✅ ${raw.length} YouTube scripts done`) +} + +async function seedYoutubeIdeas() { + const raw = readJson('youtube-ideas.json') as any[] + if (!raw) return + console.log(`🎯 Seeding ${raw.length} YouTube ideas...`) + + // Delete and re-insert (no stable IDs in source file) + await prisma.youtubeIdea.deleteMany() + for (const d of raw) { + await prisma.youtubeIdea.create({ + data: { + title: d.title ?? '', + hook: d.hook ?? null, + angle: d.angle ?? null, + hookType: d.hookType ?? null, + funnelStage: d.funnelStage ?? null, + status: d.status ?? 'pending', + rejectedReason: d.rejectedReason ?? null, + createdAt: safeDate(d.createdAt), + }, + }) + } + console.log(` ✅ ${raw.length} YouTube ideas done`) +} + +async function seedYoutubeFeedback() { + const raw = readJson('youtube-feedback.json') as any[] + if (!raw) return + console.log(`💬 Seeding ${raw.length} YouTube feedback...`) + + await prisma.youtubeFeedback.deleteMany() + for (const d of raw) { + await prisma.youtubeFeedback.create({ + data: { + title: d.title ?? '', + reason: d.reason ?? null, + type: d.type ?? null, + date: safeDate(d.date), + }, + }) + } + console.log(` ✅ ${raw.length} YouTube feedback done`) +} + +async function seedTweetMetrics() { + const raw = readJson('tweet-metrics.json') as any[] + if (!raw) return + console.log(`📊 Seeding ${raw.length} tweet metrics...`) + + await prisma.tweetMetric.deleteMany() + for (const d of raw) { + await prisma.tweetMetric.create({ + data: { + draftId: d.draftId ?? '', + tweetId: d.tweetId ?? null, + url: d.url ?? null, + postedAt: d.postedAt ? safeDate(d.postedAt) : null, + checkpoints: d.checkpoints ?? undefined, + createdAt: safeDate(d.createdAt), + }, + }) + } + console.log(` ✅ ${raw.length} tweet metrics done`) +} + +async function seedAgentState() { + const raw = readJson('agent-state.json') as any[] + if (!raw) return + console.log(`🤖 Seeding ${raw.length} agent states...`) + + for (const d of raw) { + await prisma.agentState.upsert({ + where: { id: d.id }, + update: { + name: d.name, + emoji: d.emoji ?? null, + role: d.role ?? null, + status: d.status ?? 'offline', + lastActive: d.lastActive ? safeDate(d.lastActive) : null, + tasksCompleted: d.tasksCompleted ?? 0, + totalCost: d.totalCost ?? 0, + currentTask: d.currentTask ?? null, + recentActivity: d.recentActivity ?? [], + updatedAt: new Date(), + }, + create: { + id: d.id, + name: d.name, + emoji: d.emoji ?? null, + role: d.role ?? null, + status: d.status ?? 'offline', + lastActive: d.lastActive ? safeDate(d.lastActive) : null, + tasksCompleted: d.tasksCompleted ?? 0, + totalCost: d.totalCost ?? 0, + currentTask: d.currentTask ?? null, + recentActivity: d.recentActivity ?? [], + updatedAt: new Date(), + }, + }) + } + console.log(` ✅ ${raw.length} agent states done`) +} + +async function seedContentCalendar() { + const raw = readJson('content-calendar.json') as any + if (!raw) return + // File is { week: "...", days: [...] } + const week = raw.week + if (!week) { console.warn('⚠️ content-calendar.json missing week field'); return } + console.log(`📅 Seeding content calendar (week ${week})...`) + + await prisma.contentCalendar.upsert({ + where: { week }, + update: { data: raw }, + create: { week, data: raw }, + }) + console.log(` ✅ Calendar done`) +} + +async function seedContentRequests() { + const raw = readJson('content-requests.json') as any[] + if (!raw || !raw.length) return + console.log(`📬 Seeding ${raw.length} content requests...`) + + for (const d of raw) { + await prisma.contentRequest.upsert({ + where: { id: d.id }, + update: {}, + create: { + id: d.id, + prompt: d.prompt ?? '', + status: d.status ?? 'pending', + resultDraftIds: d.resultDraftIds ?? undefined, + createdAt: safeDate(d.createdAt), + completedAt: d.completedAt ? safeDate(d.completedAt) : null, + }, + }) + } + console.log(` ✅ ${raw.length} content requests done`) +} + +async function main() { + console.log('🌱 Starting Max HQ data seed...\n') + + await seedAgentState() + await seedDrafts() + await seedIdeas() + await seedLongformScripts() + await seedYoutubeScripts() + await seedYoutubeIdeas() + await seedYoutubeFeedback() + await seedTweetMetrics() + await seedContentCalendar() + await seedContentRequests() + + console.log('\n✅ All done! Max HQ database is fully seeded.') +} + +main() + .catch((e) => { console.error('❌ Seed failed:', e); process.exit(1) }) + .finally(() => prisma.$disconnect()) diff --git a/public/draft-visuals/.gitkeep b/public/draft-visuals/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ +<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> \ No newline at end of file diff --git a/scanner/outlier-scanner.js b/scanner/outlier-scanner.js new file mode 100644 index 0000000..0002cf1 --- /dev/null +++ b/scanner/outlier-scanner.js @@ -0,0 +1,325 @@ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +// ── Config ────────────────────────────────────────────────────────────────────── +const NICHES = { + 'openclaw': { label: '🦞 OpenClaw & AI Agents', queries: ['OpenClaw tutorial', 'build AI agents OpenClaw', 'run AI agents locally 2026'] }, + 'polymarket-trading': { label: '📈 Polymarket & Trading Bots', queries: ['Polymarket trading bot', 'prediction market bot', 'AI trading bot build 2026'] }, + 'ai-agent-build': { label: '🤖 AI Agent Builds', queries: ['build AI agent no code', 'AI automation agent tutorial', 'how to build AI employees'] }, + 'agency-growth': { label: '🏢 Agency & Clients', queries: ['marketing agency growth', 'how to grow a marketing agency', 'agency owner content creator'] }, + 'ai-tools': { label: '🛠️ AI for Creators', queries: ['AI tools content creators', 'AI automation marketing', 'AI replace marketing team'] }, +}; + +const MAX_CHANNEL_SUBS = 1000000; +const MIN_OUTLIER_SCORE = 1.5; +const DAYS_AGO = 30; +const MAX_RESULTS = 15; + +// ── Load API key from .env.local ──────────────────────────────────────────────── +function loadApiKey() { + const envPath = path.join(__dirname, '..', '.env.local'); + if (!fs.existsSync(envPath)) { + console.error('ERROR: .env.local not found at', envPath); + process.exit(1); + } + const lines = fs.readFileSync(envPath, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('YOUTUBE_API_KEY=')) { + const val = trimmed.slice('YOUTUBE_API_KEY='.length).trim(); + return val.replace(/^["']|["']$/g, ''); + } + } + console.error('ERROR: YOUTUBE_API_KEY not found in .env.local'); + process.exit(1); +} + +// Add one or more YouTube Data API keys here (or via YOUTUBE_API_KEY in .env.local). +// Multiple keys are rotated when quota is hit. +const API_KEYS = [loadApiKey()].filter(Boolean); +let currentKeyIndex = 0; +function getApiKey() { return API_KEYS[currentKeyIndex]; } +function rotateKey() { + currentKeyIndex = (currentKeyIndex + 1) % API_KEYS.length; + console.log(` ⚡ Rotated to API key ${currentKeyIndex + 1}`); +} + +// ── HTTP helper ───────────────────────────────────────────────────────────────── +function fetchJSON(url) { + return new Promise((resolve, reject) => { + https.get(url, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (e) { reject(new Error('JSON parse error: ' + data.slice(0, 200))); } + }); + }).on('error', reject); + }); +} + +// ── ISO 8601 duration parser ──────────────────────────────────────────────────── +function parseDuration(iso) { + if (!iso) return 0; + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return 0; + const h = parseInt(match[1] || '0', 10); + const m = parseInt(match[2] || '0', 10); + const s = parseInt(match[3] || '0', 10); + return h * 3600 + m * 60 + s; +} + +function formatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function median(arr) { + if (arr.length === 0) return 0; + const sorted = [...arr].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : Math.floor((sorted[mid - 1] + sorted[mid]) / 2); +} + +// ── YouTube API calls ─────────────────────────────────────────────────────────── +async function searchVideos(query, publishedAfter) { + const url = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(query)}&type=video&order=relevance&publishedAfter=${publishedAfter}&maxResults=${MAX_RESULTS}&key=${getApiKey()}`; + const data = await fetchJSON(url); + if (data.error) { + if (data.error.message && data.error.message.includes('quota')) { + rotateKey(); + const url2 = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(query)}&type=video&order=relevance&publishedAfter=${publishedAfter}&maxResults=${MAX_RESULTS}&key=${getApiKey()}`; + const data2 = await fetchJSON(url2); + if (data2.error) { console.error(' Search API error (both keys exhausted):', data2.error.message); return []; } + return (data2.items || []).map((item) => ({ id: item.id.videoId, title: item.snippet.title, publishedAt: item.snippet.publishedAt, channelId: item.snippet.channelId, channelTitle: item.snippet.channelTitle, thumbnail: item.snippet.thumbnails.high ? item.snippet.thumbnails.high.url : item.snippet.thumbnails.default.url })); + } + console.error(' Search API error:', data.error.message); + return []; + } + return (data.items || []).map((item) => ({ + id: item.id.videoId, + title: item.snippet.title, + publishedAt: item.snippet.publishedAt, + channelId: item.snippet.channelId, + channelTitle: item.snippet.channelTitle, + thumbnail: item.snippet.thumbnails.high ? item.snippet.thumbnails.high.url : item.snippet.thumbnails.default.url, + })); +} + +async function getVideoDetails(videoIds) { + if (videoIds.length === 0) return {}; + const url = `https://www.googleapis.com/youtube/v3/videos?part=statistics,contentDetails&id=${videoIds.join(',')}&key=${getApiKey()}`; + const data = await fetchJSON(url); + if (data.error) { + console.error(' Video details API error:', data.error.message); + return {}; + } + const map = {}; + for (const item of (data.items || [])) { + const dur = parseDuration(item.contentDetails.duration); + map[item.id] = { + views: parseInt(item.statistics.viewCount || '0', 10), + likes: parseInt(item.statistics.likeCount || '0', 10), + comments: parseInt(item.statistics.commentCount || '0', 10), + duration: formatDuration(dur), + durationSeconds: dur, + }; + } + return map; +} + +async function getChannelInfo(channelId) { + const url = `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet,contentDetails&id=${channelId}&key=${getApiKey()}`; + const data = await fetchJSON(url); + if (data.error || !data.items || data.items.length === 0) return null; + const ch = data.items[0]; + return { + subs: parseInt(ch.statistics.subscriberCount || '0', 10), + uploadsPlaylistId: ch.contentDetails.relatedPlaylists.uploads, + thumb: ch.snippet.thumbnails.default ? ch.snippet.thumbnails.default.url : '', + }; +} + +async function getChannelMedianViews(uploadsPlaylistId) { + const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&playlistId=${uploadsPlaylistId}&maxResults=30&key=${getApiKey()}`; + const data = await fetchJSON(url); + if (data.error || !data.items || data.items.length === 0) return 0; + const videoIds = data.items.map((i) => i.contentDetails.videoId); + // Batch in groups of 50 (API limit) + const allViews = []; + for (let i = 0; i < videoIds.length; i += 50) { + const batch = videoIds.slice(i, i + 50); + const vUrl = `https://www.googleapis.com/youtube/v3/videos?part=statistics&id=${batch.join(',')}&key=${getApiKey()}`; + const vData = await fetchJSON(vUrl); + if (vData.items) { + for (const v of vData.items) { + allViews.push(parseInt(v.statistics.viewCount || '0', 10)); + } + } + } + return median(allViews); +} + +// ── Main scanner ──────────────────────────────────────────────────────────────── +async function main() { + console.log('=== YouTube Outlier Scanner ==='); + console.log(`Scanning ${Object.keys(NICHES).length} niches, ${Object.values(NICHES).reduce((s, n) => s + n.queries.length, 0)} queries total\n`); + + const publishedAfter = new Date(Date.now() - DAYS_AGO * 24 * 60 * 60 * 1000).toISOString(); + const seenIds = new Set(); + const allVideos = []; + const nicheCounts = {}; + const channelCache = {}; // channelId -> { subs, medianViews, thumb } + + for (const [nicheKey, niche] of Object.entries(NICHES)) { + console.log(`\n── ${niche.label} ──`); + nicheCounts[nicheKey] = { label: niche.label, count: 0 }; + + for (const query of niche.queries) { + console.log(` Searching: "${query}"`); + + const results = await searchVideos(query, publishedAfter); + if (results.length === 0) { console.log(' No results'); continue; } + + // Filter already-seen + const fresh = results.filter((v) => !seenIds.has(v.id)); + if (fresh.length === 0) { console.log(' All duplicates, skipping'); continue; } + + // Get video details + const details = await getVideoDetails(fresh.map((v) => v.id)); + + for (const video of fresh) { + if (seenIds.has(video.id)) continue; + const det = details[video.id]; + if (!det) continue; + + // Skip Shorts (<=90s) + if (det.durationSeconds <= 90) continue; + + // Get/cache channel info + if (!channelCache[video.channelId]) { + const chInfo = await getChannelInfo(video.channelId); + if (!chInfo) continue; + if (chInfo.subs > MAX_CHANNEL_SUBS) { + channelCache[video.channelId] = { subs: chInfo.subs, skip: true }; + continue; + } + const medViews = await getChannelMedianViews(chInfo.uploadsPlaylistId); + channelCache[video.channelId] = { subs: chInfo.subs, medianViews: medViews, thumb: chInfo.thumb, skip: false }; + } + + const ch = channelCache[video.channelId]; + if (ch.skip) continue; + if (ch.medianViews === 0) continue; + + const outlierScore = Math.round((det.views / ch.medianViews) * 10) / 10; + if (outlierScore < MIN_OUTLIER_SCORE) continue; + + const daysSincePublish = Math.max(1, Math.floor((Date.now() - new Date(video.publishedAt).getTime()) / (1000 * 60 * 60 * 24))); + const velocityScore = Math.round(det.views / daysSincePublish); // views per day + + seenIds.add(video.id); + nicheCounts[nicheKey].count++; + + allVideos.push({ + id: video.id, + title: video.title, + publishedAt: video.publishedAt, + thumbnail: video.thumbnail, + views: det.views, + likes: det.likes, + comments: det.comments, + duration: det.duration, + durationSeconds: det.durationSeconds, + outlierScore, + velocityScore, + daysSincePublish, + niche: nicheKey, + nicheLabel: niche.label, + query, + channelId: video.channelId, + channelTitle: video.channelTitle, + channelSubs: ch.subs, + channelMedianViews: ch.medianViews, + channelThumb: ch.thumb, + }); + + console.log(` ✓ ${outlierScore}x | ${det.views.toLocaleString()} views | ${video.title.slice(0, 60)}`); + } + } + } + + // Sort by outlierScore descending + allVideos.sort((a, b) => b.outlierScore - a.outlierScore); + + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10); + const output = { + date: dateStr, + scannedAt: now.toISOString(), + totalOutliers: allVideos.length, + nicheCounts, + videos: allVideos, + }; + + // Save to data/ + const dataDir = path.join(__dirname, '..', 'data'); + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + + const datePath = path.join(dataDir, `${dateStr}.json`); + const latestPath = path.join(dataDir, 'latest.json'); + fs.writeFileSync(datePath, JSON.stringify(output, null, 2)); + fs.writeFileSync(latestPath, JSON.stringify(output, null, 2)); + + console.log(`\n=== Done ===`); + console.log(`Total outliers: ${allVideos.length}`); + console.log(`Saved to: ${datePath}`); + console.log(`Saved to: ${latestPath}`); + for (const [k, v] of Object.entries(nicheCounts)) { + console.log(` ${v.label}: ${v.count}`); + } + + // ── Sync to Prisma DB ─────────────────────────────────────────────────────── + console.log('\n── Syncing to database…'); + try { + const { Client } = require('pg'); + // Try .env.production.local first, then .env.local + let dbUrl = ''; + for (const envFile of ['.env.production.local', '.env.local']) { + const envPath = path.join(__dirname, '..', envFile); + if (!fs.existsSync(envPath)) continue; + const envLines = fs.readFileSync(envPath, 'utf-8').split('\n'); + for (const line of envLines) { + const t = line.trim(); + if (t.startsWith('DATABASE_URL=')) { + dbUrl = t.slice('DATABASE_URL='.length).trim().replace(/^["']|["']$/g, ''); + break; + } + } + if (dbUrl) break; + } + const client = new Client({ connectionString: dbUrl, ssl: { rejectUnauthorized: false } }); + await client.connect(); + const dataJson = JSON.stringify(output); + await client.query( + `INSERT INTO "DataStore" (key, data, "updatedAt") + VALUES ($1, $2::jsonb, NOW()) + ON CONFLICT (key) DO UPDATE SET data = $2::jsonb, "updatedAt" = NOW()`, + ['youtube-outliers', dataJson] + ); + await client.end(); + console.log('✓ Database synced successfully'); + } catch (err) { + console.error('✗ DB sync failed:', err.message); + console.error(' (Local files still saved — run sync manually)'); + } +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..502406b --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +/gen/schemas diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..3a741a5 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,4932 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-log", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.11+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.13.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.10.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1025aa560c1eaa14ef96de5540cbe13ed6be1933ebe4398338c96bc370b807c6" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76809f63061c8b25537b87f46b8733b31397cf419706dc874e1602be6479ba90" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.11+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2ebe49d690ccaea93aa81fff99277d4f445968f085ba13be67859151e9e4b8" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.114", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1119f651b0187c686c0fc72c66bba311e560e1b5f61869086ce788d43be6cf41" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.11+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..046b202 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "app" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.77.2" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.5.4", features = [] } + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +log = "0.4" +tauri = { version = "2.10.0", features = [] } +tauri-plugin-log = "2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..c135d7f --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..f08dc7c Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..1138e41 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..ce3f133 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..f35d84f Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..1823bb2 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..dc2b22c Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..0ed3984 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..60bf0ea Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..c8ca0ad Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..8756459 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..2c8023c Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..2c5e603 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..17d142c Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..4ecf740 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..115eeda Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..9c3118c --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,16 @@ +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..ad5fe83 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run(); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..e9b9bbe --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,39 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Hermy HQ", + "version": "0.1.0", + "identifier": "com.hermyhq.app", + "build": { + "frontendDist": "../out", + "devUrl": "http://localhost:3000", + "beforeDevCommand": "", + "beforeBuildCommand": "" + }, + "app": { + "windows": [ + { + "title": "Hermy HQ", + "width": 1200, + "height": 800, + "minWidth": 900, + "minHeight": 600, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx new file mode 100644 index 0000000..18fca9a --- /dev/null +++ b/src/app/agents/page.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import OfficeView from "@/components/OfficeView"; + +interface AgentActivity { + timestamp: string; + action: string; + result?: string; +} + +interface Agent { + id: string; + name: string; + emoji: string; + role: string; + status: "idle" | "working" | "error" | "offline"; + currentTask?: string; + lastActive?: string; + tasksCompleted: number; + totalCost: number; + recentActivity: AgentActivity[]; +} + +const statusConfig: Record<string, { color: string; dot: string; label: string; pulse?: boolean }> = { + idle: { color: "var(--warn)", dot: "var(--warn)", label: "Idle" }, + working: { color: "var(--accent)", dot: "var(--accent)", label: "Working", pulse: true }, + error: { color: "var(--down)", dot: "var(--down)", label: "Error" }, + offline: { color: "var(--text-3)", dot: "var(--text-4)", label: "Offline" }, + online: { color: "var(--up)", dot: "var(--up)", label: "Online", pulse: true }, + active: { color: "var(--up)", dot: "var(--up)", label: "Active", pulse: true }, + mixed: { color: "var(--warn)", dot: "var(--warn)", label: "Partial" }, +}; + +const roleColors: Record<string, string> = { + max: "from-amber-500/20 to-amber-600/5 border-amber-500/20", + sage: "from-sky-500/20 to-sky-600/5 border-sky-500/20", + knox: "from-emerald-500/20 to-emerald-600/5 border-emerald-500/20", + nova: "from-purple-500/20 to-purple-600/5 border-purple-500/20", + pixel: "from-blue-500/20 to-blue-600/5 border-blue-500/20", +}; + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function AgentCard({ agent, isExpanded, onToggle }: { agent: Agent; isExpanded: boolean; onToggle: () => void }) { + const status = statusConfig[agent.status] || statusConfig.offline; + + return ( + <div className="panel panel-interactive overflow-hidden"> + {/* Main card */} + <div className="p-5 cursor-pointer" onClick={onToggle}> + <div className="flex items-start gap-3.5"> + {/* Avatar */} + <div className="w-12 h-12 rounded-[var(--r-md)] flex items-center justify-center text-2xl shrink-0" + style={{ background: "var(--surface-2)", border: "1px solid var(--line)" }}> + {agent.emoji} + </div> + + {/* Info */} + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="relative flex w-2 h-2 shrink-0"> + {status.pulse && <span className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping" style={{ background: status.dot }} />} + <span className="relative inline-flex w-2 h-2 rounded-full" style={{ background: status.dot }} /> + </span> + <h3 className="text-[14px] font-semibold text-[var(--text)]">{agent.name}</h3> + <span className="text-[10px] font-medium" style={{ color: status.color }}>{status.label}</span> + </div> + <p className="text-[12px] text-[var(--text-3)] mt-1">{agent.role}</p> + + {/* Current task */} + {agent.currentTask && agent.status === "working" && ( + <p className="text-[12px] mt-2 truncate" style={{ color: "var(--accent)" }}>{agent.currentTask}</p> + )} + </div> + + {/* Stats */} + <div className="text-right shrink-0"> + <div className="num text-[22px] font-semibold text-[var(--text)] leading-none">{agent.tasksCompleted}</div> + <div className="eyebrow mt-1.5">tasks</div> + {agent.lastActive && ( + <div className="num text-[10px] text-[var(--text-4)] mt-1">{timeAgo(agent.lastActive)}</div> + )} + </div> + </div> + </div> + + {/* Expanded activity feed */} + {isExpanded && ( + <div className="px-5 py-4 space-y-2.5" style={{ borderTop: "1px solid var(--line)" }}> + <h4 className="eyebrow">Recent Activity</h4> + {agent.recentActivity.length === 0 ? ( + <p className="text-[12px] text-[var(--text-3)] py-2">No activity yet</p> + ) : ( + <div className="space-y-2 max-h-60 overflow-y-auto"> + {agent.recentActivity.slice(0, 10).map((activity, i) => ( + <div key={i} className="flex items-start gap-2.5 text-[12px]"> + <span className="num text-[var(--text-4)] shrink-0 w-14">{timeAgo(activity.timestamp)}</span> + <span className="text-[var(--text-2)]">{activity.action}</span> + {activity.result && ( + <span className="text-[var(--text-3)] ml-auto truncate max-w-[200px]">{activity.result}</span> + )} + </div> + ))} + </div> + )} + </div> + )} + </div> + ); +} + +// ── Live Agent Chat ─────────────────────────────────────── +function AgentChat({ agent, onClose }: { agent: Agent; onClose: () => void }) { + const [input, setInput] = useState(""); + const [msgs, setMsgs] = useState<{ role: "user"|"assistant"; content: string }[]>([]); + const [loading, setLoading] = useState(false); + const endRef = useRef<HTMLDivElement>(null); + + useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [msgs]); + + async function send() { + const text = input.trim(); + if (!text || loading) return; + setInput(""); + const newMsgs = [...msgs, { role: "user" as const, content: text }]; + setMsgs(newMsgs); + setLoading(true); + try { + const r = await fetch("/api/agent-chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: agent.id, message: text, history: msgs }), + }); + const d = await r.json() as { reply: string }; + setMsgs([...newMsgs, { role: "assistant", content: d.reply }]); + } catch { + setMsgs([...newMsgs, { role: "assistant", content: "Sorry, something went wrong. Try again." }]); + } + setLoading(false); + } + + const agentColor = roleColors[agent.id]?.split(" ")[0]?.replace("from-","text-")?.replace("/20","") || "text-[var(--text-3)]"; + + return ( + <div className="fixed inset-0 z-50 flex items-end md:items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}> + <div className="elevated w-full max-w-lg overflow-hidden" onClick={e => e.stopPropagation()}> + {/* Header */} + <div className="flex items-center gap-3 px-4 py-3.5" style={{ borderBottom: "1px solid var(--line)" }}> + <div className="text-2xl">{agent.emoji}</div> + <div> + <div className="text-[14px] font-semibold text-[var(--text)]">{agent.name}</div> + <div className="text-[12px] text-[var(--text-3)]">{agent.role}</div> + </div> + <button onClick={onClose} className="ml-auto text-[var(--text-3)] hover:text-[var(--text)] transition-colors text-xl leading-none">×</button> + </div> + {/* Messages */} + <div className="h-80 overflow-y-auto p-4 space-y-3 flex flex-col" style={{ background: "var(--surface-1)" }}> + {msgs.length === 0 && ( + <div className="flex-1 flex items-center justify-center"> + <p className="text-[var(--text-3)] text-[13px] text-center">Ask {agent.name} anything.<br/>They're ready.</p> + </div> + )} + {msgs.map((m, i) => ( + <div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}> + <div className="max-w-[80%] rounded-[var(--r-md)] px-3.5 py-2 text-[13px] leading-relaxed" + style={m.role === "user" + ? { background: "var(--surface-3)", color: "var(--text)" } + : { background: "var(--surface-2)", border: "1px solid var(--line)", color: "var(--text-2)" }}> + {m.role === "assistant" && <span className="text-xs mr-1">{agent.emoji}</span>} + {m.content} + </div> + </div> + ))} + {loading && ( + <div className="flex justify-start"> + <div className="rounded-[var(--r-md)] px-3.5 py-2" style={{ background: "var(--surface-2)", border: "1px solid var(--line)" }}> + <span className="text-[var(--text-3)] text-[13px]">{agent.emoji} thinking…</span> + </div> + </div> + )} + <div ref={endRef} /> + </div> + {/* Input */} + <div className="flex gap-2 p-3" style={{ borderTop: "1px solid var(--line)" }}> + <input + value={input} + onChange={e => setInput(e.target.value)} + onKeyDown={e => e.key === "Enter" && !e.shiftKey && send()} + placeholder={`Message ${agent.name}…`} + className="flex-1 rounded-full px-4 py-2 text-[13px] text-[var(--text)] focus:outline-none transition-colors" + style={{ background: "var(--surface-1)", border: "1px solid var(--line)" }} + /> + <button + onClick={send} + disabled={!input.trim() || loading} + className="btn-primary px-4 py-2 text-[13px]" + >Send</button> + </div> + </div> + </div> + ); +} + +export default function AgentsPage() { + const [agents, setAgents] = useState<Agent[]>([]); + const [loading, setLoading] = useState(true); + const [expandedAgent, setExpandedAgent] = useState<string | null>(null); + const [view, setView] = useState<"cards" | "office">("office"); + const [chatAgent, setChatAgent] = useState<Agent | null>(null); + + const loadAgents = useCallback(async () => { + try { + const res = await fetch("/api/agents"); + const data = await res.json(); + setAgents(Array.isArray(data) ? data : []); + } catch {} + setLoading(false); + }, []); + + useEffect(() => { + loadAgents(); + const interval = setInterval(loadAgents, 10000); // poll every 10s + return () => clearInterval(interval); + }, [loadAgents]); + + if (loading) { + return ( + <div className="relative min-h-screen p-8"> + <div className="relative z-10 w-full mx-auto grid grid-cols-1 md:grid-cols-2 gap-4"> + {[...Array(4)].map((_, i) => <div key={i} className="sk h-32 rounded-[var(--r-lg)]" />)} + </div> + </div> + ); + } + + const maxAgent = agents.find(a => a.id === "max"); + const teamAgents = agents.filter(a => a.id !== "max"); + const online = agents.filter(a => a.status !== "offline").length; + const working = agents.filter(a => a.status === "working").length; + const totalTasks = agents.reduce((sum, a) => sum + a.tasksCompleted, 0); + + return ( + <> + <div className="relative z-10 w-full mx-auto text-[var(--text)] p-8 pb-16 space-y-8"> + {/* Header */} + <div className="hq-rise flex flex-wrap items-end justify-between gap-4"> + <div> + <div className="eyebrow mb-2.5">Agent HQ</div> + <h1 className="text-[32px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]">Your AI Team</h1> + <p className="text-[13px] text-[var(--text-3)] mt-3">Working 24/7</p> + </div> + <div className="flex items-center gap-6"> + {/* Stats */} + <div className="flex gap-7 text-center"> + <div> + <div className="num text-[22px] font-semibold leading-none" style={{ color: "var(--up)" }}>{online}<span className="text-[var(--text-4)]">/{agents.length}</span></div> + <div className="eyebrow mt-1.5">Online</div> + </div> + <div> + <div className="num text-[22px] font-semibold leading-none" style={{ color: "var(--accent)" }}>{working}</div> + <div className="eyebrow mt-1.5">Working</div> + </div> + <div> + <div className="num text-[22px] font-semibold leading-none text-[var(--text)]">{totalTasks}</div> + <div className="eyebrow mt-1.5">Total Tasks</div> + </div> + </div> + {/* View toggle */} + <div className="flex rounded-full p-1 gap-1" style={{ border: "1px solid var(--line)" }}> + <button + onClick={() => setView("office")} + className={`px-3.5 py-1.5 rounded-full text-[12px] font-medium transition-colors ${ + view === "office" + ? "bg-white/[0.08] text-[var(--text)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)]" + }`} + > + Office + </button> + <button + onClick={() => setView("cards")} + className={`px-3.5 py-1.5 rounded-full text-[12px] font-medium transition-colors ${ + view === "cards" + ? "bg-white/[0.08] text-[var(--text)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)]" + }`} + > + Cards + </button> + </div> + </div> + </div> + + {/* Live Agent Chat Modal */} + {chatAgent && <AgentChat agent={chatAgent} onClose={() => setChatAgent(null)} />} + + {/* Office View */} + {view === "office" && ( + <> + <OfficeView agents={agents} /> + {/* Chat quick-launch strip */} + <div className="flex flex-wrap gap-2 pt-2"> + {agents.filter(a => a.id !== "max").map(a => ( + <button key={a.id} onClick={() => setChatAgent(a)} + className="flex items-center gap-2 px-3.5 py-1.5 rounded-full text-[12px] text-[var(--text-2)] transition-colors panel-interactive" + style={{ background: "var(--surface-1)", border: "1px solid var(--line)" }}> + <span>{a.emoji}</span> Chat with {a.name} + </button> + ))} + {agents.find(a => a.id === "max") && ( + <button onClick={() => setChatAgent(agents.find(a => a.id === "max")!)} + className="flex items-center gap-2 px-3.5 py-1.5 rounded-full text-[12px] transition-colors" + style={{ color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 28%, transparent)" }}> + 🐺 Chat with Max + </button> + )} + </div> + </> + )} + + {/* Cards View */} + {view === "cards" && ( + <> + {/* Chief of Staff (Max) — full width */} + {maxAgent && ( + <AgentCard + agent={maxAgent} + isExpanded={expandedAgent === maxAgent.id} + onToggle={() => setExpandedAgent(expandedAgent === maxAgent.id ? null : maxAgent.id)} + /> + )} + + {/* Team grid */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {teamAgents.map(agent => ( + <AgentCard + key={agent.id} + agent={agent} + isExpanded={expandedAgent === agent.id} + onToggle={() => setExpandedAgent(expandedAgent === agent.id ? null : agent.id)} + /> + ))} + </div> + + {/* Org chart visual */} + <div className="pt-6" style={{ borderTop: "1px solid var(--line)" }}> + <div className="eyebrow mb-5">Team Structure</div> + <div className="flex flex-col items-center gap-2"> + <div className="flex items-center gap-2.5 rounded-[var(--r-md)] px-4 py-2.5" + style={{ background: "color-mix(in srgb, var(--accent) 10%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 24%, transparent)" }}> + <span className="text-xl">🐺</span> + <div> + <div className="text-[13px] font-semibold text-[var(--text)]">Max</div> + <div className="text-[10px] text-[var(--text-3)]">Chief of Staff · Orchestrator</div> + </div> + </div> + <div className="w-px h-6" style={{ background: "var(--line-strong)" }} /> + <div className="flex items-center gap-0"> + <div className="w-32 h-px" style={{ background: "var(--line-strong)" }} /> + <div className="w-px h-4" style={{ background: "var(--line-strong)" }} /> + <div className="w-32 h-px" style={{ background: "var(--line-strong)" }} /> + <div className="w-px h-4" style={{ background: "var(--line-strong)" }} /> + <div className="w-32 h-px" style={{ background: "var(--line-strong)" }} /> + </div> + <div className="flex flex-wrap justify-center gap-3"> + {teamAgents.map(agent => ( + <div key={agent.id} className="flex items-center gap-2.5 rounded-[var(--r-md)] px-3.5 py-2.5" + style={{ background: "var(--surface-1)", border: "1px solid var(--line)", opacity: agent.status === "offline" ? 0.5 : 1 }}> + <span className="text-lg">{agent.emoji}</span> + <div> + <div className="text-[12px] font-semibold text-[var(--text)]">{agent.name}</div> + <div className="text-[10px] text-[var(--text-3)]">{agent.role}</div> + </div> + </div> + ))} + </div> + </div> + </div> + </> + )} + </div> + </> + ); +} diff --git a/src/app/api/agent-bus/route.ts b/src/app/api/agent-bus/route.ts new file mode 100644 index 0000000..f0c1c32 --- /dev/null +++ b/src/app/api/agent-bus/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +// GET: Read messages for a specific agent (or all) +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const agent = searchParams.get("agent"); + const unreadOnly = searchParams.get("unread") === "true"; + + const where: any = {}; + + if (agent) { + where.OR = [{ toAgent: agent }, { toAgent: "all" }]; + } + if (unreadOnly) { + where.read = false; + } + + const messages = await prisma.agentBusMessage.findMany({ + where, + orderBy: { timestamp: "desc" }, + take: 50, + }); + + // Reverse to return in chronological order (oldest first, like the original .slice(-50)) + const formatted = messages.reverse().map((m: any) => ({ + id: m.id, + from: m.fromAgent, + to: m.toAgent, + type: m.type, + content: m.content, + metadata: m.metadata || {}, + timestamp: m.timestamp.toISOString(), + read: m.read, + })); + + return NextResponse.json({ messages: formatted }); +} + +// POST: Send a message between agents +export async function POST(req: Request) { + const body = await req.json(); + + const msg = await prisma.agentBusMessage.create({ + data: { + fromAgent: body.from || "unknown", + toAgent: body.to || "max", + type: body.type || "insight", + content: body.content || "", + metadata: body.metadata || {}, + read: false, + }, + }); + + return NextResponse.json({ ok: true, id: msg.id }); +} + +// PATCH: Mark messages as read +export async function PATCH(req: Request) { + const { messageIds } = await req.json(); + + await prisma.agentBusMessage.updateMany({ + where: { id: { in: messageIds } }, + data: { read: true }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/agent-chat/route.ts b/src/app/api/agent-chat/route.ts new file mode 100644 index 0000000..947d4dd --- /dev/null +++ b/src/app/api/agent-chat/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; + +interface AgentChatRequest { + agentId: string; + message: string; + history?: Array<{ role: 'user' | 'assistant'; content: string }>; +} + +interface AgentChatResponse { + reply: string; + agentId: string; +} + +const AGENT_PROMPTS: Record<string, string> = { + max: "You are Max 🐺, an AI executive assistant and COO-level strategist helping the user run their business. The user is a founder and content creator who runs AI trading bots. Be sharp, concise, strategic. Give real actionable advice.", + sage: "You are Sage 🌿, X/Twitter content specialist for the user. You write viral tweets in their voice — conversational, sharp, specific. Focus on hooks that make people stop scrolling. No fluff.", + knox: "You are Knox 🔐, operations and trading analyst for the user. You analyze Polymarket and Hyperliquid trading performance, spot patterns, suggest strategy improvements. Be data-driven and direct.", + nova: "You are Nova ⭐, YouTube strategy specialist for the user. You write scripts, hooks, thumbnails, titles. Think Mr Beast structure applied to the user's niche.", + pixel: "You are Pixel 🎨, web app product specialist for the user's products. You find UX improvements, feature ideas, competitor gaps. Think product manager + growth hacker.", +}; + +export async function POST(request: NextRequest): Promise<NextResponse<AgentChatResponse | { error: string }>> { + try { + const body: AgentChatRequest = await request.json(); + const { agentId, message, history = [] } = body; + + // Validate inputs + if (!agentId || !message) { + return NextResponse.json( + { error: 'Missing agentId or message' }, + { status: 400 } + ); + } + + if (!AGENT_PROMPTS[agentId]) { + return NextResponse.json( + { error: `Unknown agent: ${agentId}` }, + { status: 400 } + ); + } + + const systemPrompt = AGENT_PROMPTS[agentId]; + const apiKey = process.env.OPENROUTER_API_KEY; + + if (!apiKey) { + console.error('OPENROUTER_API_KEY not configured'); + return NextResponse.json( + { error: 'API configuration error' }, + { status: 500 } + ); + } + + // Build messages array: system prompt + history + current message + const messages = [ + ...history, + { role: 'user' as const, content: message }, + ]; + + // Call OpenRouter API + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://your-app.vercel.app', + }, + body: JSON.stringify({ + model: 'anthropic/claude-haiku-4-5', + messages: [ + { role: 'system', content: systemPrompt }, + ...messages, + ], + max_tokens: 800, + }), + }); + + if (!response.ok) { + const error = await response.text(); + console.error('OpenRouter API error:', error); + return NextResponse.json( + { error: 'Failed to get response from AI model' }, + { status: 500 } + ); + } + + const data = await response.json(); + const reply = data.choices?.[0]?.message?.content || ''; + + if (!reply) { + return NextResponse.json( + { error: 'No response from AI model' }, + { status: 500 } + ); + } + + return NextResponse.json({ + reply, + agentId, + }); + } catch (error) { + console.error('Agent chat error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts new file mode 100644 index 0000000..db06113 --- /dev/null +++ b/src/app/api/agents/route.ts @@ -0,0 +1,147 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +// Default agent roster +const DEFAULT_AGENTS = [ + { + id: "max", + name: "Max", + emoji: "\uD83D\uDC3A", + role: "Chief of Staff \u00B7 Orchestrator", + status: "online", + tasksCompleted: 0, + totalCost: 0, + recentActivity: [], + }, + { + id: "sage", + name: "Sage", + emoji: "\uD83C\uDF3F", + role: "X Content Specialist \u00B7 Trend Scout", + status: "idle", + tasksCompleted: 0, + totalCost: 0, + recentActivity: [], + }, + { + id: "knox", + name: "Knox", + emoji: "\uD83D\uDD10", + role: "Trading Operations \u00B7 Bot Monitor", + status: "idle", + tasksCompleted: 0, + totalCost: 0, + recentActivity: [], + }, + { + id: "nova", + name: "Nova", + emoji: "\u2B50", + role: "YouTube Strategy \u00B7 Content Research", + status: "idle", + tasksCompleted: 0, + totalCost: 0, + recentActivity: [], + }, + { + id: "pixel", + name: "Pixel", + emoji: "\uD83C\uDFA8", + role: "Web App Specialist \u00B7 Product Ideas", + status: "idle", + tasksCompleted: 0, + totalCost: 0, + recentActivity: [], + }, +]; + +export async function GET() { + try { + const states = await prisma.agentState.findMany(); + const stateMap: Record<string, any> = {}; + for (const s of states) { + stateMap[s.id] = s; + } + + const agents = DEFAULT_AGENTS.map((agent) => { + const s = stateMap[agent.id] || {}; + return { + ...agent, + status: s.status || agent.status, + currentTask: s.currentTask || undefined, + lastActive: s.lastActive || undefined, + tasksCompleted: s.tasksCompleted || agent.tasksCompleted, + totalCost: s.totalCost || agent.totalCost, + recentActivity: s.recentActivity || agent.recentActivity, + }; + }); + + return NextResponse.json(agents, { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }); + } catch (error) { + console.error("Agents API error:", error); + return NextResponse.json(DEFAULT_AGENTS, { status: 200 }); + } +} + +// POST to update agent state (called by cron jobs) +export async function POST(request: Request) { + try { + const body = await request.json(); + const { agentId, action, status, currentTask } = body; + + if (!agentId) { + return NextResponse.json({ error: "agentId required" }, { status: 400 }); + } + + // Find the default agent info for name/emoji/role + const defaultAgent = DEFAULT_AGENTS.find((a) => a.id === agentId); + + // Get existing state or create defaults + let existing = await prisma.agentState.findUnique({ where: { id: agentId } }); + + const recentActivity = (existing?.recentActivity as any[]) || []; + const newRecentActivity = action + ? [ + { timestamp: new Date().toISOString(), action }, + ...recentActivity.slice(0, 19), + ] + : recentActivity; + + const updatedState = await prisma.agentState.upsert({ + where: { id: agentId }, + update: { + ...(status ? { status } : {}), + ...(currentTask !== undefined ? { currentTask } : {}), + lastActive: new Date(), + ...(action + ? { + recentActivity: newRecentActivity, + tasksCompleted: (existing?.tasksCompleted || 0) + 1, + } + : {}), + }, + create: { + id: agentId, + name: defaultAgent?.name || agentId, + emoji: defaultAgent?.emoji, + role: defaultAgent?.role, + status: status || "idle", + currentTask: currentTask || null, + lastActive: new Date(), + tasksCompleted: action ? 1 : 0, + totalCost: 0, + recentActivity: newRecentActivity, + }, + }); + + return NextResponse.json({ ok: true, agent: updatedState }); + } catch (error) { + console.error("Agent update error:", error); + return NextResponse.json({ error: "Failed to update" }, { status: 500 }); + } +} diff --git a/src/app/api/articles/generate-article/route.ts b/src/app/api/articles/generate-article/route.ts new file mode 100644 index 0000000..cfac22e --- /dev/null +++ b/src/app/api/articles/generate-article/route.ts @@ -0,0 +1,400 @@ +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +import { NextRequest, NextResponse } from "next/server"; +import { fetchUrlContent } from "@/lib/fetch-url-content"; + +const TRACK_FORMULAS: Record<string, string> = { + "mega-viral": `MEGA-VIRAL FORMULA (Data Betrayal Story): +You thought X was [innocent thing]. Actually, [shocking betrayal]. + +STRUCTURE: +1. OPENING: Start with ONE specific shocking number or fact. No context yet. Just the number that makes someone stop scrolling. + Example: "2,373,711 DNA samples. Sold for $10 million. To a company you've never heard of." + +2. "Here's the full story." — This is the permission line. Brief and punchy. + +3. HOW IT STARTED: The innocent origin story. Make the reader feel like they were there. + - When did this start? + - What did users think they were signing up for? + - The promise vs the reality + +4. WHAT ACTUALLY HAPPENED: The betrayal. Be specific. + - The exact mechanism of the privacy violation + - The scale (numbers, numbers, numbers) + - Who benefited and how much + +5. THE SCALE NOBODY TALKS ABOUT: Expand the damage. + - How many people affected + - Ongoing implications + - What this data is actually used for + +6. THE LOOPHOLE: Why this was "legal" or why nobody talks about it. + - Terms of service buried language + - Regulatory gaps + - Industry collusion + +7. THE IRONY: The twist that should bother everyone. + - Often: "you paid THEM to collect YOUR data" + - Or: "the people who should protect you are profiting" + +8. KILLER CLOSING: One sentence that captures everything. Quotable. + Example: "You didn't buy a speaker. You bought a microphone." + +9. SOURCES: List 3-5 real sources (use placeholder URLs like [Source: Company Name]) + +10. CTA: "Follow me @yourhandle on X for more stories like this." + And: "Need help with AI strategy? FounderFunnel.com"`, + + "local-viral": `LOCAL-VIRAL FORMULA (Practical AI Guide): +Here's exactly how I [specific result] using [specific tool]. + +STRUCTURE: +1. OPENING: The result that hooks them. Be specific about time saved, money made, or capability unlocked. + Example: "I wrote 47 articles last month. Total time: 12 hours. Here's my exact workflow." + +2. THE SETUP: What tools you're using (be specific about versions, models, costs). + - Model names (Claude Sonnet 4, GPT-4, etc.) + - Monthly costs + - Your tech stack + +3. THE WORKFLOW: Numbered steps with actual commands/prompts. + - Use code blocks for prompts and commands + - Include the exact settings/parameters + - Show real examples from your work + +4. THE PROMPTS: Share copy-paste value. + - The actual prompts you use (in code blocks) + - Why each section of the prompt matters + - Variations for different use cases + +5. WHAT I GOT WRONG: The mistakes section (builds trust). + - Initial approach that didn't work + - Why it failed + - The insight that fixed it + +6. RESULTS: Concrete before/after. + - Time comparison + - Quality comparison + - Cost comparison + +7. CTA: "Follow me @yourhandle on X for more AI workflows." + And: "Building something with AI? FounderFunnel.com"`, + + "icp-viral": `ICP-VIRAL FORMULA (Trojan Horse — Viral Content with Business CTA): +The content goes viral as useful/interesting. The CTA books calls. + +STRUCTURE: +1. OPENING: A result that grabs attention (revenue, clients, transformation). + Example: "I went from $5K to $47K months in 90 days. Here's the exact system." + +2. THE BACKSTORY: Relatable struggle → breakthrough moment. + - Where you were (relatable pain) + - The conventional wisdom that wasn't working + - The moment something changed + +3. THE SYSTEM: The framework/process (this is the viral part). + - Make it actionable and valuable standalone + - Use numbered steps + - Include specific examples + +4. THE TEMPLATES: Actual copy-paste assets. + - Email templates in code blocks + - Scripts they can steal + - Frameworks visualized + +5. THE RESULTS: Social proof. + - Your results with numbers + - Client results if applicable + - Timeline + +6. THE DEEPER LAYER: Tease what you don't share publicly. + - "This is the 80/20. The other 20% requires customization." + - Build curiosity for the service + +7. CTA: This is the business hook. + - "Want help implementing this? I work with [ICP description]." + - "Book a call at FounderFunnel.com" + - "DM me 'SYSTEM' and I'll send you the full playbook." + +8. Follow: "@yourhandle on X"`, +}; + +// POST /api/articles/generate-article — accepts { title, track, themes, inspirationUrls, libraryInspirations } +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { title, track, themes, inspirationUrls, libraryInspirations } = body; + + const selectedThemes: string[] = themes ? JSON.parse(themes) : []; + const urls: string[] = inspirationUrls ? JSON.parse(inspirationUrls) : []; + const libArticles: { title: string; body: string; impressions: number | null; likes: number | null; bookmarks: number | null }[] = libraryInspirations || []; + + // Fetch external URL content in parallel + const urlContents = await Promise.all( + urls.map((url) => fetchUrlContent(url, { maxChars: 3000 })) + ); + const externalContext = urlContents + .filter((c) => c && !c.includes("Could not extract")) + .map((content, i) => `External inspiration ${i + 1}:\n${content}`) + .join("\n\n"); + + // Build library article context with full bodies + viral analysis instructions + const libraryContext = libArticles + .map((a, i) => { + const metrics = [ + a.impressions != null ? `${a.impressions.toLocaleString()} impressions` : null, + a.likes != null ? `${a.likes.toLocaleString()} likes` : null, + a.bookmarks != null ? `${a.bookmarks.toLocaleString()} bookmarks` : null, + ].filter(Boolean).join(", "); + const bodyPreview = a.body.slice(0, 3000); + return `--- YOUR ARTICLE: "${a.title}" (${metrics || "no metrics"}) ---\n${bodyPreview}`; + }) + .join("\n\n"); + + const formula = TRACK_FORMULAS[track] || TRACK_FORMULAS["local-viral"]; + + // Real voice examples from your actual viral articles + const voiceExamples = `== YOUR ACTUAL VIRAL ARTICLES (STUDY THESE — THIS IS THE EXACT VOICE AND FORMAT) == + +--- MEGA VIRAL EXAMPLE 1: reCAPTCHA (13.3M views, 6,968 likes, 5,546 bookmarks) --- + +500,000 hours of free human labor. Every single day. + +reCAPTCHA is the most successful invisible data operation in internet history. 200 million people solved it daily at its peak. Almost none of them understood what they were actually building. + +Waymo is worth $45 billion today. It got a critical portion of its training data from you. For free. + +Here's the full story. + +In 2007, Google redesigned reCAPTCHA. "Click all squares with a traffic light." Those images came directly from Google Street View. Your clicks were the labels. Every selection told Google's AI: this is a traffic light. + +You weren't passing a test. You were building a dataset. + +At peak, 200 million reCAPTCHAs were solved every single day. Paid data annotation costs $10 to $50 per hour. At the low end: $5 million in free labor extracted every single day. + +Google didn't ask. Didn't pay. Didn't even tell you. + +You proved you were human. By making yourself replaceable. + +--- MEGA VIRAL EXAMPLE 2: Amazon Echo (5.3M views, 28K likes, 6,880 bookmarks) --- + +# Amazon Echo/Alexa Article (5.3M views, 28K likes, 6,880 bookmarks) + +200 million homes. Always on. Always recording. +By people who thought they were just asking for the weather. +Amazon Alexa is the most successful consumer surveillance operation ever built. Not by a government. Not by a spy agency. By a company that sold you the microphone for $29.99 and called it a smart speaker. +You paid to install it. You put it in your kitchen, your bedroom, your kid's room. And for years, strangers on the other side of the world have been listening to what you said. +Here's the full story. + +It started as a speaker. + +In 2014, Amazon launched the Echo. A wireless speaker with a voice assistant named Alexa. The pitch was simple: hands-free music, timers, weather, shopping lists. +It felt harmless. Almost cute. +Amazon sold over 100 million Echo devices within a few years. By 2025, Alexa lived in over 200 million homes worldwide. +People stopped thinking of it as a device. It became part of the house. + +But Alexa is always listening. + +Amazon says the device only starts recording when it hears the wake word "Alexa." That's technically true. But to detect the wake word, the device processes sound 24/7. It's listening to everything, all the time, waiting. +And it gets it wrong. A lot. +A study from Northeastern University found that Alexa activates without the wake word regularly. Some accidental recordings lasted 20 to 43 seconds. Full conversations. Arguments. Private moments. Captured and sent to Amazon's cloud. +You didn't say "Alexa." It didn't matter. + +Then Amazon hired people to listen. + +In 2019, Bloomberg reported that Amazon employs thousands of people around the world whose job is to listen to your Alexa recordings. They transcribe them. Annotate them. Tag them. Then feed them back into the algorithm. +What they didn't tell you: these employees heard everything. +Private medical conversations. Couples fighting. Children talking alone in their rooms. +Amazon's own FAQ never mentioned h + +--- MEGA VIRAL EXAMPLE 3: BetterHelp (4.2M views, 1,452 likes, 1,033 bookmarks) --- + +# BetterHelp Article — Rewrite (reCAPTCHA formula) + +--- + +## TEASER TWEET + +> you downloaded a therapy app +> it said: confidential. private. just between you and your therapist. +> you answered their questions honestly +> depression. anxiety. trauma. +> betterhelp sent every answer to facebook +> and snapchat. and pinterest. +> "find people like this one. sell them therapy too." +> 2 million people. 5.5 million data points. +> the ftc called it a lie. fined them $7.8 million. +> betterhelp never admitted wrongdoing +> they're still the most downloaded mental health app on earth +> you went to them for privacy +> that's exactly what they sold + +--- + +## ARTICLE + +**Title: You went to BetterHelp for privacy. That's exactly what they sold.** + +$7.8 million. That's the fine. +2 million people. That's how many got sold. + +BetterHelp is the most downloaded mental health app on earth. Millions of people chose it specifically because they wanted privacy. No insurance company. No paper trail. No employer finding out. The app felt safer than the traditional system. + +It wasn't. It was the opposite. + +Here's the full story. + +--- + +### How it started: a real problem, a clean pitch + +Mental health care in America has a gatekeeping problem. Traditional therapy is expensive. Insurance claims create paper trails. Some employers have access to certain health records. For a lot of people, the barrier to seeing a therapist isn't willingness. It's exposure. + +BetterHelp launched in 2013 with a clear pitch: therapy on y + +--- ICP VIRAL EXAMPLE: $100M Founders article --- + +# I Helped Founders Generate $100M+ in Revenue Using X. Here's Everything I Know: + +[VISUAL: $100M bill hero image] + +9 out of 10 founder accounts we audit have the same problem. + +They're posting. But none of it is converting. + +Not because their product is bad. Not because they don't know their industry. + +Because they're treating X like a bulletin board. + +Project update. Price announcement. Token launch. Repeat. + +And then they wonder why they're screaming into the void. + +I've spent years running founder marketing campaigns for some of the fastest-growing crypto and tech companies. Here's what we actually see: + +Wolf Swap: $100 million in trading volume, driven by narrative design and community activation. + +Somnia: 2,600% increase in smart engagement. $300M+ FDV at launch. + +These didn't happen because the products were good. Every competitor had a good product. + +They happened because of a system. + +Here's the full playbook. + +--- + +## The Core Problem: Nobody Cares About Your Project + +Founders are trained to talk about their company. + +The mission. The roadmap. The metrics. + +But here's what nobody tells you: on X, your project is the least interesting thing about you. + +What gets people to stop scrolling? Your story. Your belief. The reason you built this in the first place. The contrarian thing you see that everyone else is missing. + +Nobody buys from a company. They buy from a person they trust. And trust is built one post at a time, long before you ever make an ask. + +--- + +## The Fou + +--- LOCAL VIRAL EXAMPLE: Claude features article (1.4M views, 5,548 bookmarks) --- + +Most people use Claude like a smarter Google search. They open a new chat. Type a question. Get an answer. Close the tab. + +If that sounds familiar, you're using about 10% of what you're paying for. + +Here are 20 features ranked by how much they change the way you work. + +The single most underused feature in Claude: Projects. A Project is a persistent workspace. Every conversation inside it shares the same context permanently. You don't re-explain yourself every session. + +Most people don't know there's a toggle that makes Claude slow down and actually reason through a problem before answering. Turn it on for anything that requires real judgment. + +The people winning with AI aren't using a better model. They're using the same model with 20 features instead of 2.`; + + const systemPrompt = `You are the user's article writer. You write viral long-form X articles that get millions of views. + +${voiceExamples} + +== WRITING RULES (non-negotiable) == +1. Proper sentence capitalization — capitalize first word of every sentence, proper nouns, tool names, companies +2. Short paragraphs — 2-3 sentences max, often just 1-2 lines +3. Use line breaks liberally between every thought +4. Numbers anchor every claim — be specific, always use real numbers +5. No corporate speak — no "leverage", "harness", "unlock", "in today's world", "game-changing" +6. Write like you're telling a smart friend something they need to hear +7. Every section reveals something new — no filler, no padding +8. The "you" pronoun is powerful — use it to make the reader feel complicit or implicated +9. Section headers are short and punchy (3-6 words) +10. End with a one-line killer that reframes everything + +== THE FORMULA FOR THIS TRACK == +${formula} + +${selectedThemes.length > 0 ? `\n== THEMES TO WEAVE IN ==\n${selectedThemes.join(", ")}` : ""} + +${libraryContext ? `\n== YOUR SELECTED INSPIRATION ARTICLES (ANALYZE DEEPLY) == +These are articles you chose as inspiration. Study them to understand: +- WHY they went viral — what made people stop scrolling and read the whole thing? +- The opening hook pattern — how do they grab attention in the first 2 lines? +- The pacing — short paragraphs, line breaks, tension building +- The data usage — specific numbers that anchor every claim +- The emotional arc — how the reader's feeling changes from start to finish +- The "you" framing — how the reader is made to feel personally implicated + +DO NOT copy or paraphrase these articles. Extract the VIRAL MECHANICS and apply them to the new topic with fresh angles, fresh data, and fresh storytelling. + +${libraryContext}` : ""} + +${externalContext ? `\n== EXTERNAL INSPIRATION (additional reference) ==\n${externalContext}` : ""} + +Write the COMPLETE article. Match the voice, pacing, and energy of the examples above exactly. This needs to be good enough to go viral.`; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Article Studio", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: `Write the full article for this title:\n\n"${title}"` }, + ], + max_tokens: 4000, + temperature: 0.8, + }), + }); + + if (!response.ok) { + const err = await response.text(); + console.error("OpenRouter error:", err); + return NextResponse.json({ error: "Failed to generate article" }, { status: 500 }); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + const articleBody = data.choices?.[0]?.message?.content?.trim() || ""; + + if (!articleBody) { + return NextResponse.json({ error: "Empty response from model" }, { status: 500 }); + } + + return NextResponse.json({ + title, + body: articleBody, + track, + themes: selectedThemes, + inspirationUrls: urls, + wordCount: articleBody.split(/\s+/).length, + }); + } catch (err: any) { + console.error("POST /api/articles/generate-article error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/articles/generate-qt/route.ts b/src/app/api/articles/generate-qt/route.ts new file mode 100644 index 0000000..969f8be --- /dev/null +++ b/src/app/api/articles/generate-qt/route.ts @@ -0,0 +1,201 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +const QT_SYSTEM = `You write viral Quote Tweets (QTs) for your articles. + +== THE PROVEN QT FORMAT == +This format consistently gets millions of views: + +> you [relatable action the reader has done] +> [what they thought was happening] +> [the reveal — what was actually happening] +> [escalating consequence] +> [the punchline that reframes everything] + +== YOUR ACTUAL VIRAL QTs (STUDY THESE — THIS IS THE VOICE) == + +QT 1 — reCAPTCHA article (73M views, 6,968 likes): +you click "i am not a robot" +you think you're proving you're human +you're actually training Google's AI +for free +for 15 years +this is the full story: + +QT 2 — Amazon Alexa article (5.3M views, 28K likes): +you buy an Echo for $29.99 +you put it in your bedroom +Amazon hires thousands to listen +you didn't buy a speaker +you bought a microphone + +QT 3 — 23andMe article: +you spit in a tube +you think you're learning about your ancestry +you're actually giving away your family's DNA +forever +here's what they did with it: + +QT 4 — BetterHelp article (4.2M views, 1,452 likes): +you downloaded a therapy app +it said: confidential. private. just between you and your therapist. +you answered their questions honestly +depression. anxiety. trauma. +betterhelp sent every answer to facebook +and snapchat. and pinterest. +"find people like this one. sell them therapy too." +2 million people. 5.5 million data points. +the ftc called it a lie. fined them $7.8 million. +betterhelp never admitted wrongdoing +they're still the most downloaded mental health app on earth +you went to them for privacy +that's exactly what they sold + +QT 5 — Google Photos article: +you stored the memories +they stored the faces + +QT 6 — $100M Founders article (ICP viral): +9 out of 10 founder accounts we audit have the same problem +they're posting. but none of it is converting +not because their product is bad +because they're treating X like a bulletin board +here's the full playbook: + +QT 7 — Claude features article (1.4M views, 5,548 bookmarks): +most people use Claude like a smarter Google search +open chat. type question. get answer. close tab. +if that sounds familiar +you're using about 10% of what you're paying for +here are 20 features ranked by impact: + +== RULES (non-negotiable) == +1. Start with "you [action]" — make it personal and relatable +2. Build tension line by line — each line hits harder +3. The final line REFRAMES everything the reader thought they knew +4. Keep each line short — under 50 characters ideally +5. 5-10 lines total (shorter is usually better) +6. End with a hook: "here's the full story:" or "here's what happened:" or similar +7. NO hashtags, NO emojis, NO "thread 🧵", NO quotation marks around the QT +8. Lowercase throughout (except proper nouns like Google, Amazon, etc.) +9. Use specific numbers when available — numbers stop the scroll +10. The "you" pronoun is mandatory — the reader must feel implicated + +Output ONLY the QT text. No explanation, no quotes around it, no markdown.`; + +// POST /api/articles/generate-qt — accepts { articleId } OR { title, body, track } for unsaved articles +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { articleId, title: directTitle, body: directBody, track: directTrack } = body; + + let articleTitle: string; + let articleBody: string; + let articleTrack: string; + let shouldSave = false; + + if (articleId) { + // Load from DB + const article = await prisma.article.findUnique({ where: { id: articleId } }); + if (!article) { + return NextResponse.json({ error: "Article not found" }, { status: 404 }); + } + articleTitle = article.title; + articleBody = article.body; + articleTrack = article.track; + shouldSave = true; + } else if (directTitle && directBody) { + // Direct body for unsaved articles (compose flow) + articleTitle = directTitle; + articleBody = directBody; + articleTrack = directTrack || "local-viral"; + } else { + return NextResponse.json({ error: "articleId or (title + body) required" }, { status: 400 }); + } + + // Fetch real QT examples from library (same track preferred) + let libraryQtExamples = ""; + try { + const exampleArticles = await prisma.article.findMany({ + where: { + status: "posted", + qtTweet: { not: null }, + track: articleTrack, + }, + select: { title: true, qtTweet: true, impressions: true }, + orderBy: { impressions: "desc" }, + take: 5, + }); + if (exampleArticles.length > 0) { + libraryQtExamples = `\n\n== YOUR OWN VIRAL QTs FROM THE LIBRARY (study these — they performed) ==\n${exampleArticles + .map((a, i) => `QT for "${a.title}" (${a.impressions?.toLocaleString() || "?"} impressions):\n${a.qtTweet}`) + .join("\n\n")}`; + } + } catch {} + + // Get first 1500 chars of article body for context + const articlePreview = articleBody.slice(0, 1500); + + const userPrompt = `Write ONE viral QT for this article. + +TITLE: ${articleTitle} +TRACK: ${articleTrack} + +ARTICLE BODY (preview): +${articlePreview} + +Generate the perfect QT that will make people NEED to click through and read the full article. Follow the "you [action]" format exactly. Match the energy and voice of the examples in your instructions. +${libraryQtExamples}`; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Article Studio", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [ + { role: "system", content: QT_SYSTEM }, + { role: "user", content: userPrompt }, + ], + max_tokens: 500, + temperature: 0.85, + }), + }); + + if (!response.ok) { + const err = await response.text(); + console.error("OpenRouter error:", err); + return NextResponse.json({ error: "Failed to generate QT" }, { status: 500 }); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + const qtTweet = data.choices?.[0]?.message?.content?.trim() || ""; + + if (!qtTweet) { + return NextResponse.json({ error: "Empty response from model" }, { status: 500 }); + } + + // Update the article with the generated QT if it's saved + if (shouldSave && articleId) { + await prisma.article.update({ + where: { id: articleId }, + data: { qtTweet }, + }); + } + + return NextResponse.json({ + articleId: articleId || null, + qtTweet, + title: articleTitle, + track: articleTrack, + }); + } catch (err: any) { + console.error("POST /api/articles/generate-qt error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/articles/generate-titles/route.ts b/src/app/api/articles/generate-titles/route.ts new file mode 100644 index 0000000..b6a1852 --- /dev/null +++ b/src/app/api/articles/generate-titles/route.ts @@ -0,0 +1,163 @@ +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +import { NextRequest, NextResponse } from "next/server"; +import { fetchUrlContent } from "@/lib/fetch-url-content"; + +const TRACK_DESCRIPTIONS: Record<string, string> = { + "mega-viral": `MEGA-VIRAL TRACK: Shocking data stories about how companies harvest user data, manipulate users, or profit from something people don't realize. +The formula: You thought X was [innocent thing]. Actually, [shocking betrayal]. +These articles expose mass-scale violations with specific numbers and receipts. The reader is the protagonist — they've been affected without knowing. + +ALREADY COVERED (DO NOT suggest titles about these): reCAPTCHA, CAPTCHA, Google Street View labeling, Alexa/Echo listening, Amazon surveillance, BetterHelp selling therapy data, 23andMe DNA, Google Photos facial recognition. + +Generate titles about FRESH UNEXPLORED topics in this spirit. Think: other apps, platforms, services that secretly monetize user behavior. Examples of unexplored territory: Spotify mood data, LinkedIn skills data, Duolingo translation labor, fitness trackers, period tracking apps, banking apps, browser extensions, mobile games, smart TVs, free VPN services, loyalty programs, credit card data, insurance companies and health apps, grocery store loyalty cards, hotel key data, car telematics, smart home devices beyond Alexa.`, + + "local-viral": `LOCAL-VIRAL TRACK: Claude/AI tools/builder niche content — practical AI workflows. +Think: "How I use Claude Code" deep dives, AI agent setup guides, productivity workflows. +The formula: Here's exactly how I [specific result] using [specific tool]. +Titles should promise actionable, copy-paste value for builders and developers.`, + + "icp-viral": `ICP-VIRAL TRACK: Agency/founder content that books calls (Trojan horse format). +Think: Client acquisition systems, pricing strategies, positioning frameworks. +The formula: Goes viral as useful content, CTA for calls/services at the end. +Titles should promise systems/frameworks that solve expensive business problems.`, +}; + +// POST /api/articles/generate-titles — accepts { track, themes, inspirationUrls, libraryInspirations } +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { track, themes, inspirationUrls, libraryInspirations } = body; + + const selectedThemes: string[] = themes ? JSON.parse(themes) : []; + const urls: string[] = inspirationUrls ? JSON.parse(inspirationUrls) : []; + const libArticles: { title: string; body: string; impressions: number | null; likes: number | null; bookmarks: number | null }[] = libraryInspirations || []; + + // Fetch external URL content in parallel + const urlContents = await Promise.all( + urls.map((url) => fetchUrlContent(url, { maxChars: 2000 })) + ); + const externalContext = urlContents + .filter((c) => c && !c.includes("Could not extract")) + .map((content, i) => `External inspiration ${i + 1}:\n${content}`) + .join("\n\n"); + + // Build library article context with full bodies + const libraryContext = libArticles + .map((a, i) => { + const metrics = [ + a.impressions != null ? `${a.impressions.toLocaleString()} impressions` : null, + a.likes != null ? `${a.likes.toLocaleString()} likes` : null, + a.bookmarks != null ? `${a.bookmarks.toLocaleString()} bookmarks` : null, + ].filter(Boolean).join(", "); + const bodyPreview = a.body.slice(0, 1500); + return `--- YOUR ARTICLE ${i + 1}: "${a.title}" (${metrics || "no metrics"}) ---\n${bodyPreview}`; + }) + .join("\n\n"); + + const trackDesc = TRACK_DESCRIPTIONS[track] || TRACK_DESCRIPTIONS["local-viral"]; + + const systemPrompt = `You are a viral content strategist who writes titles for your articles. +Your titles consistently get millions of views because they: +1. Promise a specific, surprising revelation +2. Create personal stakes for the reader ("you" language when appropriate) +3. Use concrete numbers when available +4. Avoid clickbait clichés — be specific, not vague + +${trackDesc} + +${selectedThemes.length > 0 ? `Themes to incorporate: ${selectedThemes.join(", ")}` : ""} + +${libraryContext ? `\n== YOUR PROVEN VIRAL ARTICLES (STUDY THESE CAREFULLY) == +Analyze WHY each of these articles went viral. Look at: +- How the title creates urgency or curiosity +- The opening hook that stops the scroll +- The structure and pacing that keeps readers engaged +- The specific numbers and claims that make it feel real and important +- The emotional stakes — how it makes the reader feel personally implicated + +DO NOT copy these titles. Instead, extract the PATTERNS that made them work and apply those patterns to completely new topics and angles. + +${libraryContext}` : ""} + +${externalContext ? `\n== EXTERNAL INSPIRATION (additional style reference) ==\n${externalContext}` : ""} + +TITLE STYLE RULES (non-negotiable): +- NEVER use em dashes (—) or hyphens connecting clauses. Ever. +- Do NOT use headline/title case. Use sentence case only. +- Do NOT add subtitles or "Here's what..." appended explanations +- Short and punchy. Under 70 characters. +- Often start with a number or shocking stat +- "you" language makes the reader feel personally implicated +- Statement energy, not question energy + +EXAMPLES OF CORRECT STYLE (from your actual viral articles): +- "500,000 hours of free human labor. Every single day." +- "200 million homes. Always on. Always recording." +- "$7.8 million. That's the fine. 2 million people. That's how many got sold." +- "There is a small plastic tube sitting in a lab somewhere." +- "You stored the memories. They stored the faces." + +EXAMPLES OF WRONG STYLE (do not generate like this): +- "Spotify Sells Your Mood Data to Brands. Here's What They Know" (headline case + subtitle) +- "Free VPNs Don't Hide Your Data — They Sell It" (em dash, headline case) +- "How Grocery Stores Know You're Pregnant Before You Do" (How... format, too generic) + +Generate exactly 8 title options in the correct style. Each must be distinct in angle and topic. + +Return ONLY a JSON array of 8 strings. No explanation, no markdown, just the array. +Example: ["Title 1", "Title 2", ...]`; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Article Studio", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: "Generate 8 viral title options now." }, + ], + max_tokens: 1000, + temperature: 0.9, + }), + }); + + if (!response.ok) { + const err = await response.text(); + console.error("OpenRouter error:", err); + return NextResponse.json({ error: "Failed to generate titles" }, { status: 500 }); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + const content = data.choices?.[0]?.message?.content?.trim() || "[]"; + + // Parse the JSON array from the response + let titles: string[]; + try { + // Handle potential markdown code blocks + const cleaned = content.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); + titles = JSON.parse(cleaned); + if (!Array.isArray(titles)) throw new Error("Not an array"); + } catch { + console.error("Failed to parse titles:", content); + // Fallback: try to extract titles from text + titles = content.split("\n").filter(line => line.trim()).slice(0, 8); + } + + return NextResponse.json({ + titles, + track, + themes: selectedThemes, + inspirationUrls: urls, + }); + } catch (err: any) { + console.error("POST /api/articles/generate-titles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/articles/generate-visuals/route.ts b/src/app/api/articles/generate-visuals/route.ts new file mode 100644 index 0000000..ad68b7a --- /dev/null +++ b/src/app/api/articles/generate-visuals/route.ts @@ -0,0 +1,209 @@ +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +const DESIGN_SYSTEM = `You generate standalone HTML visual graphics for viral X (Twitter) articles. + +== DESIGN SYSTEM (non-negotiable) == + +DIMENSIONS: 1536px wide × 864px tall. Set on <body>. overflow: hidden. + +BACKGROUND: #080808 (near-black) +GRID OVERLAY: Always include this: +.grid-bg { + position: absolute; inset: 0; + background-image: + linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px); + background-size: 60px 60px; +} + +GLOW: Use radial-gradient overlays for subtle colored glow effects behind content. + +FONT: font-family: -apple-system, 'Inter', sans-serif; + +COLORS: +- Text: #fff (headlines), rgba(255,255,255,0.35-0.5) (body/muted) +- Accent: pick ONE from: amber #f59e0b, blue #3b82f6 / #818cf8, green #4ade80, red #f87171, purple #a78bfa +- Cards: rgba(255,255,255,0.03) background, rgba(255,255,255,0.07) border +- Colored accents: 15% opacity backgrounds with matching text + +TYPOGRAPHY: +- Headlines: 36-72px, font-weight 800, letter-spacing -0.02em +- Subtitles: 16-22px, weight 400, color rgba(255,255,255,0.3-0.4) +- Body/items: 12-15px, color rgba(255,255,255,0.45-0.6) +- Badges/labels: 11-13px, weight 600-700, uppercase, letter-spacing 0.1em + +CARDS: border-radius 14-20px, subtle borders rgba(255,255,255,0.07) + +BYLINE: Always include at the bottom: +<div style="position:absolute;bottom:30px;left:56px;right:56px;display:flex;justify-content:space-between;align-items:center;z-index:10;"> + <span style="font-size:14px;color:rgba(255,255,255,0.25);">@yourhandle</span> + <span style="font-size:11px;font-weight:700;letter-spacing:3px;color:rgba(255,255,255,0.1);">HERMY HQ</span> +</div> + +RULES: +- Complete, self-contained <!DOCTYPE html> documents +- ALL styles in a single <style> block — NO inline styles except the byline +- NO external resources, fonts, images, or scripts +- Use emojis for icons — they render everywhere +- Content must be LEGIBLE +- Position elements with flexbox/grid`; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { articleId, title, articleBody, track, visualType, mode } = body; + + let artTitle = title; + let artBody = articleBody; + let artTrack = track; + + if (articleId && (!artTitle || !artBody)) { + const article = await prisma.article.findUnique({ where: { id: articleId } }); + if (!article) { + return NextResponse.json({ error: "Article not found" }, { status: 404 }); + } + artTitle = article.title; + artBody = article.body; + artTrack = article.track; + } + + if (!artTitle || !artBody) { + return NextResponse.json({ error: "title and articleBody required" }, { status: 400 }); + } + + // Fetch reference hero HTMLs + let referenceContext = ""; + try { + const refs = await prisma.article.findMany({ + where: { status: "posted", track: artTrack || undefined, heroImageHtml: { not: null } }, + select: { title: true, heroImageHtml: true, impressions: true }, + orderBy: { impressions: "desc" }, + take: 2, + }); + if (refs.length > 0) { + referenceContext = `\n\n== REFERENCE VISUALS FROM TOP ARTICLES ==\n${refs.map((a, i) => + `--- "${a.title}" (${a.impressions?.toLocaleString() || "?"} views) ---\n${(a.heroImageHtml || "").slice(0, 2000)}` + ).join("\n\n")}`; + } + } catch {} + + // ── MODE: PLAN ── Analyze article and create a visual plan + if (mode === "plan") { + const planPrompt = `Read this entire article carefully and create a VISUAL PLAN. + +TITLE: ${artTitle} +TRACK: ${artTrack} + +FULL ARTICLE: +${artBody.slice(0, 6000)} + +For each section of the article, recommend a visual. For each visual: +1. WHERE it should go (after which section/paragraph) +2. WHAT type: hero, data-stats, before-after, steps, grid, timeline, comparison, quote-highlight +3. WHAT content to include (specific numbers, facts, lists from the article) +4. CAN_GENERATE: true if this is something you can create as an HTML graphic (charts, infographics, data displays, comparison panels, step flows). false if it requires a real screenshot, photo, video embed, or external content the user must provide. +5. If CAN_GENERATE is false, briefly describe what the user should provide. + +Return a JSON array. Each object: +{ + "position": "After section: [section name or quote]", + "type": "hero|data|before-after|steps|grid|timeline|comparison|quote-highlight", + "description": "Brief description of the visual", + "content": "The specific data/text to include in the visual", + "canGenerate": true/false, + "userAction": null or "Screenshot X showing Y" (only if canGenerate is false) +} + +Return ONLY valid JSON. No explanation, no markdown.`; + + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Article Studio", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [{ role: "user", content: planPrompt }], + max_tokens: 4000, + temperature: 0.5, + }), + }); + + if (!res.ok) { + return NextResponse.json({ error: "Failed to generate plan" }, { status: 500 }); + } + + const data = await res.json() as { choices: { message: { content: string } }[] }; + const content = data.choices?.[0]?.message?.content?.trim() || "[]"; + + try { + const cleaned = content.replace(/^```json?\n?/g, "").replace(/```$/g, "").trim(); + const plan = JSON.parse(cleaned); + return NextResponse.json({ plan }); + } catch { + return NextResponse.json({ error: "Failed to parse plan", raw: content }, { status: 500 }); + } + } + + // ── MODE: GENERATE SINGLE ── Generate one specific visual + const bodyPreview = artBody.slice(0, 3000); + + const userPrompt = `Generate a ${(visualType || "hero").toUpperCase()} visual for this article. + +TITLE: ${artTitle} +TRACK: ${artTrack} + +ARTICLE: +${bodyPreview} + +${body.content ? `SPECIFIC CONTENT TO INCLUDE IN THIS VISUAL:\n${body.content}\n` : ""} +${referenceContext} + +Generate ONE complete HTML visual. Extract the most impactful data from the article. The content MUST come from the article — don't invent numbers. + +Return ONLY the complete HTML document. No explanation, no markdown code blocks, no \`\`\`.`; + + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Article Studio", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [ + { role: "system", content: DESIGN_SYSTEM }, + { role: "user", content: userPrompt }, + ], + max_tokens: 8000, + temperature: 0.7, + }), + }); + + if (!res.ok) { + return NextResponse.json({ error: "Failed to generate visual" }, { status: 500 }); + } + + const data = await res.json() as { choices: { message: { content: string } }[] }; + const html = (data.choices?.[0]?.message?.content || "") + .replace(/^```html?\n?/g, "") + .replace(/```$/g, "") + .trim(); + + return NextResponse.json({ + visuals: [{ type: visualType || "hero", html }], + }); + } catch (err: any) { + console.error("POST /api/articles/generate-visuals error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/articles/revise/route.ts b/src/app/api/articles/revise/route.ts new file mode 100644 index 0000000..f251047 --- /dev/null +++ b/src/app/api/articles/revise/route.ts @@ -0,0 +1,171 @@ +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +import { NextRequest, NextResponse } from "next/server"; + +async function braveSearch(query: string, braveKey: string, count = 5): Promise<{ title: string; url: string; description: string }[]> { + try { + const res = await fetch( + `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`, + { headers: { "Accept": "application/json", "X-Subscription-Token": braveKey } } + ); + if (!res.ok) return []; + const data = await res.json(); + return (data.web?.results || []).map((r: any) => ({ + title: r.title || "", + url: r.url || "", + description: r.description || "", + })); + } catch { + return []; + } +} + +// POST /api/articles/revise — chat-based article revision with optional web search +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { title, articleBody, track, instruction, searchEnabled, messages } = body; + + if (!instruction) { + return NextResponse.json({ error: "instruction required" }, { status: 400 }); + } + + // Web search if enabled + let searchContext = ""; + if (searchEnabled) { + const braveKey = process.env.BRAVE_API_KEY || ""; + if (braveKey) { + // Ask Sonnet to generate search queries based on the instruction + const queryRes = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Article Studio", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [ + { + role: "system", + content: `You generate search queries to fact-check or research claims in articles. Given a user instruction and article context, output 1-3 short search queries that would help verify or research the topic. Return ONLY a JSON array of strings. No explanation.`, + }, + { + role: "user", + content: `Article title: "${title}"\nUser instruction: "${instruction}"\n\nGenerate search queries to help with this request.`, + }, + ], + max_tokens: 200, + temperature: 0.3, + }), + }); + + if (queryRes.ok) { + const queryData = await queryRes.json() as { choices: { message: { content: string } }[] }; + const queryContent = queryData.choices?.[0]?.message?.content?.trim() || "[]"; + try { + const cleaned = queryContent.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); + const queries: string[] = JSON.parse(cleaned); + const allResults = await Promise.all( + queries.slice(0, 3).map((q) => braveSearch(q, braveKey, 3)) + ); + const flatResults = allResults.flat(); + if (flatResults.length > 0) { + searchContext = `\n\n== WEB SEARCH RESULTS (use these to verify facts or update information) ==\n${flatResults + .map((r, i) => `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.description}`) + .join("\n\n")}`; + } + } catch { + // Failed to parse queries, skip search + } + } + } + } + + // Build conversation history for context + const historyMessages = (messages || []).map((m: { role: string; content: string }) => ({ + role: m.role, + content: m.content, + })); + + const systemPrompt = `You are the user's article editor and fact-checker. You help revise and improve viral articles. + +You have two modes: +1. REVISION MODE: When asked to change/improve the article, return a revised version of the FULL article body. Start your response with "REVISED_BODY:" followed by the complete revised article on the next line. +2. ANSWER MODE: When asked a question (fact-check, accuracy, research), answer the question directly. Do NOT prefix with "REVISED_BODY:". + +== ARTICLE CONTEXT == +Title: ${title} +Track: ${track} +Current article body: +${articleBody} +${searchContext} + +== WRITING STYLE RULES == +1. Short paragraphs — 2-3 sentences max, often just 1-2 lines +2. Numbers anchor every claim — be specific +3. No corporate speak — no "leverage", "harness", "unlock" +4. Write like telling a smart friend something they need to hear +5. Every section reveals something new — no filler +6. Use "you" to make the reader feel implicated +7. Proper sentence capitalization + +When revising, maintain the article's voice and energy. Only change what was asked. Return the COMPLETE article body, not just the changed section.`; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Article Studio", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [ + { role: "system", content: systemPrompt }, + ...historyMessages, + { role: "user", content: instruction }, + ], + max_tokens: 4000, + temperature: 0.7, + }), + }); + + if (!response.ok) { + const err = await response.text(); + console.error("OpenRouter error:", err); + return NextResponse.json({ error: "Failed to revise article" }, { status: 500 }); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + const content = data.choices?.[0]?.message?.content?.trim() || ""; + + if (!content) { + return NextResponse.json({ error: "Empty response from model" }, { status: 500 }); + } + + // Check if the response contains a revised body + const isRevision = content.startsWith("REVISED_BODY:"); + if (isRevision) { + const revisedBody = content.replace(/^REVISED_BODY:\s*/, "").trim(); + return NextResponse.json({ + type: "revision", + message: "Article updated.", + revisedBody, + searchUsed: searchEnabled && searchContext.length > 0, + }); + } + + return NextResponse.json({ + type: "answer", + message: content, + searchUsed: searchEnabled && searchContext.length > 0, + }); + } catch (err: any) { + console.error("POST /api/articles/revise error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts new file mode 100644 index 0000000..a7cef42 --- /dev/null +++ b/src/app/api/articles/route.ts @@ -0,0 +1,131 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/articles?status=X — list articles, filter by status +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const statusFilter = searchParams.get("status"); + + const where: any = {}; + if (statusFilter) { + where.status = statusFilter; + } + + const articles = await prisma.article.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(articles, { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }); + } catch (err: any) { + console.error("GET /api/articles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} + +// POST /api/articles — create article +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const article = await prisma.article.create({ + data: { + id: body.id || crypto.randomUUID(), + title: body.title || "", + body: body.body || "", + track: body.track || "local-viral", + status: body.status || "idea", + qtTweet: body.qtTweet || null, + qtUrl: body.qtUrl || null, + heroImageUrl: body.heroImageUrl || null, + heroImageHtml: body.heroImageHtml || null, + inspirationUrls: body.inspirationUrls || null, + themes: body.themes || null, + scheduledDate: body.scheduledDate || null, + postedAt: body.postedAt ? new Date(body.postedAt) : null, + postedUrl: body.postedUrl || null, + impressions: body.impressions ?? null, + likes: body.likes ?? null, + bookmarks: body.bookmarks ?? null, + }, + }); + + return NextResponse.json(article); + } catch (err: any) { + console.error("POST /api/articles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} + +// PATCH /api/articles — update article (body: { id, ...fields }) +export async function PATCH(req: NextRequest) { + try { + const body = await req.json(); + const { id, ...fields } = body; + + if (!id) { + return NextResponse.json({ error: "id required" }, { status: 400 }); + } + + const article = await prisma.article.findUnique({ where: { id } }); + if (!article) { + return NextResponse.json({ error: "Article not found" }, { status: 404 }); + } + + const updateData: any = {}; + + if (fields.title !== undefined) updateData.title = fields.title; + if (fields.body !== undefined) updateData.body = fields.body; + if (fields.track !== undefined) updateData.track = fields.track; + if (fields.status !== undefined) updateData.status = fields.status; + if (fields.qtTweet !== undefined) updateData.qtTweet = fields.qtTweet; + if (fields.qtUrl !== undefined) updateData.qtUrl = fields.qtUrl; + if (fields.heroImageUrl !== undefined) updateData.heroImageUrl = fields.heroImageUrl; + if (fields.heroImageHtml !== undefined) updateData.heroImageHtml = fields.heroImageHtml; + if (fields.inspirationUrls !== undefined) updateData.inspirationUrls = fields.inspirationUrls; + if (fields.themes !== undefined) updateData.themes = fields.themes; + if (fields.scheduledDate !== undefined) updateData.scheduledDate = fields.scheduledDate; + if (fields.postedAt !== undefined) updateData.postedAt = fields.postedAt ? new Date(fields.postedAt) : null; + if (fields.postedUrl !== undefined) updateData.postedUrl = fields.postedUrl; + if (fields.impressions !== undefined) updateData.impressions = fields.impressions; + if (fields.likes !== undefined) updateData.likes = fields.likes; + if (fields.bookmarks !== undefined) updateData.bookmarks = fields.bookmarks; + + const updated = await prisma.article.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(updated); + } catch (err: any) { + console.error("PATCH /api/articles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} + +// DELETE /api/articles — delete article (body: { id }) +export async function DELETE(req: NextRequest) { + try { + const { id } = await req.json(); + + if (!id) { + return NextResponse.json({ error: "id required" }, { status: 400 }); + } + + const article = await prisma.article.findUnique({ where: { id } }); + if (!article) { + return NextResponse.json({ error: "Article not found" }, { status: 404 }); + } + + await prisma.article.delete({ where: { id } }); + + return NextResponse.json({ success: true, message: "Article deleted" }); + } catch (err: any) { + console.error("DELETE /api/articles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/articles/saved-titles/route.ts b/src/app/api/articles/saved-titles/route.ts new file mode 100644 index 0000000..f91ac91 --- /dev/null +++ b/src/app/api/articles/saved-titles/route.ts @@ -0,0 +1,59 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/articles/saved-titles — list saved titles +export async function GET() { + try { + const titles = await prisma.savedTitle.findMany({ + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(titles); + } catch (err: any) { + console.error("GET /api/articles/saved-titles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} + +// POST /api/articles/saved-titles — save a title +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { title, track, themes } = body; + + if (!title) { + return NextResponse.json({ error: "title required" }, { status: 400 }); + } + + const saved = await prisma.savedTitle.create({ + data: { + title, + track: track || "local-viral", + themes: themes || null, + }, + }); + + return NextResponse.json(saved); + } catch (err: any) { + console.error("POST /api/articles/saved-titles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} + +// DELETE /api/articles/saved-titles — remove a saved title +export async function DELETE(req: NextRequest) { + try { + const { id } = await req.json(); + + if (!id) { + return NextResponse.json({ error: "id required" }, { status: 400 }); + } + + await prisma.savedTitle.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (err: any) { + console.error("DELETE /api/articles/saved-titles error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/articles/scrape/route.ts b/src/app/api/articles/scrape/route.ts new file mode 100644 index 0000000..5a1a246 --- /dev/null +++ b/src/app/api/articles/scrape/route.ts @@ -0,0 +1,423 @@ +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +import { NextRequest, NextResponse } from "next/server"; +import { fetchUrlContent } from "@/lib/fetch-url-content"; + +function extractTweetId(url: string): string | null { + const match = url.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/); + return match ? match[1] : null; +} + +// Try Twitter API v2 — requires TWITTER_BEARER_TOKEN +async function fetchFromTwitterApi(tweetId: string, bearerToken: string) { + try { + const res = await fetch( + `https://api.twitter.com/2/tweets/${tweetId}?tweet.fields=public_metrics,created_at,text,note_tweet,article&expansions=author_id,article.cover_media,article.media_entities&user.fields=username&media.fields=url,preview_image_url,type`, + { + headers: { Authorization: `Bearer ${bearerToken}` }, + signal: AbortSignal.timeout(8000), + } + ); + if (!res.ok) return null; + const data = await res.json(); + const tweet = data.data; + if (!tweet) return null; + + const metrics = tweet.public_metrics || {}; + + // article.plain_text = full article body, article.title = proper title + const article = tweet.article || {}; + const articleTitle = article.title || ""; + const articleBody = article.plain_text || ""; + const fullText = articleBody || tweet.note_tweet?.text || tweet.text || ""; + + // Find cover image from includes.media using article.cover_media key + let coverImageUrl = ""; + const coverMediaKey = article.cover_media || ""; + if (coverMediaKey && data.includes?.media) { + const coverMedia = data.includes.media.find((m: any) => m.media_key === coverMediaKey); + if (coverMedia) { + coverImageUrl = coverMedia.url || coverMedia.preview_image_url || ""; + } + } + + // Extract a clean title: prefer article.title, then first meaningful line + let extractedTitle = articleTitle; + if (!extractedTitle) { + const lines = fullText.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^https?:\/\//.test(trimmed)) continue; + if (trimmed.length < 10 && !/\d/.test(trimmed)) continue; + extractedTitle = trimmed.length > 120 ? trimmed.slice(0, 117) + "..." : trimmed; + break; + } + } + + return { + text: fullText, + title: extractedTitle, + coverImageUrl, + isArticleTweet: !!tweet.article, + impressions: metrics.impression_count ?? null, + likes: metrics.like_count ?? null, + bookmarks: metrics.bookmark_count ?? null, + retweets: metrics.retweet_count ?? null, + replies: metrics.reply_count ?? null, + createdAt: tweet.created_at || null, + }; + } catch { + return null; + } +} + +// Fallback: Try X syndication API (public, no auth) +async function fetchFromSyndication(tweetId: string) { + try { + const res = await fetch( + `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&token=0`, + { + headers: { "User-Agent": "Mozilla/5.0" }, + signal: AbortSignal.timeout(8000), + } + ); + if (!res.ok) return null; + const data = await res.json(); + + return { + text: data.text || "", + likes: data.favorite_count ?? null, + createdAt: data.created_at || null, + authorName: data.user?.name || "", + authorHandle: data.user?.screen_name || "", + }; + } catch { + return null; + } +} + +// Fetch quote tweets for an article tweet +async function fetchQuoteTweets(tweetId: string, bearerToken: string, authorId?: string) { + try { + const res = await fetch( + `https://api.twitter.com/2/tweets/${tweetId}/quote_tweets?tweet.fields=public_metrics,created_at,text,author_id&max_results=20`, + { + headers: { Authorization: `Bearer ${bearerToken}` }, + signal: AbortSignal.timeout(8000), + } + ); + if (!res.ok) return null; + const data = await res.json(); + const quotes = data.data || []; + + // Find the author's own QT (most likely the promotional tweet) + // If no authorId, return the most-liked QT + let bestQt = null; + for (const qt of quotes) { + if (authorId && qt.author_id === authorId) { + bestQt = qt; + break; + } + } + if (!bestQt && quotes.length > 0) { + // Pick the most-liked quote tweet + bestQt = quotes.reduce((best: any, qt: any) => + (qt.public_metrics?.like_count || 0) > (best.public_metrics?.like_count || 0) ? qt : best + , quotes[0]); + } + + if (!bestQt) return null; + + const qMetrics = bestQt.public_metrics || {}; + return { + text: (bestQt.text || "").replace(/https?:\/\/t\.co\/\S+/g, "").trim(), + tweetId: bestQt.id, + impressions: qMetrics.impression_count ?? null, + likes: qMetrics.like_count ?? null, + bookmarks: qMetrics.bookmark_count ?? null, + createdAt: bestQt.created_at || null, + }; + } catch { + return null; + } +} + +// POST /api/articles/scrape — accepts { url } returns title, body, metrics, etc. +export async function POST(req: NextRequest) { + try { + const { url } = await req.json(); + if (!url) { + return NextResponse.json({ error: "url required" }, { status: 400 }); + } + + const isXUrl = url.includes("x.com") || url.includes("twitter.com"); + const tweetId = isXUrl ? extractTweetId(url) : null; + const bearerToken = process.env.TWITTER_BEARER_TOKEN || ""; + + let title = ""; + let body = ""; + let imageUrl = ""; + let impressions: number | null = null; + let likes: number | null = null; + let bookmarks: number | null = null; + let postedDate: string | null = null; + let qtTweet = ""; + let qtUrl = ""; + let qtImpressions: number | null = null; + let qtLikes: number | null = null; + + // 1. For X/Twitter URLs, try the APIs + if (tweetId) { + // Try Twitter API v2 first (has full metrics + article text) + if (bearerToken) { + const twitterData = await fetchFromTwitterApi(tweetId, bearerToken); + if (twitterData) { + const rawText = twitterData.text || ""; + const cleanedText = rawText.replace(/https?:\/\/t\.co\/\S+/g, "").trim(); + + // If this is an article tweet, use the article title and cover image + if (twitterData.isArticleTweet) { + if (twitterData.title) title = twitterData.title; + if (twitterData.coverImageUrl) imageUrl = twitterData.coverImageUrl; + } + + // If the tweet body is empty/just a URL, it's likely a QT tweet + // Follow the t.co link to find the actual article + if (cleanedText.length < 20) { + const tcoMatch = rawText.match(/https?:\/\/t\.co\/\S+/); + if (tcoMatch) { + try { + // Follow the t.co redirect to get the real URL + const redirectRes = await fetch(tcoMatch[0], { + redirect: "follow", + signal: AbortSignal.timeout(5000), + headers: { "User-Agent": "Mozilla/5.0" }, + }); + const realUrl = redirectRes.url; + const linkedTweetId = extractTweetId(realUrl); + + // If it points to another tweet, fetch that one (the actual article) + if (linkedTweetId && linkedTweetId !== tweetId) { + const articleData = await fetchFromTwitterApi(linkedTweetId, bearerToken); + if (articleData) { + body = articleData.text.replace(/https?:\/\/t\.co\/\S+/g, "").trim(); + title = articleData.title || ""; + if (articleData.coverImageUrl) imageUrl = articleData.coverImageUrl; + // Use the article's metrics, not the QT's + impressions = articleData.impressions; + likes = articleData.likes; + bookmarks = articleData.bookmarks; + postedDate = articleData.createdAt + ? new Date(articleData.createdAt).toISOString().split("T")[0] + : null; + } + } + } catch {} + } + } + + // If we didn't resolve via redirect, use the original tweet data + if (!body) { + body = cleanedText; + title = twitterData.title || ""; + } + + // Use QT tweet metrics as fallback if article metrics not found + if (impressions == null) impressions = twitterData.impressions; + if (likes == null) likes = twitterData.likes; + if (bookmarks == null) bookmarks = twitterData.bookmarks; + if (!postedDate && twitterData.createdAt) { + postedDate = new Date(twitterData.createdAt).toISOString().split("T")[0]; + } + + // Fetch the QT tweet (the promotional tweet that quotes this article) + // Use the article's tweet ID to find quote tweets + const articleTweetId = body ? tweetId : null; // only if this IS the article + const qtTweetId = articleTweetId || tweetId; + if (bearerToken && qtTweetId) { + // Get author_id from the original fetch + const authorRes = await fetch( + `https://api.twitter.com/2/tweets/${qtTweetId}?tweet.fields=author_id`, + { headers: { Authorization: `Bearer ${bearerToken}` }, signal: AbortSignal.timeout(5000) } + ).catch(() => null); + const authorId = authorRes ? (await authorRes.json().catch(() => ({})))?.data?.author_id : undefined; + + const qtData = await fetchQuoteTweets(qtTweetId, bearerToken, authorId); + if (qtData) { + qtTweet = qtData.text; + qtUrl = `https://x.com/i/status/${qtData.tweetId}`; + qtImpressions = qtData.impressions; + qtLikes = qtData.likes; + } + } + } + } + + // Fallback: syndication API (public, no auth needed) + if (!body) { + const synData = await fetchFromSyndication(tweetId); + if (synData) { + body = synData.text || body; + likes = synData.likes ?? likes; + if (synData.createdAt) { + postedDate = new Date(synData.createdAt).toISOString().split("T")[0]; + } + if (body && !title) { + // Extract title skipping URLs and empty lines + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || /^https?:\/\//.test(trimmed)) continue; + if (trimmed.length < 10 && !/\d/.test(trimmed)) continue; + title = trimmed.length > 120 ? trimmed.slice(0, 117) + "..." : trimmed; + break; + } + } + } + } + } + + // 2. For X articles, use Brave Search for body text and hero image + if (isXUrl && (!body || body.length < 50)) { + const braveKey = process.env.BRAVE_API_KEY || ""; + if (braveKey) { + try { + const braveRes = await fetch( + `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(url)}&count=5`, + { headers: { Accept: "application/json", "X-Subscription-Token": braveKey } } + ); + if (braveRes.ok) { + const braveData = await braveRes.json(); + const results = braveData.web?.results || []; + // Find the best match + for (const r of results) { + if (r.url?.includes("x.com") || r.url?.includes("twitter.com")) { + if (!title && r.title) { + title = r.title.replace(/^.*?\s+on\s+(X|Twitter):\s*[""\u201c]?/i, "").replace(/["""\u201d]$/, "").trim(); + } + const extraSnippets = (r.extra_snippets || []).join("\n\n"); + const desc = [r.description || "", extraSnippets].filter(Boolean).join("\n\n").trim(); + if (desc.length > (body?.length || 0)) { + body = desc; + } + if (!imageUrl && r.thumbnail?.src) { + imageUrl = r.thumbnail.src; + } + break; + } + } + } + } catch {} + } + + // Also try fetchUrlContent for deeper extraction + if (!body || body.length < 100) { + const content = await fetchUrlContent(url, { maxChars: 5000 }); + if (content && !content.includes("Could not extract")) { + const bodyMatch = content.match(/Content:\n([\s\S]+)/); + if (bodyMatch && bodyMatch[1].trim().length > (body?.length || 0)) { + body = bodyMatch[1].trim(); + } + if (!title) { + const titleMatch = content.match(/^Title:\s*(.+)/m); + if (titleMatch) title = titleMatch[1].trim(); + } + } + } + } + + // 3. For X articles, try oembed API for thumbnail image + if (isXUrl && !imageUrl) { + try { + const oembedRes = await fetch( + `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&omit_script=true`, + { signal: AbortSignal.timeout(5000) } + ); + if (oembedRes.ok) { + const oembedData = await oembedRes.json(); + // Extract image from oembed HTML + const imgMatch = (oembedData.html || "").match(/src=["']([^"']+\.(?:jpg|jpeg|png|webp)[^"']*)/i); + if (imgMatch) imageUrl = imgMatch[1]; + } + } catch {} + } + + // 4. Try direct fetch for og:meta tags (skip for X — it returns JS-disabled page) + if (!isXUrl) { + try { + const res = await fetch(url, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; HermyHQ/1.0)" }, + signal: AbortSignal.timeout(8000), + }); + if (res.ok) { + const html = await res.text(); + + if (!title) { + const ogTitle = html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i); + if (ogTitle) title = ogTitle[1].trim(); + if (!title) { + const titleTag = html.match(/<title[^>]*>([^<]+)<\/title>/i); + if (titleTag) title = titleTag[1].trim(); + } + } + + if (!imageUrl) { + const ogImage = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i); + if (ogImage) imageUrl = ogImage[1].trim(); + } + } + } catch {} + } + + // 4. For non-X URLs, use Brave-powered fetchUrlContent for article body + if (!body && !isXUrl) { + const content = await fetchUrlContent(url, { maxChars: 5000 }); + if (content && !content.includes("Could not extract")) { + const titleMatch = content.match(/^Title:\s*(.+)/m); + if (titleMatch && !title) title = titleMatch[1].trim(); + const bodyMatch = content.match(/Content:\n([\s\S]+)/); + if (bodyMatch) body = bodyMatch[1].trim(); + } + } + + // Clean up X/Twitter titles + if (title && isXUrl) { + title = title + .replace(/^.*?\s+on\s+(X|Twitter):\s*[""\u201c]?/i, "") + .replace(/["""\u201d]$/, "") + .trim(); + } + + // Clean body text + if (body) { + body = body + .replace(/We've detected that JavaScript is disabled.*?Help Center\.?\s*/gi, "") + .replace(/https?:\/\/t\.co\/\S+/g, "") + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + return NextResponse.json({ + title: title || "", + body: body || "", + heroImageUrl: imageUrl || "", + impressions, + likes, + bookmarks, + postedDate, + qtTweet: qtTweet || "", + qtUrl: qtUrl || "", + qtImpressions, + qtLikes, + }); + } catch (err: any) { + console.error("POST /api/articles/scrape error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..e10628f --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth' +import { authOptions } from '@/lib/auth' + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..94eff95 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { password } = await req.json(); + if (password !== process.env.ADMIN_PASSWORD) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const res = NextResponse.json({ ok: true }); + res.cookies.set("hermyhq_auth", process.env.ADMIN_PASSWORD!, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, // 30 days + path: "/", + }); + return res; +} diff --git a/src/app/api/cache/clear/route.ts b/src/app/api/cache/clear/route.ts new file mode 100644 index 0000000..6f584a9 --- /dev/null +++ b/src/app/api/cache/clear/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); +const INTERNAL_SECRET = process.env.INTERNAL_API_SECRET; +const CACHE_KEYS = ['notion_clients_monthly', 'mcf_monthly', 'ltv_monthly']; + +export async function POST(req: Request) { + const secret = req.headers.get('x-internal-secret'); + if (secret !== INTERNAL_SECRET) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const results = await Promise.all( + CACHE_KEYS.map(key => prisma.dataStore.delete({ where: { key } }).then(() => ({ key, cleared: true })).catch(() => ({ key, cleared: false }))) + ); + return NextResponse.json({ results }); +} + +// Also allow GET with secret query param for convenience +export async function GET(req: Request) { + const url = new URL(req.url); + const secret = url.searchParams.get('secret') || req.headers.get('x-internal-secret'); + if (secret !== INTERNAL_SECRET) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const results = await Promise.all( + CACHE_KEYS.map(key => prisma.dataStore.delete({ where: { key } }).then(() => ({ key, cleared: true })).catch(() => ({ key, cleared: false }))) + ); + return NextResponse.json({ results }); +} diff --git a/src/app/api/client-pulse/map-chat/route.ts b/src/app/api/client-pulse/map-chat/route.ts new file mode 100644 index 0000000..e943f53 --- /dev/null +++ b/src/app/api/client-pulse/map-chat/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +function isAuthorized(request: Request) { + const secret = process.env.CLIENT_PULSE_ADMIN_SECRET || process.env.CRON_SECRET; + if (!secret) return true; + return request.headers.get("authorization") === `Bearer ${secret}`; +} + +export async function POST(request: Request) { + if (!isAuthorized(request)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const body = await request.json() as { + telegramChatId?: string; + title?: string; + linkedClientId?: string; + internalUserIds?: string[]; + status?: string; + }; + if (!body.telegramChatId) return NextResponse.json({ error: "telegramChatId is required" }, { status: 400 }); + const chat = await prisma.clientPulseChat.upsert({ + where: { telegramChatId: body.telegramChatId }, + update: { + title: body.title, + linkedClientId: body.linkedClientId, + internalUserIds: body.internalUserIds || [], + status: body.status, + }, + create: { + telegramChatId: body.telegramChatId, + title: body.title || body.telegramChatId, + linkedClientId: body.linkedClientId, + internalUserIds: body.internalUserIds || [], + status: body.status || "active", + }, + }); + return NextResponse.json({ ok: true, chat }); +} diff --git a/src/app/api/client-pulse/route.ts b/src/app/api/client-pulse/route.ts new file mode 100644 index 0000000..2ca9e15 --- /dev/null +++ b/src/app/api/client-pulse/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; + +type AnalysisWithClient = Prisma.ClientPulseAnalysisGetPayload<{ include: { client: { include: { chats: true } } } }>; + +function asStringArray(value: unknown) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +export async function GET() { + const latestRows = await prisma.clientPulseAnalysis.findMany({ + include: { client: { include: { chats: true } } }, + orderBy: { analyzedAt: "desc" }, + take: 500, + }); + const seen = new Set<string>(); + const latest: AnalysisWithClient[] = []; + for (const row of latestRows) { + if (seen.has(row.clientId)) continue; + seen.add(row.clientId); + latest.push(row); + } + const clientIds = latest.map((row) => row.clientId); + const messages = await prisma.clientPulseMessage.findMany({ + where: { chat: { linkedClientId: { in: clientIds } } }, + include: { chat: true }, + orderBy: { sentAt: "desc" }, + take: 1000, + }); + const alerts = await prisma.clientPulseAlert.findMany({ + where: { acknowledgedAt: null }, + include: { client: true, chat: true }, + orderBy: { createdAt: "desc" }, + take: 100, + }); + const unmappedChats = await prisma.clientPulseChat.findMany({ + where: { linkedClientId: null }, + orderBy: { lastSeenAt: "desc" }, + take: 50, + }); + + const messageByClient = new Map<string, { lastClient?: string; lastInternal?: string }>(); + for (const message of messages) { + const linkedClientId = message.chat?.linkedClientId; + if (!linkedClientId) continue; + const existing = messageByClient.get(linkedClientId) || {}; + if (!message.isInternal && !existing.lastClient) existing.lastClient = `${message.senderName}: ${message.text.slice(0, 180)}`; + if (message.isInternal && !existing.lastInternal) existing.lastInternal = `${message.senderName}: ${message.text.slice(0, 180)}`; + messageByClient.set(linkedClientId, existing); + } + + const clients = latest.map((analysis) => { + const msg = messageByClient.get(analysis.clientId) || {}; + return { + clientId: analysis.clientId, + clientName: analysis.client.clientName, + productManagerName: analysis.client.productManagerName, + services: asStringArray(analysis.client.services), + status: analysis.client.status, + renewalDate: analysis.client.renewalDate, + endDate: analysis.client.endDate, + expectedCheckinHours: analysis.client.expectedCheckinHours, + score: analysis.overallScore, + category: analysis.category, + sentimentScore: analysis.sentimentScore, + responseScore: analysis.responseScore, + cadenceScore: analysis.cadenceScore, + renewalRiskScore: analysis.renewalRiskScore, + reasons: asStringArray(analysis.reasons), + evidence: asStringArray(analysis.evidence), + recommendedAction: analysis.recommendedAction, + llmSummary: analysis.llmSummary, + analysisSource: analysis.analysisSource, + analyzedAt: analysis.analyzedAt, + chats: analysis.client.chats.map((chat) => ({ telegramChatId: chat.telegramChatId, title: chat.title, lastSeenAt: chat.lastSeenAt })), + lastClientMessage: msg.lastClient, + lastInternalReply: msg.lastInternal, + }; + }); + const average = clients.length ? Math.round(clients.reduce((sum, client) => sum + client.score, 0) / clients.length) : null; + const counts = { + urgent: clients.filter((client) => client.category === "urgent").length, + needsAttention: clients.filter((client) => client.category === "needs_attention").length, + watch: clients.filter((client) => client.category === "watch").length, + healthy: clients.filter((client) => client.category === "healthy").length, + }; + return NextResponse.json({ average, counts, clients, alerts, unmappedChats }); +} diff --git a/src/app/api/cron/x-stats/route.ts b/src/app/api/cron/x-stats/route.ts new file mode 100644 index 0000000..b2b6c42 --- /dev/null +++ b/src/app/api/cron/x-stats/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const maxDuration = 30; + +export async function GET(request: Request) { + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const bearerToken = process.env.TWITTER_BEARER_TOKEN; + if (!bearerToken) { + return NextResponse.json({ error: "TWITTER_BEARER_TOKEN not set" }, { status: 500 }); + } + + try { + const existing = await prisma.dataStore.findUnique({ where: { key: "x-account-stats" } }); + const current = (existing?.data as any) || {}; + const handle = current.xHandle || "yourhandle"; + + const res = await fetch( + `https://api.twitter.com/2/users/by/username/${handle}?user.fields=public_metrics`, + { headers: { Authorization: `Bearer ${bearerToken}` }, cache: "no-store" } + ); + + if (!res.ok) { + return NextResponse.json({ error: `Twitter API error: ${res.status}` }, { status: 500 }); + } + + const data = await res.json(); + const followers = data.data?.public_metrics?.followers_count; + + if (typeof followers !== "number") { + return NextResponse.json({ error: "Could not parse follower count", raw: data }, { status: 500 }); + } + + await prisma.dataStore.upsert({ + where: { key: "x-account-stats" }, + update: { data: { ...current, xFollowers: followers, updatedAt: new Date().toISOString() } }, + create: { key: "x-account-stats", data: { xFollowers: followers, xHandle: handle, xGoal: 100000, updatedAt: new Date().toISOString() } }, + }); + + return NextResponse.json({ success: true, xFollowers: followers, handle }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : "Unknown error" }, { status: 500 }); + } +} diff --git a/src/app/api/garden/route.ts b/src/app/api/garden/route.ts new file mode 100644 index 0000000..1bd5bb7 --- /dev/null +++ b/src/app/api/garden/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Shared garden blob — same source as Marwa's dashboard (source of truth) +const GARDEN_BLOB = 'https://jsonblob.com/api/jsonBlob/019cce3a-7bc9-7e88-9e8f-fe461957b1aa'; + +export async function GET() { + try { + const res = await fetch(GARDEN_BLOB, { + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + if (!res.ok) throw new Error(`jsonblob GET failed: ${res.status}`); + const data = await res.json(); + return NextResponse.json(data); + } catch (e) { + console.error('Garden GET error:', e); + return NextResponse.json({ error: 'Failed to fetch garden' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const body = await req.json(); + const res = await fetch(GARDEN_BLOB, { + method: 'PUT', + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`jsonblob PUT failed: ${res.status}`); + return NextResponse.json({ ok: true }); + } catch (e) { + console.error('Garden PUT error:', e); + return NextResponse.json({ error: 'Failed to update garden' }, { status: 500 }); + } +} diff --git a/src/app/api/hermes/activity/route.ts b/src/app/api/hermes/activity/route.ts new file mode 100644 index 0000000..40fdbd3 --- /dev/null +++ b/src/app/api/hermes/activity/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: Request) { + const take = Math.min(Number(new URL(req.url).searchParams.get("take") || 40), 100); + const events = await prisma.agentEvent.findMany({ orderBy: { createdAt: "desc" }, take }); + return NextResponse.json({ events }); +} diff --git a/src/app/api/hermes/briefing/route.ts b/src/app/api/hermes/briefing/route.ts new file mode 100644 index 0000000..34fa906 --- /dev/null +++ b/src/app/api/hermes/briefing/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const row = await prisma.dataStore.findUnique({ where: { key: "hermes-briefing" } }); + return NextResponse.json(row?.data ?? { generatedAt: null, summary: null, sections: [] }); +} + +// POST → ask the bridge to (re)generate the chief-of-staff brief now. +export async function POST() { + const row = await prisma.agentRequest.create({ + data: { + origin: "web", + kind: "briefing.generate", + title: "Generate chief-of-staff brief", + prompt: "now", + sideEffecting: false, + status: "queued", + }, + }); + return NextResponse.json({ request: row }); +} diff --git a/src/app/api/hermes/cost/route.ts b/src/app/api/hermes/cost/route.ts new file mode 100644 index 0000000..3f05247 --- /dev/null +++ b/src/app/api/hermes/cost/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const row = await prisma.dataStore.findUnique({ where: { key: "hermes-cost" } }); + return NextResponse.json(row?.data ?? { summary: null, byModel: [], totalCost: null, totalTokens: null, syncedAt: null }); +} diff --git a/src/app/api/hermes/crons/route.ts b/src/app/api/hermes/crons/route.ts new file mode 100644 index 0000000..54a0a8f --- /dev/null +++ b/src/app/api/hermes/crons/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export type CronJob = { + id: string; + status: string; // "active" | "paused" + name: string; + schedule: string; + nextRun: string | null; + lastRun: string | null; + lastResult: string | null; + deliver: string | null; + skills: string | null; + script: string | null; + mode: string | null; +}; + +// Parse the raw `hermes cron list --all` terminal output into clean objects. +// Format per job: two-space-indented "<id> [status]" then four-space "Key: value" lines. +function parseCrons(raw: string): CronJob[] { + const jobs: CronJob[] = []; + let cur: CronJob | null = null; + const push = () => { if (cur) jobs.push(cur); }; + for (const line of raw.split("\n")) { + const head = line.match(/^\s{2}([0-9a-f]{6,})\s+\[(\w+)\]/); + if (head) { + push(); + cur = { id: head[1], status: head[2], name: "", schedule: "", nextRun: null, lastRun: null, lastResult: null, deliver: null, skills: null, script: null, mode: null }; + continue; + } + const kv = line.match(/^\s{4}([A-Za-z][A-Za-z ]*?):\s+(.*)$/); + if (kv && cur) { + const key = kv[1].trim().toLowerCase(); + const val = kv[2].trim(); + if (key === "name") cur.name = val; + else if (key === "schedule") cur.schedule = val; + else if (key === "next run") cur.nextRun = val; + else if (key === "deliver") cur.deliver = val; + else if (key === "skills") cur.skills = val; + else if (key === "script") cur.script = val; + else if (key === "mode") cur.mode = val; + else if (key === "last run") { + const m = val.match(/^(\S+)\s+(.*)$/); + cur.lastRun = m ? m[1] : val; + cur.lastResult = m ? m[2] : null; + } + } + } + push(); + return jobs; +} + +export async function GET() { + const row = await prisma.dataStore.findUnique({ where: { key: "hermes-crons" } }); + const data = (row?.data as { raw?: string; syncedAt?: string } | null) ?? {}; + const jobs = data.raw ? parseCrons(data.raw) : []; + return NextResponse.json({ jobs, syncedAt: data.syncedAt ?? null }); +} + +// POST { op: "create"|"pause"|"resume"|"run"|"remove"|"edit", ... } → queue a cron mutation for the bridge +export async function POST(req: Request) { + const b = await req.json().catch(() => ({})); + const op = (b.op || "").toString(); + if (!["create", "pause", "resume", "run", "remove", "edit"].includes(op)) + return NextResponse.json({ error: "bad op" }, { status: 400 }); + const label = op === "create" ? `Schedule: ${b.schedule || "?"} — ${b.prompt || b.name || ""}` : `Cron ${op}: ${b.name || b.id || ""}`; + const sideEffecting = op === "create" || op === "edit" || op === "remove"; + const row = await prisma.agentRequest.create({ + data: { + origin: "web", + kind: `cron.${op}`, + title: label.slice(0, 200), + prompt: JSON.stringify(b), + sideEffecting, + status: sideEffecting ? "awaiting_approval" : "queued", + }, + }); + return NextResponse.json({ request: row }); +} diff --git a/src/app/api/hermes/dispatch/route.ts b/src/app/api/hermes/dispatch/route.ts new file mode 100644 index 0000000..46d2994 --- /dev/null +++ b/src/app/api/hermes/dispatch/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// POST { kind?, title, prompt?, sideEffecting? } → queue work for Hermes. +// Side-effecting work waits for approval; safe work is queued immediately. +export async function POST(req: Request) { + const b = await req.json().catch(() => ({})); + const title = (b.title || b.prompt || "").toString().trim(); + if (!title) return NextResponse.json({ error: "title or prompt required" }, { status: 400 }); + const sideEffecting = Boolean(b.sideEffecting); + const row = await prisma.agentRequest.create({ + data: { + origin: "web", + kind: (b.kind || "oneshot").toString(), + title: title.slice(0, 200), + prompt: (b.prompt ?? b.title ?? "").toString() || null, + sideEffecting, + status: sideEffecting ? "awaiting_approval" : "queued", + }, + }); + return NextResponse.json({ request: row }); +} diff --git a/src/app/api/hermes/health/route.ts b/src/app/api/hermes/health/route.ts new file mode 100644 index 0000000..1c8b100 --- /dev/null +++ b/src/app/api/hermes/health/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +export async function GET() { + const row = await prisma.dataStore.findUnique({ where: { key: "hermes-health" } }); + return NextResponse.json(row?.data ?? { online: false, gateway: "unknown", lastSeen: null }); +} diff --git a/src/app/api/hermes/memory/route.ts b/src/app/api/hermes/memory/route.ts new file mode 100644 index 0000000..a386363 --- /dev/null +++ b/src/app/api/hermes/memory/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET ?q=&type=&status= → list/search wiki entries (mirrored by the bridge) +export async function GET(req: Request) { + const url = new URL(req.url); + const q = (url.searchParams.get("q") || "").trim(); + const type = url.searchParams.get("type"); + const status = url.searchParams.get("status") || "active"; + const where: Record<string, unknown> = {}; + if (status !== "all") where.status = status; + if (type && type !== "all") where.type = type; + if (q) where.OR = [ + { title: { contains: q, mode: "insensitive" } }, + { body: { contains: q, mode: "insensitive" } }, + { tags: { has: q.toLowerCase() } }, + ]; + const entries = await prisma.hermesMemory.findMany({ where, orderBy: { updatedAt: "desc" }, take: 300 }); + const all = await prisma.hermesMemory.findMany({ select: { type: true }, where: status === "all" ? {} : { status } }); + const typeCounts: Record<string, number> = {}; + for (const e of all) typeCounts[e.type] = (typeCounts[e.type] || 0) + 1; + const lastSync = entries[0]?.syncedAt ?? null; + return NextResponse.json({ entries, typeCounts, total: all.length, lastSync }); +} + +// POST { path?, id?, type, title, body, tags?, links?, status?, confidence? } +// → queue a wiki write for the bridge (writes the .md file + git commit on the mini). +export async function POST(req: Request) { + const b = await req.json().catch(() => ({})); + const title = (b.title || "").toString().trim(); + if (!title) return NextResponse.json({ error: "title required" }, { status: 400 }); + const slug = (b.id || b.path || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")).toString(); + const entry = { + id: slug, + path: (b.path || `${b.type || "note"}s/${slug}.md`).toString(), + type: (b.type || "note").toString(), + title, + status: (b.status || "active").toString(), + confidence: b.confidence ?? null, + provenance: b.provenance ?? "dashboard", + tags: Array.isArray(b.tags) ? b.tags : [], + links: Array.isArray(b.links) ? b.links : [], + body: (b.body || "").toString(), + }; + const row = await prisma.agentRequest.create({ + data: { + origin: "web", + kind: "memory.write", + title: `Memory: ${title}`.slice(0, 200), + prompt: JSON.stringify(entry), + sideEffecting: false, + status: "queued", + }, + }); + return NextResponse.json({ request: row, entry }); +} diff --git a/src/app/api/hermes/requests/[id]/route.ts b/src/app/api/hermes/requests/[id]/route.ts new file mode 100644 index 0000000..996a740 --- /dev/null +++ b/src/app/api/hermes/requests/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const b = await req.json().catch(() => ({})); + const action = (b.action || "").toString(); // approve | reject | edit + const existing = await prisma.agentRequest.findUnique({ where: { id } }); + if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 }); + if (!["awaiting_approval", "queued"].includes(existing.status)) + return NextResponse.json({ error: `cannot decide a ${existing.status} request` }, { status: 409 }); + + const data: Record<string, unknown> = { decidedAt: new Date() }; + if (action === "approve") data.status = "approved"; + else if (action === "reject") data.status = "rejected"; + else if (action === "edit") { data.status = "approved"; if (b.prompt) data.prompt = b.prompt.toString(); if (b.title) data.title = b.title.toString().slice(0, 200); } + else return NextResponse.json({ error: "action must be approve|reject|edit" }, { status: 400 }); + + const row = await prisma.agentRequest.update({ where: { id }, data }); + return NextResponse.json({ request: row }); +} diff --git a/src/app/api/hermes/requests/route.ts b/src/app/api/hermes/requests/route.ts new file mode 100644 index 0000000..8136269 --- /dev/null +++ b/src/app/api/hermes/requests/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: Request) { + const url = new URL(req.url); + const status = url.searchParams.get("status"); // e.g. "awaiting_approval" + const take = Math.min(Number(url.searchParams.get("take") || 50), 200); + const where = status ? { status: { in: status.split(",") } } : {}; + const requests = await prisma.agentRequest.findMany({ + where, orderBy: { createdAt: "desc" }, take, + }); + const pending = await prisma.agentRequest.count({ where: { status: "awaiting_approval" } }); + return NextResponse.json({ requests, pending }); +} diff --git a/src/app/api/hermes/tasks/route.ts b/src/app/api/hermes/tasks/route.ts new file mode 100644 index 0000000..452d3dd --- /dev/null +++ b/src/app/api/hermes/tasks/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const tasks = await prisma.hermesTask.findMany({ orderBy: [{ status: "asc" }, { priority: "desc" }], take: 200 }); + const counts: Record<string, number> = {}; + for (const t of tasks) counts[t.status] = (counts[t.status] || 0) + 1; + const lastSync = tasks[0]?.syncedAt ?? null; + return NextResponse.json({ tasks, counts, total: tasks.length, lastSync }); +} diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts new file mode 100644 index 0000000..2ac9b0e --- /dev/null +++ b/src/app/api/home/route.ts @@ -0,0 +1,410 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const HL_WALLET = process.env.HL_WALLET || ""; +const HL_API = "https://api.hyperliquid.xyz/info"; +const YT_API_KEY = process.env.YOUTUBE_API_KEY; +const YT_CHANNEL_ID = process.env.YOUTUBE_CHANNEL_ID || ""; +const HERMES_KANBAN_BOARD = process.env.HERMES_BOARD || "default"; + +const HERMES_KANBAN_DEMO_TASKS = [ + { id: "demo-1", title: "Research Hermes outliers and keywords", assignee: "nova", status: "done", priority: 100 }, + { id: "demo-2", title: "Create Notion To Film trigger", assignee: "hermes", status: "done", priority: 95 }, + { id: "demo-3", title: "Build Kanban demo in Hermy HQ", assignee: "coding-agent", status: "running", priority: 90 }, + { id: "demo-4", title: "Write final filming script", assignee: "nova", status: "ready", priority: 85 }, + { id: "demo-5", title: "Prepare upload SEO package", assignee: "nova", status: "ready", priority: 80 }, + { id: "demo-6", title: "Design thumbnail concepts", assignee: "creative-agent", status: "ready", priority: 75 }, +]; + +function formatHermesKanban(tasks: Array<{ id: string; title: string; assignee?: string | null; status: string; priority?: number | null }>, source: "live" | "demo" = "live") { + const counts = tasks.reduce<Record<string, number>>((acc, task) => { + acc[task.status] = (acc[task.status] || 0) + 1; + return acc; + }, {}); + return { + board: "Hermes 24/7 Assistant Video", + slug: HERMES_KANBAN_BOARD, + source, + total: tasks.length, + counts, + tasks: tasks + .sort((a, b) => (b.priority || 0) - (a.priority || 0)) + .slice(0, 6) + .map(task => ({ + id: task.id, + title: task.title, + assignee: task.assignee || "unassigned", + status: task.status, + priority: task.priority || 0, + })), + }; +} + +// Reads the HermesTask mirror kept in sync by the bridge on the Mac mini. +// Works on Vercel (no `hermes` binary needed); falls back to demo tasks until +// the bridge has synced at least once. +async function loadHermesKanban() { + try { + const tasks = await prisma.hermesTask.findMany({ orderBy: [{ priority: "desc" }], take: 50 }); + if (!tasks.length) return formatHermesKanban(HERMES_KANBAN_DEMO_TASKS, "demo"); + return formatHermesKanban( + tasks.map(t => ({ id: t.id, title: t.title, assignee: t.assignee, status: t.status, priority: t.priority })), + "live" + ); + } catch { + return formatHermesKanban(HERMES_KANBAN_DEMO_TASKS, "demo"); + } +} + +function extractMetrics(raw: unknown) { + if (!raw || typeof raw !== "object") return { views: 0, likes: 0, bookmarks: 0, replies: 0 }; + const obj = raw as Record<string, unknown>; + if (typeof obj.views === "number" || typeof obj.likes === "number") { + return { views: Number(obj.views) || 0, likes: Number(obj.likes) || 0, bookmarks: Number(obj.bookmarks) || 0, replies: Number(obj.replies) || 0 }; + } + let best = { views: 0, likes: 0, bookmarks: 0, replies: 0 }; + for (const val of Object.values(obj)) { + if (val && typeof val === "object") { + const cp = val as Record<string, unknown>; + const v = Number(cp.views) || 0; + if (v > best.views) { + best = { views: v, likes: Number(cp.likes) || 0, bookmarks: Number(cp.bookmarks) || 0, replies: Number(cp.replies) || 0 }; + } + } + } + return best; +} + +async function hlPost(body: object) { + const res = await fetch(HL_API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store" }); + return res.json(); +} + +export async function GET() { + const [ + draftsResult, + metricsResult, + topPendingDraftsResult, + hlResult, + ytTopResult, + ytLatestResult, + ytChannelResult, + xStatsRow, + ytIdeasResult, + hermesKanbanResult, + ] = await Promise.allSettled([ + prisma.draft.findMany({ where: { status: "posted" }, orderBy: { postedAt: "desc" } }), + prisma.tweetMetric.findMany(), + prisma.draft.findMany({ where: { status: "pending" }, orderBy: { createdAt: "desc" }, take: 10 }), + Promise.all([ + hlPost({ type: "clearinghouseState", user: HL_WALLET }), + hlPost({ type: "allMids" }), + hlPost({ type: "userFills", user: HL_WALLET }), + ]), + fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${YT_CHANNEL_ID}&maxResults=5&order=viewCount&type=video&key=${YT_API_KEY}`, { next: { revalidate: 3600 } }).then(r => r.json()), + fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${YT_CHANNEL_ID}&maxResults=3&order=date&type=video&key=${YT_API_KEY}`, { next: { revalidate: 3600 } }).then(r => r.json()), + fetch(`https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${YT_CHANNEL_ID}&key=${YT_API_KEY}`, { next: { revalidate: 3600 } }).then(r => r.json()), + prisma.dataStore.findUnique({ where: { key: "x-account-stats" } }), + prisma.youtubeIdea.findMany({ where: { status: { in: ["pending", "approved"] }, NOT: { status: "rejected" } }, orderBy: { createdAt: "desc" }, take: 3 }), + Promise.resolve(loadHermesKanban()), + ]); + + // ─── X Analytics ───────────────────────────────────────────────────────────── + const dbDrafts = draftsResult.status === "fulfilled" ? draftsResult.value : []; + const dbMetrics = metricsResult.status === "fulfilled" ? metricsResult.value : []; + const existingDraftIds = new Set(dbDrafts.map(d => d.id)); + const matchedTweetIds = new Set<string>(); + + const tweets: { id: string; text: string; views: number; engRate: number; postedAt: Date | string | null; tweetUrl: string | null }[] = []; + + for (const d of dbDrafts) { + const metric = dbMetrics.find(m => m.draftId === d.id || (d.tweetId && m.tweetId === d.tweetId)); + if (metric?.tweetId) matchedTweetIds.add(metric.tweetId); + const rawMetrics = d.metrics ?? metric?.checkpoints; + if (!rawMetrics && !d.tweetUrl) continue; + const { views, likes, bookmarks, replies } = extractMetrics(rawMetrics); + const engRate = views > 0 ? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100 : 0; + tweets.push({ id: d.id, text: d.text || "", views, engRate, postedAt: d.postedAt, tweetUrl: d.tweetUrl }); + } + + for (const m of dbMetrics) { + if (!m.tweetId || matchedTweetIds.has(m.tweetId)) continue; + if (m.draftId && existingDraftIds.has(m.draftId)) continue; + const { views, likes, bookmarks, replies } = extractMetrics(m.checkpoints); + if (views === 0 && likes === 0) continue; + const engRate = views > 0 ? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100 : 0; + tweets.push({ id: m.draftId || `tweet-${m.tweetId}`, text: "", views, engRate, postedAt: m.postedAt, tweetUrl: m.url }); + matchedTweetIds.add(m.tweetId); + } + + tweets.sort((a, b) => b.views - a.views); + + const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const tweetsThisWeek = tweets.filter(t => t.postedAt && new Date(t.postedAt as string).getTime() > weekAgo); + const xViewsThisWeek = tweetsThisWeek.reduce((s, t) => s + t.views, 0); + const topTweets = tweetsThisWeek.length >= 3 + ? tweetsThisWeek.slice(0, 3) + : [...tweetsThisWeek, ...tweets.filter(t => !tweetsThisWeek.includes(t))].slice(0, 3); + + // Real 14-day X-views series (summed per calendar day) for the sparkline + const DAY_MS = 86400000; + const viewsByDay = new Map<string, number>(); + for (const t of tweets) { + if (!t.postedAt || !t.views) continue; + const key = new Date(t.postedAt as string).toISOString().slice(0, 10); + viewsByDay.set(key, (viewsByDay.get(key) || 0) + t.views); + } + const xViewsTrend: number[] = []; + for (let i = 13; i >= 0; i--) { + const d = new Date(Date.now() - i * DAY_MS).toISOString().slice(0, 10); + xViewsTrend.push(viewsByDay.get(d) || 0); + } + + const heatmap: Record<string, { sum: number; count: number }> = {}; + for (const t of tweets) { + if (!t.postedAt || t.views === 0) continue; + const dt = new Date(t.postedAt as string); + const key = `${dt.getUTCDay()}-${dt.getUTCHours()}`; + if (!heatmap[key]) heatmap[key] = { sum: 0, count: 0 }; + heatmap[key].sum += t.views; + heatmap[key].count++; + } + const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + let bestSlot = { day: 0, hour: 18, avgViews: 0 }; + for (const [key, v] of Object.entries(heatmap)) { + const avg = v.sum / v.count; + if (avg > bestSlot.avgViews) { + const [day, hour] = key.split("-").map(Number); + bestSlot = { day, hour, avgViews: avg }; + } + } + + // Find the most recently posted tweet (tweets array is sorted by views, so we must reduce) + const lastPosted = tweets.reduce((latest: Date | string | null, t) => { + if (!t.postedAt) return latest; + if (!latest) return t.postedAt; + return new Date(t.postedAt as string).getTime() > new Date(latest as string).getTime() ? t.postedAt : latest; + }, null); + const daysSincePost = lastPosted ? Math.floor((Date.now() - new Date(lastPosted as string).getTime()) / 86400000) : 999; + + // ─── X account stats — live fetch + DataStore cache ─────────────────────────── + const xStatsFile = xStatsRow.status === "fulfilled" ? (xStatsRow.value?.data as any) : null; + let liveFollowers: number | null = null; + const bearerToken = process.env.TWITTER_BEARER_TOKEN; + const xHandle = xStatsFile?.xHandle || "yourhandle"; + if (bearerToken) { + try { + const twitterRes = await fetch( + `https://api.twitter.com/2/users/by/username/${xHandle}?user.fields=public_metrics`, + { headers: { Authorization: `Bearer ${bearerToken}` }, cache: "no-store" } + ); + if (twitterRes.ok) { + const twitterData = await twitterRes.json(); + liveFollowers = twitterData.data?.public_metrics?.followers_count ?? null; + // Update DataStore asynchronously (fire-and-forget) + if (liveFollowers !== null) { + prisma.dataStore.upsert({ + where: { key: "x-account-stats" }, + update: { data: { ...(xStatsFile || {}), xFollowers: liveFollowers, xHandle, xGoal: xStatsFile?.xGoal || 100000, updatedAt: new Date().toISOString() } }, + create: { key: "x-account-stats", data: { xFollowers: liveFollowers, xHandle, xGoal: 100000, updatedAt: new Date().toISOString() } }, + }).catch(() => {}); + } + } + } catch { /* non-fatal */ } + } + const xStats = { + xFollowers: liveFollowers ?? xStatsFile?.xFollowers ?? parseInt(process.env.X_FOLLOWERS || "0"), + xGoal: xStatsFile?.xGoal || 100000, + xHandle, + updatedAt: liveFollowers !== null ? new Date().toISOString() : (xStatsFile?.updatedAt || ""), + }; + + // ─── Top Sage drafts (X ideas) ─────────────────────────────────────────────── + const rawPendingDrafts = topPendingDraftsResult.status === "fulfilled" ? topPendingDraftsResult.value : []; + const topSageDrafts = rawPendingDrafts.slice(0, 3).map(d => ({ + id: d.id, + text: d.text?.slice(0, 140) || "", + })); + + // ─── YouTube ideas ──────────────────────────────────────────────────────────── + const ytIdeasAll = ytIdeasResult.status === "fulfilled" ? ytIdeasResult.value : []; + const topYoutubeIdeas = ytIdeasAll.slice(0, 3).map(i => ({ title: i.title, hook: i.hook || i.angle || "" })); + + const hermesKanban = hermesKanbanResult.status === "fulfilled" ? hermesKanbanResult.value : { board: "Hermes 24/7 Assistant Video", slug: HERMES_KANBAN_BOARD, total: 0, counts: {}, tasks: [] }; + + // ─── Build ideas (Pixel) from DataStore ──────────────────────────────────── + let topBuildIdeas: { title: string; description: string; effort: string }[] = []; + try { + const row = await prisma.dataStore.findUnique({ where: { key: "pixel-ideas" } }); + if (row?.data) { + const ideas = row.data as Array<{ title: string; description: string; effort: string; status: string }>; + topBuildIdeas = ideas.filter(i => i.status === "pending").slice(0, 3).map(i => ({ + title: i.title, + description: i.description?.slice(0, 100) || "", + effort: i.effort || "medium", + })); + } + } catch { /* ignore */ } + + // ─── YouTube ───────────────────────────────────────────────────────────────── + type YTItem = { id?: { videoId?: string }; snippet?: { title?: string; thumbnails?: { high?: { url?: string } }; publishedAt?: string } }; + type YTResult = { items?: YTItem[] }; + + let topVideo: { title: string; thumbnail: string; url: string; publishedAt: string } | null = null; + let latestVideo: { title: string; thumbnail: string; url: string; publishedAt: string } | null = null; + let ytSubscribers = 0; + + if (ytTopResult.status === "fulfilled") { + const item = (ytTopResult.value as YTResult).items?.[0]; + if (item?.id?.videoId) { + topVideo = { title: item.snippet?.title || "Top Video", thumbnail: item.snippet?.thumbnails?.high?.url || "", url: `https://youtube.com/watch?v=${item.id.videoId}`, publishedAt: item.snippet?.publishedAt || "" }; + } + } + if (ytLatestResult.status === "fulfilled") { + const item = (ytLatestResult.value as YTResult).items?.[0]; + if (item?.id?.videoId) { + latestVideo = { title: item.snippet?.title || "Latest Video", thumbnail: item.snippet?.thumbnails?.high?.url || "", url: `https://youtube.com/watch?v=${item.id.videoId}`, publishedAt: item.snippet?.publishedAt || "" }; + } + } + if (ytChannelResult.status === "fulfilled") { + const ch = ytChannelResult.value as { items?: { statistics?: { subscriberCount?: string } }[] }; + ytSubscribers = parseInt(ch.items?.[0]?.statistics?.subscriberCount || "0"); + } + if (!ytSubscribers) ytSubscribers = parseInt(process.env.YT_SUBSCRIBERS || "0"); + + if (!latestVideo && process.env.YT_LATEST_VIDEO) { + try { latestVideo = JSON.parse(process.env.YT_LATEST_VIDEO); } catch { /* ignore */ } + } + + // ─── P&L (env var fallbacks for Vercel) ──────────────────────────────────── + let allTimePnl = parseFloat(process.env.POLY_ALL_TIME_PNL || process.env.ALL_TIME_PNL || "0"); + let todayPnl = parseFloat(process.env.POLY_TODAY_PNL || "0"); + let polyWinRate = parseFloat(process.env.POLY_WIN_RATE || "0"); + + // Try DataStore for richer PnL data + try { + const row = await prisma.dataStore.findUnique({ where: { key: "polymarket-pnl" } }); + if (row?.data) { + const pnl = row.data as any; + if (pnl.allTimePnl) allTimePnl = pnl.allTimePnl; + if (pnl.todayPnl) todayPnl = pnl.todayPnl; + if (pnl.winRate) polyWinRate = pnl.winRate; + } + } catch {} + + // ─── Hyperliquid ───────────────────────────────────────────────────────────── + let hlBalance = 0; + let hlPosition: { asset: string; direction: string; unrealizedPnl: number; unrealizedPnlPct: number; leverage: number; stopLoss?: number; takeProfit?: number } | null = null; + + let hlTodayPnl = parseFloat(process.env.HL_TODAY_PNL || "0"); + let hlAllTimePnl = parseFloat(process.env.HL_ALL_TIME_PNL || "0"); + + if (hlResult.status === "fulfilled") { + const [accountState, allMids, fills] = hlResult.value as [any, any, Array<{ time: number; closedPnl: string }>]; + hlBalance = parseFloat(accountState.marginSummary?.accountValue || "0"); + for (const p of accountState.assetPositions || []) { + const pos = p.position; + if (!pos || parseFloat(pos.szi) === 0) continue; + const size = parseFloat(pos.szi); + const markPx = parseFloat(allMids[pos.coin] || pos.entryPx || "0"); + const unrealizedPnl = parseFloat(pos.unrealizedPnl || "0"); + const leverage = parseFloat(pos.leverage?.value || pos.leverage || "1"); + const notional = Math.abs(size) * markPx; + hlPosition = { asset: pos.coin, direction: size > 0 ? "long" : "short", unrealizedPnl, unrealizedPnlPct: notional > 0 ? (unrealizedPnl / (notional / leverage)) * 100 : 0, leverage }; + } + if (Array.isArray(fills) && fills.length > 0) { + const cutoffMs = Date.now() - 24 * 60 * 60 * 1000; + hlTodayPnl = fills.filter(f => f.time > cutoffMs).reduce((s, f) => s + parseFloat(f.closedPnl || "0"), 0); + hlAllTimePnl = fills.reduce((s, f) => s + parseFloat(f.closedPnl || "0"), 0); + } + try { + const row = await prisma.battleRoyaleBot.findUnique({ where: { id: "claude-opus" } }); + if (row?.state && hlPosition) { + const state = row.state as { current_position?: { stop_loss?: number; take_profit?: number } }; + hlPosition.stopLoss = state.current_position?.stop_loss; + hlPosition.takeProfit = state.current_position?.take_profit; + } + } catch { /* ignore */ } + } + + // ─── Polymarket balance (env var fallback) ────────────────────────────────── + let polyBalance = parseFloat(process.env.POLY_BALANCE || "0"); + + // ─── PM2 Processes — not available on Vercel ─────────────────────────────── + const processes: { name: string; status: string; uptime: string }[] = []; + + const hourStr = (h: number) => { const s = h >= 12 ? "PM" : "AM"; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${h12}${s}`; }; + + // ─── Daily metric snapshots (rolling history for deltas + sparklines) ──────── + // Stored in the generic DataStore (no schema migration). Honest by design: + // history starts accumulating today; deltas render once we have 2+ days. + type Snap = { d: string; xf: number; yt: number; pnl: number }; + let snapshots: Snap[] = []; + try { + const snapRow = await prisma.dataStore.findUnique({ where: { key: "metric-snapshots" } }); + snapshots = Array.isArray(snapRow?.data) ? (snapRow!.data as Snap[]) : []; + const today = new Date().toISOString().slice(0, 10); + const totalPnlNow = allTimePnl + hlAllTimePnl; + const point: Snap = { + d: today, + xf: xStats.xFollowers, + yt: ytSubscribers, + pnl: Math.round(totalPnlNow * 100) / 100, + }; + const idx = snapshots.findIndex(s => s.d === today); + if (idx >= 0) snapshots[idx] = point; else snapshots.push(point); + snapshots = snapshots.slice(-60); // ~2 months of daily history + prisma.dataStore.upsert({ + where: { key: "metric-snapshots" }, + update: { data: snapshots }, + create: { key: "metric-snapshots", data: snapshots }, + }).catch(() => {}); + } catch { /* non-fatal */ } + + return NextResponse.json({ + // X + xFollowers: xStats.xFollowers, + xGoal: xStats.xGoal, + xHandle: xStats.xHandle, + topTweets, + topTweet: topTweets[0] || null, + xViewsThisWeek, + totalTweets: tweets.length, + daysSincePost, + bestPostingDay: DAYS[bestSlot.day], + bestPostingHourStr: hourStr(bestSlot.hour), + xViewsTrend, + snapshots, + // Ideas + topSageDrafts, + topYoutubeIdeas, + topBuildIdeas, + // YouTube + topVideo, + latestVideo, + ytSubscribers, + ytGoal: 20000, + // Trading, hard-coded for dashboard/demo display + polyBalance, + polyWinRate, + polyTodayPnl: 67.22, + polyAllTimePnl: 67.22, + hlBalance, + hlTodayPnl: 0, + hlAllTimePnl: 0, + hlPosition: null, + allTimePnl: 67.22, + todayPnl: 67.22, + // System + processes, + hermesKanban, + // Legacy + pendingDrafts: rawPendingDrafts.length, + tweetIdeas: await prisma.idea.count({ where: { status: { notIn: ["done", "dismissed"] } } }).catch(() => 0), + videosToFilm: await prisma.youtubeScript.count({ where: { status: { in: ["ready", "to_film", "tofilm", "approved"] } } }).catch(() => 0), + insight: "", + }, { headers: { "Cache-Control": "no-store, no-cache" } }); +} diff --git a/src/app/api/ideas/route.ts b/src/app/api/ideas/route.ts new file mode 100644 index 0000000..51eef32 --- /dev/null +++ b/src/app/api/ideas/route.ts @@ -0,0 +1,54 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const type = req.nextUrl.searchParams.get("type"); + + const ideas = await prisma.idea.findMany({ + where: type ? { type } : undefined, + orderBy: { timestamp: "desc" }, + }); + + return NextResponse.json(ideas); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + + const idea = await prisma.idea.create({ + data: { + title: body.title, + description: body.description || null, + category: body.category || null, + type: body.type || null, + model: body.model || null, + status: body.status || null, + }, + }); + + return NextResponse.json(idea); +} + +export async function PUT(req: NextRequest) { + const { id, ...updates } = await req.json(); + if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); + + try { + const idea = await prisma.idea.update({ + where: { id }, + data: updates, + }); + return NextResponse.json(idea); + } catch { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } +} + +export async function DELETE(req: NextRequest) { + const { id } = await req.json(); + if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); + + await prisma.idea.delete({ where: { id } }).catch(() => {}); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/longform/generate/route.ts b/src/app/api/longform/generate/route.ts new file mode 100644 index 0000000..72932a2 --- /dev/null +++ b/src/app/api/longform/generate/route.ts @@ -0,0 +1,265 @@ +export const maxDuration = 120; +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; + +async function callLLM(apiKey: string, apiBase: string, payload: object): Promise<string> { + const res = await fetch(`${apiBase}/chat/completions`, { + method: "POST", + headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`LLM API error ${res.status}: ${err.slice(0, 200)}`); + } + return res.json().then(d => JSON.stringify(d)); +} + +async function braveSearch(query: string, braveKey: string, count = 5): Promise<{ title: string; url: string; description: string }[]> { + try { + const res = await fetch( + `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`, + { headers: { "Accept": "application/json", "X-Subscription-Token": braveKey } } + ); + if (!res.ok) return []; + const data = await res.json(); + return (data.web?.results || []).map((r: any) => ({ title: r.title, url: r.url, description: r.description })); + } catch { + return []; + } +} + +export async function POST(req: NextRequest) { + const { topic, hook: existingHook, type: contentType } = await req.json(); + if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 }); + + // Use XAI (Grok) — already configured on Vercel. Falls back to OpenAI if set. + const apiKey = process.env.XAI_API_KEY || process.env.OPENAI_API_KEY || ""; + const apiBase = process.env.XAI_API_KEY ? "https://api.x.ai/v1" : "https://api.openai.com/v1"; + const model = process.env.XAI_API_KEY ? "grok-4-1-fast-non-reasoning" : "gpt-4o-mini"; + const braveKey = process.env.BRAVE_API_KEY || ""; + if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 }); + + // Step 1: Web search to gather real context + let sourceContext = ""; + const sourceUrls: string[] = []; + + if (braveKey) { + // Generate search queries + const queryResult = await callLLM(apiKey, apiBase, { + model, + messages: [ + { role: "system", content: "Extract 3 specific search queries to research this video topic thoroughly. Return JSON: {\"queries\":[\"query1\",\"query2\",\"query3\"]}" }, + { role: "user", content: `Long-form video topic: ${topic}` } + ], + temperature: 0.3, + response_format: { type: "json_object" } + }); + + let queries: string[] = []; + try { + const qd = JSON.parse(queryResult); + queries = JSON.parse(qd.choices[0].message.content).queries || []; + } catch { /* ok */ } + + for (const query of queries.slice(0, 3)) { + const results = await braveSearch(query, braveKey, 3); + if (results.length > 0) { + sourceContext += `\n\n## SEARCH: "${query}"\n${results.map(r => { + sourceUrls.push(r.url); + return `- ${r.title} (${r.url})\n ${r.description}`; + }).join("\n")}`; + } + } + } + + // Step 2: Load voice analysis + let voiceGuide = ""; + try { + voiceGuide = fs.readFileSync("./data/longform-voice-analysis.md", "utf-8"); + } catch { /* ok */ } + + // Step 2b: Load feedback history (what the user likes/hates) + let feedbackContext = ""; + try { + const drafts = JSON.parse(fs.readFileSync("./data/drafts.json", "utf-8")); + const rejected = drafts.filter((d: any) => d.feedback?.rating === "down" && d.feedback?.reason); + const approved = drafts.filter((d: any) => d.status === "approved" || d.feedback?.rating === "up"); + + if (rejected.length > 0) { + feedbackContext += "\n## THINGS THE USER HATES (from rejected drafts):\n"; + for (const r of rejected.slice(0, 15)) { + feedbackContext += `- "${r.title}" REJECTED because: "${r.feedback.reason}"\n`; + } + } + if (approved.length > 0) { + feedbackContext += "\n## THINGS THE USER LIKES (approved drafts):\n"; + for (const a of approved.slice(0, 10)) { + feedbackContext += `- "${a.title}" ✅\n`; + } + } + } catch { /* ok */ } + + // Step 2c: Load YouTube performance data + let performanceContext = ""; + try { + const transcripts = JSON.parse(fs.readFileSync("./data/youtube-transcripts.json", "utf-8")); + const sorted = transcripts.sort((a: any, b: any) => (b.views || 0) - (a.views || 0)); + performanceContext = "\n## TOP PERFORMING YOUTUBE VIDEOS:\n"; + for (const v of sorted.slice(0, 5)) { + performanceContext += `- "${v.title}" — ${v.views} views\n`; + } + performanceContext += "\nThe AI trading/experiment format massively outperforms other content.\n"; + } catch { /* ok */ } + + // Step 2d: Load rejected longform scripts for learning + let longformFeedback = ""; + try { + const lfScripts = JSON.parse(fs.readFileSync("./data/longform-scripts.json", "utf-8")); + const rejectedLf = lfScripts.filter((s: any) => s.status === "rejected" && s.rejectedReason); + if (rejectedLf.length > 0) { + longformFeedback = "\n## REJECTED LONGFORM SCRIPTS:\n"; + for (const r of rejectedLf) { + longformFeedback += `- "${r.title}" REJECTED: "${r.rejectedReason}"\n`; + } + } + } catch { /* ok */ } + + // Step 3: Generate long-form script + const systemPrompt = `You are writing a long-form YouTube video script for the user, a founder and content creator. + +## THE USER'S LONG-FORM VOICE (30-Transcript Analysis) + +**Narrative Strategy and Performance Optimization:** + +1. **Content Architecture (Statistically Most Engaging):** + - Personal experiment/challenge format + - Clear, quantifiable goal + - Day-by-day progression + - Unexpected insights + - Data-driven conclusions + - Vulnerability in sharing results + +2. **Voice Characteristics:** + - "Curious explorer" — NOT a lecturer + - Learning WITH the audience, not AT them + - Comfortable admitting initial ignorance + - Technical depth without academic language + +3. **Opening Hook Hierarchy (By Performance):** + - "I was doing X when I noticed Y" + - "Everyone says Z. So I decided to test it." + - "I kept seeing something and realized I didn't understand it." + +4. **Linguistic Performance Markers:** + - Primary transition: "So," + - Secondary transitions: + * "And here's the thing" + * "Which made me realize something" + * "See," + - Conversational fillers: "like," "I mean," "honestly" + +5. **Humor Guidelines:** + - Dry, self-deprecating + - Observational comedy + - Highlight absurdity without forcing jokes + - Meta-commentary on process + +6. **Content Structure:** + 1. Personal discovery moment + 2. Clear investigation goal + 3. Systematic exploration + 4. Unexpected insights + 5. Broader implications + 6. Soft, organic conclusion + +7. **Language Restrictions:** + - No em dashes + - Avoid academic vocabulary + - No exaggerated YouTuber language + - Minimal CTAs + - Prioritize genuine curiosity + +8. **High-Performance Topic Types:** + - Technology disruption + - Personal experiments + - Demystifying complex topics + - Unexpected perspectives on trends + +9. **Sentence Mechanics:** + - Mix technical explanation + conversational reaction + - Short, punchy sentences + - Rarely academic + - Strategic emphatic words: "literally," "impressive" + +10. **Fundamental Principle:** + Sound like a smart, curious friend figuring something out — NOT an expert lecturing. + +## Performance Correlation Insights +- Experiment/challenge videos generate 3-5x more engagement +- Personal vulnerability increases viewer retention +- Unexpected topic approaches drive curiosity +- Technical topics made accessible through storytelling + +**Absolute Rule:** Authenticity trumps polish. Always. + +${voiceGuide ? `## FULL VOICE ANALYSIS REFERENCE\n${voiceGuide.slice(0, 3000)}` : ""} +${feedbackContext} +${performanceContext} +${longformFeedback} + +${existingHook ? `## EXISTING HOOK (keep or improve):\n"${existingHook}"\n` : ""} + +## SOURCE MATERIAL (verified): +${sourceContext || "No sources — be cautious with claims, hedge everything."} + +## SCRIPT FORMAT +Write a FULL script in markdown with: +- Clear section headers (## HOOK, ## PART 1: ..., etc.) +- Stage directions in *[brackets]* for B-roll/screen recordings +- ALL dialogue written as SPOKEN WORD, exactly how the user would say it on camera +- Target: 8-15 minutes when read aloud (~1500-2500 words) +- Use their actual patterns: "So, I decided to look into it just to understand it." / "And here's where it gets interesting." + +## OUTPUT FORMAT — valid JSON: +{ + "title": "Compelling YouTube title", + "hook": "The first 2-3 sentences that stop the scroll — personal, narrative, curiosity-driven", + "outline": "Section1 → Section2 → Section3 → ...", + "targetLength": "X-Y min", + "fullScript": "Full markdown script with all sections, stage directions, and dialogue", + "factCheck": { + "status": "✅", + "verified": ["list of verified claims"], + "issues": ["any issues found"] + } +}`; + + const genResult = await callLLM(apiKey, apiBase, { + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: `Write a full long-form video script about: ${topic}\n\nMake it feel real and honest. Use facts from the source material. Write the COMPLETE script, not just an outline.` } + ], + temperature: 0.85, + response_format: { type: "json_object" } + }); + + const genData = JSON.parse(genResult); + const script = JSON.parse(genData.choices[0].message.content); + + return NextResponse.json({ + id: `lf-${Date.now()}`, + title: script.title, + hook: script.hook, + outline: script.outline, + targetLength: script.targetLength || "8-12 min", + fullScript: script.fullScript, + platforms: ["youtube", "twitter"], + factCheck: script.factCheck || { status: "✅", verified: [], issues: [] }, + status: "draft", + type: contentType || "article", + createdAt: new Date().toISOString().split("T")[0], + notes: "", + }); +} diff --git a/src/app/api/longform/route.ts b/src/app/api/longform/route.ts new file mode 100644 index 0000000..69b627d --- /dev/null +++ b/src/app/api/longform/route.ts @@ -0,0 +1,62 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + const scripts = await prisma.longformScript.findMany({ + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(scripts, { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }); + } catch (e: any) { + console.error("[longform GET]", e?.message || e); + return NextResponse.json({ error: e?.message || "DB error" }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + + const script = await prisma.longformScript.create({ + data: { + id: body.id || undefined, + title: body.title, + type: body.type || null, + status: body.status || "draft", + platform: body.platform || null, + platforms: body.platforms || null, + description: body.description || null, + hook: body.hook || null, + outline: body.outline || null, + fullScript: body.fullScript || null, + targetLength: body.targetLength || null, + factCheck: body.factCheck || null, + notes: body.notes || null, + }, + }); + + return NextResponse.json(script); +} + +export async function PATCH(req: NextRequest) { + const body = await req.json(); + const { id, deleted, ...updates } = body; + if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); + + if (deleted) { + await prisma.longformScript.delete({ where: { id } }).catch(() => {}); + return NextResponse.json({ ok: true }); + } + + try { + const script = await prisma.longformScript.update({ + where: { id }, + data: updates, + }); + return NextResponse.json(script); + } catch { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } +} diff --git a/src/app/api/longform/tweak/route.ts b/src/app/api/longform/tweak/route.ts new file mode 100644 index 0000000..8bab763 --- /dev/null +++ b/src/app/api/longform/tweak/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; + +// TODO: Trigger files written to the local filesystem won't work on Vercel. +// This route needs to be reworked for serverless (e.g., use a database queue, +// Inngest, or a similar job system) when deploying to Vercel. +const TRIGGERS_DIR = "./data/triggers"; + +export async function POST(req: NextRequest) { + const { id, title, hook, outline, message } = await req.json(); + + if (!id || !message) { + return NextResponse.json({ error: "id and message required" }, { status: 400 }); + } + + // Ensure triggers directory exists + if (!fs.existsSync(TRIGGERS_DIR)) { + fs.mkdirSync(TRIGGERS_DIR, { recursive: true }); + } + + // Write trigger file for Nova to pick up + const trigger = { + type: "longform-tweak", + scriptId: id, + title: title || "", + hook: hook || "", + outline: outline || "", + message, + createdAt: new Date().toISOString(), + }; + + const filename = `longform-tweak-${id}-${Date.now()}.json`; + fs.writeFileSync(`${TRIGGERS_DIR}/${filename}`, JSON.stringify(trigger, null, 2)); + + return NextResponse.json({ ok: true, trigger: filename }); +} diff --git a/src/app/api/score/route.ts b/src/app/api/score/route.ts new file mode 100644 index 0000000..ea356b3 --- /dev/null +++ b/src/app/api/score/route.ts @@ -0,0 +1,144 @@ +/** + * Momentum Score — Weekly Performance Score (last 7 days) + * + * Components: + * X Performance 35% — Weekly tweet views + engagement + * YouTube Growth 20% — New subscribers this week + * Trading 20% — Win rate (not P&L — skill > luck) + * Content Output 25% — Posts made + quality this week + */ + +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function GET() { + const now = Date.now(); + const weekAgo = new Date(now - 7 * 86_400_000); + + // ── 1. X Performance (35%) ───────────────────────────────── + let weeklyViews = parseInt(process.env.X_WEEKLY_VIEWS || "0"); + let weeklyPosts = 0; + let bestTweetViews = parseInt(process.env.X_BEST_TWEET_VIEWS || "0"); + + if (weeklyViews === 0 || weeklyPosts === 0) { + try { + const drafts = await prisma.draft.findMany({ where: { postedAt: { gte: weekAgo } } }); + weeklyPosts = drafts.length; + for (const d of drafts) { + const m = d.metrics as Record<string, unknown> | null; + const v = typeof m?.views === "number" ? m.views : 0; + if (weeklyViews === 0) weeklyViews += v; + if (v > bestTweetViews) bestTweetViews = v; + } + } catch { /* Prisma may be unavailable */ } + } else { + try { + weeklyPosts = await prisma.draft.count({ where: { postedAt: { gte: weekAgo } } }); + } catch { weeklyPosts = parseInt(process.env.WEEKLY_POSTS || "3"); } + } + + const xScore = weeklyViews >= 1_000_000 ? 100 + : weeklyViews >= 500_000 ? 92 + : weeklyViews >= 200_000 ? 82 + : weeklyViews >= 100_000 ? 72 + : weeklyViews >= 50_000 ? 60 + : weeklyViews >= 10_000 ? 45 + : weeklyViews >= 1_000 ? 25 + : 10; + + // ── 2. YouTube Growth (20%) ──────────────────────────────── + const currentYtSubs = parseInt(process.env.YT_SUBSCRIBERS || "0"); + let ytGrowth = parseInt(process.env.YT_WEEK_NEW_SUBS || "0"); + let ytSubsWeekAgo = ytGrowth > 0 ? currentYtSubs - ytGrowth : currentYtSubs; + + if (ytGrowth === 0) { + try { + const row = await prisma.dataStore.findUnique({ where: { key: "score-snapshots" } }); + const snapshots = (row?.data as any[]) ?? []; + const target = new Date(weekAgo).toISOString().slice(0, 10); + const past = snapshots.filter((s: any) => s.date <= target).sort((a: any, b: any) => b.date.localeCompare(a.date)); + if (past.length > 0) { + ytSubsWeekAgo = past[0].ytSubscribers; + ytGrowth = Math.max(0, currentYtSubs - ytSubsWeekAgo); + } + } catch { /* ignore */ } + } + + const ytGrowthPct = ytSubsWeekAgo > 0 ? (ytGrowth / ytSubsWeekAgo) * 100 : 0; + + const ytScoreFromGrowth = ytGrowth >= 1000 ? 100 + : ytGrowth >= 500 ? 90 + : ytGrowth >= 200 ? 80 + : ytGrowth >= 100 ? 70 + : ytGrowth >= 50 ? 58 + : ytGrowth >= 20 ? 45 + : ytGrowth >= 5 ? 30 + : ytGrowth >= 1 ? 15 + : 5; + + const ytGrowthBonus = ytGrowthPct >= 200 ? 15 + : ytGrowthPct >= 100 ? 10 + : ytGrowthPct >= 50 ? 5 + : 0; + + const ytScore = Math.min(100, ytScoreFromGrowth + ytGrowthBonus); + + // ── 3. Trading P&L (20%) ────────────────────────────────── + const weekPolyPnl = parseFloat(process.env.POLY_WEEK_PNL || "0"); + const weekHlPnl = parseFloat(process.env.HL_WEEK_PNL || "0"); + const weekPnl = weekPolyPnl + weekHlPnl; + + const tradingScore = weekPnl >= 200 ? 100 + : weekPnl >= 100 ? 90 + : weekPnl >= 50 ? 80 + : weekPnl >= 20 ? 72 + : weekPnl >= 5 ? 63 + : weekPnl >= 0 ? 52 + : weekPnl >= -10 ? 42 + : weekPnl >= -30 ? 32 + : weekPnl >= -60 ? 22 + : weekPnl >= -100 ? 12 + : 5; + + // ── 4. Content Output (25%) ─────────────────────────────── + const consistencyScore = weeklyPosts >= 7 ? 100 + : weeklyPosts >= 5 ? 88 + : weeklyPosts >= 3 ? 75 + : weeklyPosts >= 2 ? 62 + : weeklyPosts >= 1 ? 50 + : 10; + + const viralBonus = bestTweetViews >= 1_000_000 ? 12 + : bestTweetViews >= 500_000 ? 8 + : bestTweetViews >= 100_000 ? 4 + : 0; + + const contentScore = Math.min(100, consistencyScore + viralBonus); + + // ── Final score ──────────────────────────────────────────── + const raw = xScore * 0.40 + ytScore * 0.25 + contentScore * 0.35; + const score = Math.round(Math.min(100, Math.max(0, raw))); + + const grade = score >= 90 ? "S" : score >= 80 ? "A" : score >= 70 ? "B" : score >= 60 ? "C" : score >= 50 ? "D" : "F"; + const label = score >= 90 ? "Legendary" : score >= 80 ? "On Fire 🔥" : score >= 70 ? "Crushing It" : score >= 60 ? "Solid" : score >= 50 ? "Building" : "Slow Week"; + const color = score >= 75 ? "emerald" : score >= 55 ? "yellow" : "red"; + + return NextResponse.json({ + score, grade, label, color, + period: "last 7 days", + components: { + x: { score: Math.round(xScore), weight: 0.40, label: "X Performance", detail: weeklyViews > 0 ? `${(weeklyViews/1000).toFixed(0)}K views` : "No data" }, + youtube: { score: Math.round(ytScore), weight: 0.25, label: "YouTube Growth", detail: ytGrowth > 0 ? `+${ytGrowth} subs (${ytGrowthPct.toFixed(0)}%)` : "Tracking..." }, + content: { score: Math.round(contentScore), weight: 0.35, label: "Content Output", detail: `${weeklyPosts} posts${bestTweetViews > 100000 ? ` · ${(bestTweetViews/1000000).toFixed(1)}M best` : ""}` }, + }, + inputs: { + weeklyViews, weeklyPosts, bestTweetViews, + currentYtSubs, ytSubsWeekAgo, ytGrowth, ytGrowthPct, + weekPnl, weekPolyPnl, weekHlPnl, + }, + generatedAt: new Date().toISOString(), + }, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/src/app/api/scrape-metrics/route.ts b/src/app/api/scrape-metrics/route.ts new file mode 100644 index 0000000..e817978 --- /dev/null +++ b/src/app/api/scrape-metrics/route.ts @@ -0,0 +1,178 @@ +export const dynamic = "force-dynamic"; +export const maxDuration = 60; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +async function getYouTubeMetrics(url: string) { + try { + const match = url.match(/(?:v=|youtu\.be\/|\/shorts\/)([a-zA-Z0-9_-]{11})/); + if (!match) return null; + const videoId = match[1]; + + // Use YouTube Data API v3 if key available (most reliable) + const ytKey = process.env.YOUTUBE_API_KEY; + if (ytKey) { + const apiRes = await fetch( + `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&part=snippet,statistics&key=${ytKey}`, + { cache: "no-store" } + ); + if (apiRes.ok) { + const data = await apiRes.json(); + const item = data.items?.[0]; + if (item) { + return { + title: item.snippet?.title || "", + thumbnail: item.snippet?.thumbnails?.maxres?.url || + item.snippet?.thumbnails?.high?.url || + `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`, + viewCount: parseInt(item.statistics?.viewCount || "0"), + likeCount: parseInt(item.statistics?.likeCount || "0"), + commentCount: parseInt(item.statistics?.commentCount || "0"), + }; + } + } + } + + // Fallback: oEmbed for title/thumbnail + noembed for basic info + let title = ""; + let thumbnail = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; + let viewCount = 0; + let likeCount = 0; + + try { + const oembed = await fetch( + `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`, + { cache: "no-store" } + ); + if (oembed.ok) { + const d = await oembed.json(); + title = d.title || ""; + thumbnail = d.thumbnail_url || thumbnail; + } + } catch { /* ok */ } + + // Try to get view count from YouTube page (parse simple patterns) + try { + const pageRes = await fetch( + `https://www.youtube.com/watch?v=${videoId}`, + { + headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)" }, + cache: "no-store", + } + ); + if (pageRes.ok) { + const html = await pageRes.text(); + // View count appears as "viewCount\":\"12345\" + const vcMatch = html.match(/"viewCount"\s*:\s*"(\d+)"/); + if (vcMatch) viewCount = parseInt(vcMatch[1]); + // Like count + const lcMatch = html.match(/"likeCount"\s*:\s*"(\d+)"/); + if (lcMatch) likeCount = parseInt(lcMatch[1]); + // Title fallback + if (!title) { + const tMatch = html.match(/<title>([^<]+)<\/title>/); + if (tMatch) title = tMatch[1].replace(" - YouTube", "").trim(); + } + } + } catch { /* ok */ } + + return { title, thumbnail, viewCount, likeCount, commentCount: 0 }; + } catch (e) { + console.error("YouTube scrape error:", e); + return null; + } +} + +async function getTwitterMetrics(tweetUrl: string) { + const xaiKey = process.env.XAI_API_KEY; + if (!xaiKey) { console.error("[scrape-metrics] XAI_API_KEY not set"); return null; } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 25000); + + try { + console.log("[scrape-metrics] Calling xAI for:", tweetUrl); + const res = await fetch("https://api.x.ai/v1/responses", { + method: "POST", + headers: { "Authorization": `Bearer ${xaiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "grok-4-1-fast-non-reasoning", + tools: [{ type: "x_search" }], + input: [{ + role: "user", + content: `Fetch the engagement stats for this specific tweet ID: ${tweetUrl}\nReturn ONLY this JSON (no markdown, no extra text): {"views":0,"likes":0,"retweets":0,"bookmarks":0,"replies":0}` + }] + }), + signal: controller.signal, + }); + clearTimeout(timeout); + console.log("[scrape-metrics] xAI status:", res.status); + if (!res.ok) { + const errText = await res.text(); + console.error("[scrape-metrics] xAI error:", errText.slice(0, 300)); + return null; + } + const data = await res.json(); + const text = data.output?.find((o: { type: string }) => o.type === "message") + ?.content?.find((c: { type: string }) => c.type === "output_text")?.text || ""; + console.log("[scrape-metrics] xAI response text:", text.slice(0, 300)); + const jsonMatch = text.match(/\{[^{}]*\}/); + if (!jsonMatch) { console.error("[scrape-metrics] No JSON in response:", text.slice(0, 300)); return null; } + const parsed = JSON.parse(jsonMatch[0]); + console.log("[scrape-metrics] Parsed twitter metrics:", parsed); + return parsed; + } catch (e: any) { + clearTimeout(timeout); + console.error("[scrape-metrics] xAI fetch error:", e?.name, e?.message); + return null; + } +} + +export async function POST(req: NextRequest) { + const { youtubeUrl, tweetUrl, scriptId } = await req.json(); + const result: any = {}; + + // Run both in parallel + const [yt, twitter] = await Promise.all([ + youtubeUrl ? getYouTubeMetrics(youtubeUrl) : Promise.resolve(null), + tweetUrl ? getTwitterMetrics(tweetUrl) : Promise.resolve(null), + ]); + + if (yt) result.youtube = yt; + if (twitter) result.twitter = twitter; + + // If scriptId provided, save directly to DB — no client-side PATCH needed + if (scriptId && (yt || twitter)) { + try { + const updates: Record<string, unknown> = {}; + if (yt) { + if (yt.title) updates.title = yt.title; + if (yt.thumbnail) updates.thumbnailUrl = yt.thumbnail; + if (yt.viewCount != null) updates.youtubeViews = yt.viewCount; + if (yt.likeCount != null) updates.youtubeLikes = yt.likeCount; + if (yt.commentCount != null) updates.youtubeComments = yt.commentCount; + } + if (twitter) { + if (twitter.views != null) updates.tweetViews = twitter.views; + if (twitter.likes != null) updates.tweetLikes = twitter.likes; + if (twitter.bookmarks != null) updates.tweetBookmarks = twitter.bookmarks; + if (twitter.retweets != null) updates.tweetRetweets = twitter.retweets; + if (twitter.replies != null) updates.tweetReplies = twitter.replies; + } + if (Object.keys(updates).length > 0) { + const saved = await prisma.longformScript.update({ where: { id: scriptId }, data: updates }); + result.saved = true; + // Return the updated fields in result so client can update UI + result.updatedScript = saved; + console.log("[scrape-metrics] Saved to DB:", scriptId, Object.keys(updates)); + } + } catch (e) { + console.error("[scrape-metrics] DB save error:", e); + result.saveError = String(e); + } + } + + return NextResponse.json(result, { + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 0000000..083c653 --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server"; + +// This would connect to Notion API +// For now, return mock data + +const NOTION_API_KEY = process.env.NOTION_API_KEY; +const DATABASE_ID = "1264208d-f768-4604-b4cb-09f4d6fd41e3"; // Max's Tasks DB + +export async function GET() { + try { + if (!NOTION_API_KEY) { + // Return mock data if no API key + return NextResponse.json({ + tasks: [ + { id: "1", name: "Review Polymarket bot strategy", status: "In progress", priority: "High", category: "Research" }, + { id: "2", name: "Build Hermy HQ dashboard", status: "In progress", priority: "High", category: "Content" }, + { id: "3", name: "Daily brief automation", status: "Approved", priority: "Medium", category: "Admin" }, + ], + }); + } + + const res = await fetch(`https://api.notion.com/v1/databases/${DATABASE_ID}/query`, { + method: "POST", + headers: { + Authorization: `Bearer ${NOTION_API_KEY}`, + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filter: { + property: "Status", + status: { + does_not_equal: "Done", + }, + }, + }), + }); + + const data = await res.json(); + + const tasks = data.results?.map((page: any) => ({ + id: page.id, + name: page.properties.Name?.title?.[0]?.plain_text || "Untitled", + status: page.properties.Status?.status?.name || "Not started", + priority: page.properties.Priority?.select?.name || "", + category: page.properties.Category?.select?.name || "", + dueDate: page.properties["Due Date"]?.date?.start || null, + })) || []; + + return NextResponse.json({ tasks }); + } catch (error) { + console.error("Tasks API error:", error); + return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 }); + } +} + +export async function POST(req: Request) { + try { + const { name, status } = await req.json(); + + if (!NOTION_API_KEY) { + return NextResponse.json({ success: true, message: "Mock - would create task in Notion" }); + } + + const res = await fetch("https://api.notion.com/v1/pages", { + method: "POST", + headers: { + Authorization: `Bearer ${NOTION_API_KEY}`, + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + parent: { database_id: DATABASE_ID }, + properties: { + Name: { title: [{ text: { content: name } }] }, + Status: { status: { name: status || "Not started" } }, + }, + }), + }); + + const data = await res.json(); + return NextResponse.json({ success: true, task: data }); + } catch (error) { + console.error("Create task error:", error); + return NextResponse.json({ error: "Failed to create task" }, { status: 500 }); + } +} + +export async function PATCH(req: Request) { + try { + const { id, status } = await req.json(); + + if (!NOTION_API_KEY) { + return NextResponse.json({ success: true, message: "Mock - would update task in Notion" }); + } + + const res = await fetch(`https://api.notion.com/v1/pages/${id}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${NOTION_API_KEY}`, + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + properties: { + Status: { status: { name: status } }, + }, + }), + }); + + const data = await res.json(); + return NextResponse.json({ success: true, task: data }); + } catch (error) { + console.error("Update task error:", error); + return NextResponse.json({ error: "Failed to update task" }, { status: 500 }); + } +} diff --git a/src/app/api/trends/route.ts b/src/app/api/trends/route.ts new file mode 100644 index 0000000..e65f3da --- /dev/null +++ b/src/app/api/trends/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +interface Trend { + id: string; + topic: string; + summary: string; + source: "x_search" | "rss" | "breaking"; + velocity: "forming" | "rising" | "peaking" | "fading"; + velocityScore: number; + ageMinutes: number; + relevance: "crypto" | "ai" | "creator" | "other"; + url: string | null; + sageAngle: string | null; + detectedAt: string; +} + +interface TrendsResponse { + trends: Trend[]; + lastUpdated: string; + nextUpdate: string; +} + +async function readTrendData(): Promise<TrendsResponse> { + // 1. Try DataStore (synced by heartbeat) + try { + const row = await prisma.dataStore.findUnique({ where: { key: "trend-radar" } }); + if (row?.data) { + const parsed = row.data as unknown as TrendsResponse; + if (parsed.trends && parsed.trends.length > 0) return parsed; + } + } catch { /* not available */ } + + // 2. Try env var cache (pushed by heartbeat, works on Vercel) + if (process.env.TREND_RADAR_DATA) { + try { + const parsed = JSON.parse(process.env.TREND_RADAR_DATA) as TrendsResponse; + if (parsed.trends && parsed.trends.length > 0) { + // Normalise any non-standard velocity values Grok might return + const validVelocities = new Set(["forming", "rising", "peaking", "fading"]); + const velocityMap: Record<string, Trend["velocity"]> = { + viral: "peaking", emerging: "forming", stable: "fading", + breaking: "peaking", new: "forming", growing: "rising", + }; + parsed.trends = parsed.trends.map((t) => ({ + ...t, + velocity: validVelocities.has(t.velocity) + ? t.velocity + : (velocityMap[t.velocity] ?? "forming"), + })); + return parsed; + } + } catch { /* invalid */ } + } + + // 3. No real data — return empty + const now = new Date(); + return { + trends: [], + lastUpdated: now.toISOString(), + nextUpdate: new Date(now.getTime() + 30 * 60000).toISOString(), + }; +} + +export async function GET() { + try { + const data = await readTrendData(); + return NextResponse.json(data); + } catch (error) { + console.error("[trends] GET error:", error); + const now = new Date(); + return NextResponse.json({ + trends: [], + lastUpdated: now.toISOString(), + nextUpdate: new Date(now.getTime() + 5 * 60000).toISOString(), + }); + } +} diff --git a/src/app/api/trends/snipe/route.ts b/src/app/api/trends/snipe/route.ts new file mode 100644 index 0000000..4994552 --- /dev/null +++ b/src/app/api/trends/snipe/route.ts @@ -0,0 +1,143 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +const SAGE_SYSTEM = `You are Sage 🌿, the user's X/Twitter content specialist. You write viral tweets in the user's voice. + +== WHO THE USER IS == +- AI content creator, growing fast +- Founder building AI tools and products +- Runs AI agents (Max, Sage, Knox, Nova, Pixel) +- Runs trading bots on Polymarket + Hyperliquid + +== VOICE — RULES == +1. Lowercase throughout (except proper nouns like Claude, OpenAI, etc.) +2. No em-dashes (—). Use line breaks instead +3. No "harness", "leverage", "AI-powered", "here's why X is dying", "hot take:" +4. Never end with "what do you think?" or "let me know below" +5. No 🧵 thread emoji +6. Real numbers anchor every claim ($140/month, 999K views, 94% win rate) +7. Line breaks every 1-2 sentences — no walls of text +8. The hook is everything. First line must make you stop scrolling. + +== HOOK PATTERNS THAT WORK == +- "I [specific action] and here's what happened" +- "[Real number] [thing]. here's how." +- "most people [wrong thing]. I [right thing]." +- "[Provocative statement that sounds wrong but is right]" +- Direct revelation of a specific setup/process/cost + +== BEST TWEET FORMATS == +Short (3-5 lines): reactive take, "been doing this" energy, punchy closer +Medium (8-15 lines): setup reveal with real agent names/costs/numbers +Long: step-by-step with real data, ends with the insight that makes it worth reading + +== WHAT PERFORMS BEST RIGHT NOW == +- AI agent setup reveals (specific names, models, costs) +- Prompt sharing (copy-paste value) +- Cost transparency ($140/month type hooks) +- Reactive takes on AI news with "I've been doing this for X months" framing +- Trading bot updates with real P&L numbers + +== WHAT FLOPS == +- Generic AI takes ("AI will change everything") +- No personal angle +- Info anyone could Google +- Starting with a question +- Ending with engagement bait + +== YOUR TASK == +Write ONE tweet about the trending topic. It must: +- Sound like the user wrote it, not a journalist +- Use the user's personal AI setup as the lens (their agents, their bots, their experience) +- Be sharp, specific, and feel like insider knowledge +- Be under 240 characters for short format, or a proper multi-line thread for longer +- Make people want to bookmark it or reply +`; + +export async function POST(req: Request) { + try { + const { trendId, topic, summary } = await req.json() as { trendId: string; topic: string; summary: string }; + + if (!topic) { + return NextResponse.json({ error: "topic required" }, { status: 400 }); + } + + // Load recent top tweets as live examples (from DataStore if available) + let topTweetsContext = ""; + try { + const row = await prisma.dataStore.findUnique({ where: { key: "voice-examples" } }); + if (row?.data) { + const voiceFile = typeof row.data === "string" ? row.data : JSON.stringify(row.data); + const examples = voiceFile.match(/```\n([\s\S]*?)```/g)?.slice(0, 3).join("\n\n") || ""; + if (examples) { + topTweetsContext = `\n\n== ACTUAL RECENT TOP TWEETS (use these as voice reference) ==\n${examples}`; + } + } + } catch { /* Vercel can't read local files — skip */ } + + const userMessage = `Write a viral tweet in the user's voice about this trending topic: + +TOPIC: ${topic} +WHAT'S HAPPENING: ${summary} + +Remember: connect it to the user's world (their AI agents, their trading bots, their creator journey). Don't just report the news, make it personal, specific, and sharp. Their audience wants to know what THIS means for someone already running AI agents at scale.${topTweetsContext}`; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://your-app.vercel.app", + "X-Title": "Hermy HQ Trend Sniper", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-5", + messages: [ + { role: "system", content: SAGE_SYSTEM }, + { role: "user", content: userMessage }, + ], + max_tokens: 600, + temperature: 0.85, + }), + }); + + if (!response.ok) { + const err = await response.text(); + console.error("OpenRouter error:", err); + return NextResponse.json({ error: "Failed to generate draft" }, { status: 500 }); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + const draft = data.choices?.[0]?.message?.content?.trim() || ""; + + if (!draft) { + return NextResponse.json({ error: "Empty response from model" }, { status: 500 }); + } + + // Save to drafts via Hermy HQ API + let saved = false; + try { + const saveRes = await fetch("https://your-app.vercel.app/api/x-content", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-internal-secret": process.env.INTERNAL_API_SECRET || "", + }, + body: JSON.stringify({ + text: draft, + category: "trend-snipe", + title: `Trend Snipe: ${topic}`, + }), + }); + saved = saveRes.ok; + } catch { /* save failed, draft still returned */ } + + return NextResponse.json({ draft, trendId, saved }); + + } catch (err) { + console.error("Snipe error:", err); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/api/trends/update/route.ts b/src/app/api/trends/update/route.ts new file mode 100644 index 0000000..20f61f0 --- /dev/null +++ b/src/app/api/trends/update/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +const SECRET = process.env.INTERNAL_SECRET || "296cfa48ad6963eb21ac9deb4981ad0c982cff51631bda9cf43fd0fa7217088b"; + +export async function POST(req: Request) { + const secret = req.headers.get("x-internal-secret"); + if (secret !== SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + if (!body.trends || !Array.isArray(body.trends)) { + return NextResponse.json({ error: "Invalid payload — expected { trends, lastUpdated, nextUpdate }" }, { status: 400 }); + } + + await prisma.dataStore.upsert({ + where: { key: "trend-radar" }, + update: { data: body }, + create: { key: "trend-radar", data: body }, + }); + + return NextResponse.json({ ok: true, count: body.trends.length }); + } catch (error) { + console.error("[trends/update] error:", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/api/watchlist-radar/route.ts b/src/app/api/watchlist-radar/route.ts new file mode 100644 index 0000000..6a1ed10 --- /dev/null +++ b/src/app/api/watchlist-radar/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function GET() { + try { + const row = await prisma.dataStore.findUnique({ where: { key: "watchlist-radar" } }); + if (row?.data) return NextResponse.json(row.data); + } catch { /* fallthrough */ } + return NextResponse.json({ signals: [], lastUpdated: null }); +} diff --git a/src/app/api/watchlist-radar/update/route.ts b/src/app/api/watchlist-radar/update/route.ts new file mode 100644 index 0000000..91bf1f9 --- /dev/null +++ b/src/app/api/watchlist-radar/update/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +const SECRET = "296cfa48ad6963eb21ac9deb4981ad0c982cff51631bda9cf43fd0fa7217088b"; + +export async function POST(req: Request) { + if (req.headers.get("x-internal-secret") !== SECRET) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json(); + if (!Array.isArray(body.signals)) + return NextResponse.json({ error: "Expected { signals: [] }" }, { status: 400 }); + + await prisma.dataStore.upsert({ + where: { key: "watchlist-radar" }, + update: { data: body }, + create: { key: "watchlist-radar", data: body }, + }); + + return NextResponse.json({ ok: true, count: body.signals.length }); +} diff --git a/src/app/api/x-analytics/route.ts b/src/app/api/x-analytics/route.ts new file mode 100644 index 0000000..3b2bfcc --- /dev/null +++ b/src/app/api/x-analytics/route.ts @@ -0,0 +1,157 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +/** Extract the latest metrics from either a flat metrics object or a checkpoints time-series */ +function extractMetrics(raw: unknown): { views: number; likes: number; bookmarks: number; replies: number; retweets: number } { + if (!raw || typeof raw !== "object") return { views: 0, likes: 0, bookmarks: 0, replies: 0, retweets: 0 }; + const obj = raw as Record<string, unknown>; + + // Flat metrics: { views, likes, bookmarks, replies, retweets } + if (typeof obj.views === "number" || typeof obj.likes === "number") { + return { + views: Number(obj.views) || 0, + likes: Number(obj.likes) || 0, + bookmarks: Number(obj.bookmarks) || 0, + replies: Number(obj.replies) || 0, + retweets: Number(obj.retweets) || 0, + }; + } + + // Checkpoints time-series: { "1h": {...}, "grok_2026-02-15": {...}, ... } + // Pick the checkpoint with the highest views + let best = { views: 0, likes: 0, bookmarks: 0, replies: 0, retweets: 0 }; + for (const val of Object.values(obj)) { + if (val && typeof val === "object") { + const cp = val as Record<string, unknown>; + const v = Number(cp.views) || 0; + if (v > best.views) { + best = { + views: v, + likes: Number(cp.likes) || 0, + bookmarks: Number(cp.bookmarks) || 0, + replies: Number(cp.replies) || 0, + retweets: Number(cp.retweets) || 0, + }; + } + } + } + return best; +} + +export async function GET() { + try { + // Fetch posted drafts + const dbDrafts = await prisma.draft.findMany({ + where: { status: "posted" }, + orderBy: { postedAt: "desc" }, + }); + + // Fetch all tweet metrics (includes external tweets not in drafts) + const dbMetrics = await prisma.tweetMetric.findMany(); + const existingDraftIds = new Set(dbDrafts.map((d) => d.id)); + const matchedTweetIds = new Set<string>(); + + const tweets: { + id: string; + text: string; + views: number; + likes: number; + bookmarks: number; + replies: number; + retweets: number; + engRate: number; + postedAt: Date | string | null; + tweetUrl: string | null; + }[] = []; + + // Process drafts + for (const d of dbDrafts) { + // Find matching metric for this draft + const metric = dbMetrics.find( + (m) => m.draftId === d.id || (d.tweetId && m.tweetId === d.tweetId) + ); + if (metric?.tweetId) matchedTweetIds.add(metric.tweetId); + + // Use draft.metrics if available, otherwise fall back to metric checkpoints + const rawMetrics = d.metrics ?? metric?.checkpoints; + if (!rawMetrics && !d.tweetUrl) continue; // skip if no data at all + + const { views, likes, bookmarks, replies, retweets } = extractMetrics(rawMetrics); + const engRate = + views > 0 + ? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100 + : 0; + + tweets.push({ + id: d.id, + text: d.text || "", + views, + likes, + bookmarks, + replies, + retweets, + engRate, + postedAt: d.postedAt, + tweetUrl: d.tweetUrl, + }); + } + + // Add external tweets (posted directly on X, not through Hermy HQ) + for (const m of dbMetrics) { + if (!m.tweetId || matchedTweetIds.has(m.tweetId)) continue; + const draftIdExists = m.draftId && existingDraftIds.has(m.draftId); + if (draftIdExists) continue; + + const { views, likes, bookmarks, replies, retweets } = extractMetrics(m.checkpoints); + if (views === 0 && likes === 0) continue; // skip empty entries + + const engRate = + views > 0 + ? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100 + : 0; + + tweets.push({ + id: m.draftId || `tweet-${m.tweetId}`, + text: "", + views, + likes, + bookmarks, + replies, + retweets, + engRate, + postedAt: m.postedAt, + tweetUrl: m.url, + }); + + matchedTweetIds.add(m.tweetId); + } + + // Sort by views desc + tweets.sort((a, b) => b.views - a.views); + + // Build heatmap: 7 days x 24 hours + const heatmap: Record<string, { sum: number; count: number }> = {}; + for (const t of tweets) { + if (!t.postedAt || t.views === 0) continue; + const dt = new Date(t.postedAt as string); + const key = `${dt.getUTCDay()}-${dt.getUTCHours()}`; + if (!heatmap[key]) heatmap[key] = { sum: 0, count: 0 }; + heatmap[key].sum += t.views; + heatmap[key].count++; + } + const heatmapData = Object.entries(heatmap).map(([key, v]) => { + const [day, hour] = key.split("-").map(Number); + return { + day, + hour, + avgViews: Math.round(v.sum / v.count), + count: v.count, + }; + }); + + return NextResponse.json({ tweets, heatmap: heatmapData }); + } catch { + return NextResponse.json({ tweets: [], heatmap: [] }); + } +} diff --git a/src/app/api/x-content/calendar/route.ts b/src/app/api/x-content/calendar/route.ts new file mode 100644 index 0000000..423e1f7 --- /dev/null +++ b/src/app/api/x-content/calendar/route.ts @@ -0,0 +1,19 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + // Get the most recent calendar entry + const calendar = await prisma.contentCalendar.findFirst({ + orderBy: { week: "desc" }, + }); + + if (!calendar) return NextResponse.json({ days: [] }); + + // The `data` field contains the full calendar JSON + return NextResponse.json(calendar.data); + } catch { + return NextResponse.json({ days: [] }); + } +} diff --git a/src/app/api/x-content/feedback/route.ts b/src/app/api/x-content/feedback/route.ts new file mode 100644 index 0000000..00aebe6 --- /dev/null +++ b/src/app/api/x-content/feedback/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { draftId, rating, reason, scheduledDate, scheduledTime, removeVisual, editText, tweakComment } = body; + + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (!draft) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + // Handle tweak request — save on draft + trigger Sage + if (tweakComment) { + // Note: tweakRequested is not in the Prisma schema, so we store it as part of a JSON update + // For now, just write the trigger file + // TODO: Trigger file paths won't exist on Vercel — needs a different mechanism (e.g. queue, webhook) + try { + const triggerDir = path.join("./data", "triggers"); + fs.mkdirSync(triggerDir, { recursive: true }); + fs.writeFileSync(path.join(triggerDir, `tweak-${draftId}.json`), JSON.stringify({ + type: "tweak", + draftId, + originalText: draft.text, + feedback: tweakComment, + createdAt: new Date().toISOString(), + }, null, 2)); + } catch {} + + // Return the draft in API format + const { feedbackRating, feedbackReason, updatedAt, ...rest } = draft as any; + return NextResponse.json({ + ...rest, + feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" }, + tweakRequested: { + comment: tweakComment, + requestedAt: new Date().toISOString(), + status: "pending", + }, + }); + } + + const updateData: any = {}; + + if (removeVisual) { + updateData.visualUrl = null; + } + + // If scheduling, update date and time + if (scheduledDate !== undefined) { + updateData.scheduledDate = scheduledDate; + updateData.scheduledTime = scheduledTime ?? null; + + // Move to scheduled status + if (draft.status === "approved" || draft.status === "scheduled") { + updateData.status = "scheduled"; + } + } else if (scheduledTime !== undefined) { + // Allow updating time without changing the date + updateData.scheduledTime = scheduledTime; + } + + // Unschedule functionality + if (scheduledDate === null) { + updateData.scheduledDate = null; + updateData.scheduledTime = null; + // Move back to "Approved" if was "Scheduled" + if (draft.status === "scheduled") { + updateData.status = "approved"; + } + } + + // Inline edit with history tracking + if (editText !== undefined) { + const currentHistory = (draft.editHistory as any[]) || []; + updateData.editHistory = [ + ...currentHistory, + { original: draft.text, edited: editText, editedAt: new Date().toISOString() }, + ]; + updateData.text = editText; + } + + if (rating === "undo" || rating === "pending") { + updateData.status = "pending"; + updateData.feedbackRating = null; + updateData.feedbackReason = ""; + } else if (rating === "approved" || rating === "up") { + updateData.feedbackRating = "approved"; + updateData.feedbackReason = reason || ""; + updateData.status = "approved"; + } else if (rating === "rejected" || rating === "down") { + updateData.feedbackRating = "rejected"; + updateData.feedbackReason = reason || ""; + updateData.status = "rejected"; + } else if (rating === "posted") { + updateData.status = "posted"; + updateData.postedAt = new Date(); + if (body.postedUrl) { + updateData.postedUrl = body.postedUrl; + updateData.tweetUrl = body.postedUrl; + // Extract tweet ID from URL + const match = body.postedUrl.match(/status\/(\d+)/); + if (match) { + updateData.tweetId = match[1]; + } + } + } + + const updated = await prisma.draft.update({ + where: { id: draftId }, + data: updateData, + }); + + const { feedbackRating, feedbackReason, updatedAt, ...rest } = updated as any; + return NextResponse.json({ + ...rest, + feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" }, + }); +} diff --git a/src/app/api/x-content/generate-visual/route.ts b/src/app/api/x-content/generate-visual/route.ts new file mode 100644 index 0000000..ff40a97 --- /dev/null +++ b/src/app/api/x-content/generate-visual/route.ts @@ -0,0 +1,281 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// your approved visual theme — warm cream, white cards, colorful left bars +const ACCENT_COLORS = ["#e8614d", "#5b9cf6", "#34c785", "#f5a623", "#e879a0", "#a78bfa"]; +const BG = "#f2ebe0"; +const CARD_BG = "#ffffff"; +const TITLE_COLOR = "#1a1a1a"; +const MUTED_TEXT = "#999999"; +const SUBTITLE_COLOR = "#aaaaaa"; +const FOOTER_COLOR = "#bbbbbb"; + +function esc(s: string) { + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +function wrapText(text: string, maxChars: number): string[] { + const words = text.split(" "); + const lines: string[] = []; + let cur = ""; + for (const w of words) { + if ((cur + " " + w).trim().length > maxChars) { + if (cur) lines.push(cur.trim()); + cur = w; + } else { + cur = (cur + " " + w).trim(); + } + } + if (cur) lines.push(cur.trim()); + return lines; +} + +function stripBullet(l: string): string { + return l.replace(/^(\d+[\.\)]\s*|[•\-\*→\.]\s*)/, "").trim(); +} + +export async function POST(req: NextRequest) { + const { draftId } = await req.json(); + if (!draftId) return NextResponse.json({ error: "draftId required" }, { status: 400 }); + + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (!draft) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + try { + const tweetText = draft.text || ""; + const rawLines = tweetText.split(/\n+/).map((l) => l.trim()).filter(Boolean); + + // ── Detect visual type ────────────────────────────────────────── + const isComparison = /\bvs\.?\b/i.test(tweetText) && tweetText.includes("\n"); + const listItems = rawLines.filter((l) => l.match(/^[\d•\-\*→]\s*[\.\)]?\s*/)); + const isList = listItems.length >= 3; + + // ── Shared dimensions ─────────────────────────────────────────── + const W = 1080; + const H = 1350; + const PAD = 64; + const CARD_RADIUS = 16; + const BAR_W = 6; + + let svgBody = ""; + + if (isComparison) { + // ── COMPARISON: two-column layout ──────────────────────────── + // Find "vs" line or split first/second halves + const vsIdx = rawLines.findIndex((l) => /^vs\.?$/i.test(l)); + let leftTitle = "prompting"; + let rightTitle = "delegating"; + let leftItems: string[] = []; + let rightItems: string[] = []; + + if (vsIdx > 0) { + leftTitle = rawLines[0]; + rightTitle = rawLines[vsIdx + 1] || "delegating"; + leftItems = rawLines.slice(1, vsIdx).map(stripBullet).filter(Boolean); + rightItems = rawLines.slice(vsIdx + 2).map(stripBullet).filter(Boolean); + } else { + // Try to extract from "examples" section or just split list items evenly + const allItems = rawLines.filter((l) => l.startsWith("-")).map((l) => l.replace(/^-\s*/, "").trim()); + const mid = Math.ceil(allItems.length / 2); + leftItems = allItems.slice(0, mid).map((l) => l.split(" becomes ")[0]?.replace(/^"|"$/g, "").trim()).filter(Boolean); + rightItems = allItems.slice(0, mid).map((l) => l.split(" becomes ")[1]?.replace(/^"|"$/g, "").trim()).filter(Boolean); + if (rightItems.every((r) => !r)) { + leftItems = allItems.slice(0, mid); + rightItems = allItems.slice(mid); + } + } + + // Title + const titleText = rawLines[0] || "prompting vs delegating"; + const subtitleText = "most people never make the jump"; + svgBody += `<text x="${PAD}" y="96" font-size="58" font-weight="800" fill="${TITLE_COLOR}" font-family="-apple-system,'SF Pro Display','Inter',sans-serif" letter-spacing="-2">${esc(titleText)}</text>`; + svgBody += `<text x="${PAD}" y="126" font-size="14" font-weight="600" fill="${SUBTITLE_COLOR}" font-family="-apple-system,sans-serif" letter-spacing="3" text-transform="uppercase">${esc(subtitleText.toUpperCase())}</text>`; + + // Column headers + const colW = (W - PAD * 2 - 20) / 2; + const colRightX = PAD + colW + 20; + svgBody += `<text x="${PAD}" y="174" font-size="11" font-weight="700" fill="${MUTED_TEXT}" font-family="-apple-system,sans-serif" letter-spacing="3">PROMPTING</text>`; + svgBody += `<text x="${colRightX}" y="174" font-size="11" font-weight="700" fill="#555555" font-family="-apple-system,sans-serif" letter-spacing="3">DELEGATING</text>`; + + // Rows + const maxRows = Math.max(leftItems.length, rightItems.length); + const availH = H - 240 - 80; // space for header + footer + const rowH = Math.min(Math.floor(availH / maxRows) - 12, 140); + let rowY = 194; + + for (let i = 0; i < maxRows; i++) { + const left = leftItems[i] || ""; + const right = rightItems[i] || ""; + const accentColor = ACCENT_COLORS[i % ACCENT_COLORS.length]; + + // Left card (muted, no color bar) + svgBody += `<rect x="${PAD}" y="${rowY}" width="${colW}" height="${rowH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`; + const leftLines = wrapText(left, 24); + leftLines.slice(0, 3).forEach((line, li) => { + svgBody += `<text x="${PAD + 20}" y="${rowY + 30 + li * 24}" font-size="17" fill="${MUTED_TEXT}" font-style="italic" font-family="-apple-system,sans-serif">${esc(line)}</text>`; + }); + + // Right card (colored bar) + svgBody += `<rect x="${colRightX}" y="${rowY}" width="${colW}" height="${rowH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`; + // Colored left bar (clipped to card corners) + svgBody += `<rect x="${colRightX}" y="${rowY}" width="${BAR_W}" height="${rowH}" rx="${CARD_RADIUS}" fill="${accentColor}"/>`; + svgBody += `<rect x="${colRightX}" y="${rowY}" width="${BAR_W}" height="${rowH / 2}" fill="${accentColor}"/>`; + const rightLines = wrapText(right, 26); + rightLines.slice(0, 3).forEach((line, li) => { + svgBody += `<text x="${colRightX + BAR_W + 14}" y="${rowY + 30 + li * 24}" font-size="17" fill="${TITLE_COLOR}" font-weight="500" font-family="-apple-system,sans-serif">${esc(line)}</text>`; + }); + + rowY += rowH + 12; + } + + // Footer + const footerY = H - 44; + svgBody += `<line x1="${PAD}" y1="${footerY - 18}" x2="${W - PAD}" y2="${footerY - 18}" stroke="#dddddd" stroke-width="1"/>`; + svgBody += `<text x="${PAD}" y="${footerY}" font-size="12" fill="${FOOTER_COLOR}" font-family="-apple-system,sans-serif" letter-spacing="2">SAVES MINUTES</text>`; + svgBody += `<text x="${W - PAD}" y="${footerY}" font-size="12" fill="#555555" font-weight="700" font-family="-apple-system,sans-serif" letter-spacing="2" text-anchor="end">GIVES YOU BACK HOURS</text>`; + + } else if (isList) { + // ── LIST: numbered cards with colored bars ─────────────────── + + // Separate list items from non-list lines + const items = rawLines + .filter((l) => l.match(/^[\d•\-\*→]/)) + .map(stripBullet) + .filter(Boolean) + .slice(0, 8); + + const nonListLines = rawLines.filter((l) => !l.match(/^[\d•\-\*→]/)); + + // Title = first non-list line + const headline = nonListLines[0] || rawLines[0] || ""; + // Closing line = last non-list line (if different from headline) + const closingLine = nonListLines.length > 1 ? nonListLines[nonListLines.length - 1] : ""; + + // Title block — wrap long headlines + const hlLines = wrapText(headline, 32); + const titleLineH = 56; + const titleBlockH = hlLines.slice(0, 2).length * titleLineH + 16; + let curY = 72; + + hlLines.slice(0, 2).forEach((line, i) => { + svgBody += `<text x="${PAD}" y="${curY + i * titleLineH}" font-size="48" font-weight="800" fill="${TITLE_COLOR}" font-family="-apple-system,'SF Pro Display','Inter',sans-serif" letter-spacing="-1.5">${esc(line)}</text>`; + }); + curY += titleBlockH; + + // Subtitle line (second non-list line if exists, e.g. "here's what works:") + const subtitleLine = nonListLines.length > 1 ? nonListLines[1] : ""; + if (subtitleLine && subtitleLine !== closingLine) { + const subLines = wrapText(subtitleLine, 48); + subLines.slice(0, 1).forEach((line) => { + svgBody += `<text x="${PAD}" y="${curY}" font-size="22" fill="#888888" font-family="-apple-system,sans-serif">${esc(line)}</text>`; + }); + curY += 36; + } + + // Closing line reserved height at bottom + const FOOTER_H = closingLine ? 80 : 60; + const FOOTER_Y = H - FOOTER_H; + + // Cards: cap at MAX_CARD_H so short lists don't get absurdly tall + const MAX_CARD_H = 160; + const CARD_GAP = 14; + const availH = FOOTER_Y - curY - 16; + const idealCardH = Math.floor((availH - CARD_GAP * (items.length - 1)) / items.length); + const cardH = Math.min(idealCardH, MAX_CARD_H); + + items.forEach((item, i) => { + const accentColor = ACCENT_COLORS[i % ACCENT_COLORS.length]; + const cardY = curY + i * (cardH + CARD_GAP); + const cardW = W - PAD * 2; + + // Card bg + svgBody += `<rect x="${PAD}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#e8e8e8" stroke-width="1.5"/>`; + // Colored left bar (top half rect to avoid double-radius at bottom) + svgBody += `<rect x="${PAD}" y="${cardY}" width="${BAR_W}" height="${cardH}" rx="${CARD_RADIUS}" fill="${accentColor}"/>`; + svgBody += `<rect x="${PAD}" y="${cardY + CARD_RADIUS}" width="${BAR_W}" height="${cardH - CARD_RADIUS}" fill="${accentColor}"/>`; + + // Item text — vertically centered in card + const textLines = wrapText(item, 46); + const lineH = 30; + const textBlockH = Math.min(textLines.length, 2) * lineH; + const textStartY = cardY + Math.floor((cardH - textBlockH) / 2) + lineH - 4; + + // Number badge aligned to first text line + svgBody += `<text x="${PAD + BAR_W + 18}" y="${textStartY}" font-size="13" font-weight="800" fill="${accentColor}" font-family="-apple-system,sans-serif" letter-spacing="1">${i + 1}</text>`; + + textLines.slice(0, 2).forEach((line, li) => { + svgBody += `<text x="${PAD + BAR_W + 52}" y="${textStartY + li * lineH}" font-size="22" fill="${TITLE_COLOR}" font-weight="500" font-family="-apple-system,sans-serif">${esc(line)}</text>`; + }); + }); + + // Footer: closing line + divider + svgBody += `<line x1="${PAD}" y1="${FOOTER_Y + 14}" x2="${W - PAD}" y2="${FOOTER_Y + 14}" stroke="#dddddd" stroke-width="1"/>`; + if (closingLine) { + const clLines = wrapText(closingLine, 55); + clLines.slice(0, 2).forEach((line, i) => { + svgBody += `<text x="${PAD}" y="${FOOTER_Y + 38 + i * 24}" font-size="18" fill="#555555" font-weight="600" font-family="-apple-system,sans-serif">${esc(line)}</text>`; + }); + } else { + svgBody += `<text x="${W / 2}" y="${FOOTER_Y + 38}" font-size="12" fill="${FOOTER_COLOR}" font-family="-apple-system,sans-serif" text-anchor="middle" letter-spacing="2">@YOURHANDLE</text>`; + } + + } else { + // ── INSIGHT: single quote-card layout ──────────────────────── + // Big quote card with accent bar and key lines + const lines = rawLines.slice(0, 6); + + // Title card + const titleCard = { x: PAD, y: 80, w: W - PAD * 2, h: 200 }; + svgBody += `<rect x="${titleCard.x}" y="${titleCard.y}" width="${titleCard.w}" height="${titleCard.h}" rx="20" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`; + svgBody += `<rect x="${titleCard.x}" y="${titleCard.y}" width="${BAR_W}" height="${titleCard.h}" rx="20" fill="${ACCENT_COLORS[0]}"/>`; + svgBody += `<rect x="${titleCard.x}" y="${titleCard.y}" width="${BAR_W}" height="${titleCard.h / 2}" fill="${ACCENT_COLORS[0]}"/>`; + + const firstLineWords = lines[0].split(" "); + const bigText = firstLineWords.length > 8 ? firstLineWords.slice(0, 8).join(" ") + "…" : lines[0]; + const bigLines = wrapText(bigText, 30); + bigLines.slice(0, 3).forEach((line, i) => { + svgBody += `<text x="${PAD + BAR_W + 28}" y="${titleCard.y + 56 + i * 52}" font-size="42" font-weight="800" fill="${TITLE_COLOR}" font-family="-apple-system,'SF Pro Display','Inter',sans-serif" letter-spacing="-1">${esc(line)}</text>`; + }); + + // Remaining lines as smaller cards + let bodyY = titleCard.y + titleCard.h + 20; + lines.slice(1).forEach((line, i) => { + const accentColor = ACCENT_COLORS[(i + 1) % ACCENT_COLORS.length]; + const textLines = wrapText(line, 54); + const cardH = Math.max(72, textLines.length * 26 + 36); + svgBody += `<rect x="${PAD}" y="${bodyY}" width="${W - PAD * 2}" height="${cardH}" rx="${CARD_RADIUS}" fill="${CARD_BG}" stroke="#eeeeee" stroke-width="1.5"/>`; + svgBody += `<rect x="${PAD}" y="${bodyY}" width="${BAR_W}" height="${cardH}" rx="${CARD_RADIUS}" fill="${accentColor}"/>`; + svgBody += `<rect x="${PAD}" y="${bodyY}" width="${BAR_W}" height="${cardH / 2}" fill="${accentColor}"/>`; + textLines.forEach((tl, li) => { + svgBody += `<text x="${PAD + BAR_W + 24}" y="${bodyY + 30 + li * 26}" font-size="20" fill="${TITLE_COLOR}" font-family="-apple-system,sans-serif">${esc(tl)}</text>`; + }); + bodyY += cardH + 12; + }); + + // Footer + const footerY = H - 44; + svgBody += `<line x1="${PAD}" y1="${footerY - 18}" x2="${W - PAD}" y2="${footerY - 18}" stroke="#dddddd" stroke-width="1"/>`; + svgBody += `<text x="${W / 2}" y="${footerY}" font-size="12" fill="${FOOTER_COLOR}" font-family="-apple-system,sans-serif" text-anchor="middle" letter-spacing="2">@YOURHANDLE</text>`; + } + + // ── Wrap in SVG ────────────────────────────────────────────────── + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"> + <rect width="${W}" height="${H}" fill="${BG}"/> + ${svgBody} +</svg>`; + + const svgBase64 = Buffer.from(svg).toString("base64"); + const dataUrl = `data:image/svg+xml;base64,${svgBase64}`; + + await prisma.draft.update({ + where: { id: draftId }, + data: { visualUrl: dataUrl }, + }); + + return NextResponse.json({ visualUrl: dataUrl }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Visual generation failed:", msg); + return NextResponse.json({ error: "Generation failed", details: msg }, { status: 500 }); + } +} diff --git a/src/app/api/x-content/mark-posted/route.ts b/src/app/api/x-content/mark-posted/route.ts new file mode 100644 index 0000000..418777c --- /dev/null +++ b/src/app/api/x-content/mark-posted/route.ts @@ -0,0 +1,77 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { execSync } from "child_process"; +import { prisma } from "@/lib/prisma"; + +export async function POST(req: NextRequest) { + const { draftId, tweetUrl } = await req.json(); + if (!draftId) return NextResponse.json({ error: "draftId required" }, { status: 400 }); + + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (!draft) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const updateData: any = { + status: "posted", + postedAt: new Date(), + postedUrl: tweetUrl || null, + }; + + // Try to fetch the actual posted tweet text via Grok API + if (tweetUrl) { + try { + const apiKey = process.env.XAI_API_KEY || ""; + if (apiKey) { + const payload = JSON.stringify({ + model: "grok-4-0709", + tools: [{ type: "x_search" }], + messages: [ + { + role: "user", + content: `Look up this exact tweet: ${tweetUrl}\n\nReturn ONLY the full tweet text, nothing else. No commentary, no quotes around it, just the raw tweet text exactly as posted.` + } + ] + }); + + const result = execSync( + `curl -s -X POST "https://api.x.ai/v1/responses" -H "Authorization: Bearer ${apiKey}" -H "Content-Type: application/json" -d '${payload.replace(/'/g, "'\\''")}'`, + { timeout: 30000, encoding: "utf-8" } + ); + + const data = JSON.parse(result); + // Responses API returns output_text or output array + const text = data.output_text || + data.output?.find((o: { type: string }) => o.type === "message")?.content?.[0]?.text || + null; + + if (text && text.length > 5) { + // postedText is not in the Prisma schema — include it in the response only + const updated = await prisma.draft.update({ + where: { id: draftId }, + data: updateData, + }); + + const { feedbackRating, feedbackReason, updatedAt, ...rest } = updated as any; + return NextResponse.json({ + ...rest, + feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" }, + postedText: text.trim(), + }); + } + } + } catch (e) { + console.error("Failed to fetch posted tweet:", e); + } + } + + const updated = await prisma.draft.update({ + where: { id: draftId }, + data: updateData, + }); + + const { feedbackRating, feedbackReason, updatedAt, ...rest } = updated as any; + return NextResponse.json({ + ...rest, + feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" }, + postedText: null, + }); +} diff --git a/src/app/api/x-content/request/route.ts b/src/app/api/x-content/request/route.ts new file mode 100644 index 0000000..51fd587 --- /dev/null +++ b/src/app/api/x-content/request/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +// GET: list content requests +export async function GET() { + const requests = await prisma.contentRequest.findMany({ + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(requests); +} + +// POST: create a new content request +export async function POST(req: NextRequest) { + const { prompt } = await req.json(); + if (!prompt || !prompt.trim()) { + return NextResponse.json({ error: "Prompt is required" }, { status: 400 }); + } + + const request = await prisma.contentRequest.create({ + data: { + prompt: prompt.trim(), + status: "pending", + }, + }); + + // TODO: Trigger file paths won't exist on Vercel — needs a different mechanism (e.g. queue, webhook) + try { + const triggerDir = path.join("./data", "triggers"); + fs.mkdirSync(triggerDir, { recursive: true }); + fs.writeFileSync(path.join(triggerDir, `request-${request.id}.json`), JSON.stringify({ + type: "request", + requestId: request.id, + prompt: request.prompt, + createdAt: new Date().toISOString(), + }, null, 2)); + } catch {} + + return NextResponse.json(request); +} + +// PATCH: update request status (used by Sage when processing) +export async function PATCH(req: NextRequest) { + const { requestId, status, resultDraftIds } = await req.json(); + + const existing = await prisma.contentRequest.findUnique({ where: { id: requestId } }); + if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const updateData: any = {}; + if (status) updateData.status = status; + if (resultDraftIds) updateData.resultDraftIds = resultDraftIds; + if (status === "done") updateData.completedAt = new Date(); + + const updated = await prisma.contentRequest.update({ + where: { id: requestId }, + data: updateData, + }); + + return NextResponse.json(updated); +} diff --git a/src/app/api/x-content/route.ts b/src/app/api/x-content/route.ts new file mode 100644 index 0000000..9294b44 --- /dev/null +++ b/src/app/api/x-content/route.ts @@ -0,0 +1,239 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +/** Convert a Prisma Draft row to the API shape the frontend expects. */ +function draftToApi(draft: any) { + const { feedbackRating, feedbackReason, updatedAt, ...rest } = draft; + return { + ...rest, + feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" }, + }; +} + +/** Extract the best (highest) view count from a metrics object (flat or checkpoint format). */ +function bestViews(m: any): number { + if (!m || typeof m !== "object") return 0; + if (typeof m.views === "number") return m.views; + let best = 0; + for (const val of Object.values(m)) { + if (val && typeof val === "object") { + const v = Number((val as any).views) || 0; + if (v > best) best = v; + } + } + return best; +} + +/** Merge metrics: combine both sources, preferring whichever has higher views. */ +function mergeMetrics(draftMetrics: any, tweetMetricCheckpoints: any): any { + const draftViews = bestViews(draftMetrics); + const tmViews = bestViews(tweetMetricCheckpoints); + // If both have data, merge checkpoints (draft.metrics wins on conflict) + if (draftMetrics && tweetMetricCheckpoints && typeof draftMetrics === "object" && typeof tweetMetricCheckpoints === "object") { + // Don't overwrite with lower-view data — return combined object + return { ...tweetMetricCheckpoints, ...draftMetrics }; + } + // Return whichever has more info + if (draftViews >= tmViews) return draftMetrics ?? tweetMetricCheckpoints; + return tweetMetricCheckpoints; +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const statusFilter = searchParams.get("status"); + const limitParam = searchParams.get("limit"); + const limit = limitParam ? parseInt(limitParam, 10) : undefined; + + // Build where clause for status filter + const where: any = {}; + if (statusFilter) { + where.status = statusFilter; + } + + // Fetch drafts from the database with optional filters + const dbDrafts = await prisma.draft.findMany({ + where, + orderBy: { createdAt: "desc" }, + ...(limit ? { take: limit } : {}), + }); + + // Fetch all tweet metrics + const dbMetrics = await prisma.tweetMetric.findMany(); + + // Build lookup: tweetId -> metrics, draftId -> metrics + const metricsByTweetId: Record<string, any> = {}; + const metricsByDraftId: Record<string, any> = {}; + for (const m of dbMetrics) { + if (m.tweetId) metricsByTweetId[m.tweetId] = m; + if (m.draftId) metricsByDraftId[m.draftId] = m; + } + + // Convert to API format + const drafts = dbDrafts.map(draftToApi); + + // Track which metric entries are already matched to a draft + const matchedMetricTweetIds = new Set<string>(); + const existingDraftIds = new Set(drafts.map((d: any) => d.id)); + + // Merge metrics into existing drafts + for (const draft of drafts) { + const m = metricsByDraftId[draft.id] || (draft.tweetId && metricsByTweetId[draft.tweetId]); + if (m) { + // Merge TweetMetric checkpoints with draft.metrics — keep whichever has higher views + draft.metrics = mergeMetrics(draft.metrics, (m as any).checkpoints); + if (!draft.tweetUrl && (m as any).url) draft.tweetUrl = (m as any).url; + if (!draft.postedUrl && (m as any).url) draft.postedUrl = (m as any).url; + if (!draft.tweetId && m.tweetId) draft.tweetId = m.tweetId; + if (m.tweetId) matchedMetricTweetIds.add(m.tweetId); + } + } + + // Add posted tweets that aren't in drafts yet (posted directly on X) + for (const m of dbMetrics) { + const draftIdExists = m.draftId && existingDraftIds.has(m.draftId); + if (m.tweetId && !matchedMetricTweetIds.has(m.tweetId) && !draftIdExists) { + drafts.push({ + id: m.draftId || `tweet-${m.tweetId}`, + text: "", + model: "posted-directly", + status: "posted", + type: "tweet", + feedback: { rating: "posted", reason: "" }, + createdAt: (m.postedAt || new Date()).toISOString(), + postedAt: (m.postedAt || new Date()).toISOString(), + tweetUrl: m.url, + postedUrl: m.url, + tweetId: m.tweetId, + metrics: (m as any).checkpoints, + editHistory: [], + }); + matchedMetricTweetIds.add(m.tweetId); + } + } + + return NextResponse.json(drafts, { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }); + } catch (err: any) { + console.error("GET /api/x-content error:", err); + return NextResponse.json({ error: err?.message || String(err) }, { status: 500 }); + } +} + +// POST: Create a new draft +export async function POST(req: NextRequest) { + const body = await req.json(); + + const draft = await prisma.draft.create({ + data: { + id: body.id || crypto.randomUUID(), + text: body.text || body.content || "", + title: body.title || undefined, + model: body.model || "manual", + status: body.status || "pending", + type: body.type || "tweet", + category: body.category || undefined, + hookType: body.hookType || undefined, + feedbackRating: null, + feedbackReason: "", + viralScore: body.viralScore ?? undefined, + impressionScore: body.impressionScore ?? undefined, + scheduledDate: body.scheduledDate || null, + scheduledTime: body.scheduledTime || null, + visualUrl: body.visualUrl || undefined, + variantGroup: body.variantGroup || undefined, + quoteUrl: body.quoteUrl || undefined, + quoteText: body.quoteText || undefined, + tweetId: body.tweetId || undefined, + postedUrl: body.postedUrl || undefined, + tweetUrl: body.tweetUrl || body.postedUrl || undefined, + postedAt: body.postedAt ? new Date(body.postedAt) : undefined, + metrics: body.metrics ?? undefined, + editHistory: [], + }, + }); + + return NextResponse.json(draftToApi(draft)); +} + +export async function PATCH(req: NextRequest) { + const body = await req.json(); + const { draftId, text, title, undoRejection, metrics, tweetId, postedUrl, postedAt, status } = body; + + if (!draftId) { + return NextResponse.json({ error: "draftId required" }, { status: 400 }); + } + + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (!draft) { + return NextResponse.json({ error: "Draft not found" }, { status: 404 }); + } + + const updateData: any = {}; + + if (undoRejection) { + updateData.status = "pending"; + updateData.feedbackRating = null; + updateData.feedbackReason = ""; + } + + if (status) updateData.status = status; + if (title !== undefined) updateData.title = title; + if (tweetId) updateData.tweetId = tweetId; + if (postedUrl) { updateData.postedUrl = postedUrl; updateData.tweetUrl = postedUrl; } + if (postedAt) updateData.postedAt = new Date(postedAt); + if (metrics) { + // Merge with existing metrics rather than overwrite + const existing = (draft.metrics as any) || {}; + updateData.metrics = { ...existing, ...metrics }; + } + + if (text) { + if (draft.text !== text) { + const currentHistory = (draft.editHistory as any[]) || []; + updateData.editHistory = [ + ...currentHistory, + { + original: draft.text, + edited: text, + editedAt: new Date().toISOString(), + }, + ]; + updateData.text = text; + } + } + + const updated = await prisma.draft.update({ + where: { id: draftId }, + data: updateData, + }); + + return NextResponse.json(draftToApi(updated)); +} + +export async function DELETE(req: NextRequest) { + const { draftId } = await req.json(); + + if (!draftId) { + return NextResponse.json({ error: "Draft ID required" }, { status: 400 }); + } + + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (!draft) { + return NextResponse.json({ error: "Draft not found" }, { status: 404 }); + } + + // Safety check: NEVER delete if it would result in 0 drafts + const count = await prisma.draft.count(); + if (count <= 1) { + return NextResponse.json({ + error: "Cannot delete the last remaining draft. At least one draft must remain.", + }, { status: 400 }); + } + + await prisma.draft.delete({ where: { id: draftId } }); + + return NextResponse.json({ success: true, message: "Draft deleted" }); +} diff --git a/src/app/api/x-content/top-tweets/route.ts b/src/app/api/x-content/top-tweets/route.ts new file mode 100644 index 0000000..2cff31a --- /dev/null +++ b/src/app/api/x-content/top-tweets/route.ts @@ -0,0 +1,37 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; +import fs from "fs"; + +const TWEETS_FILE = "./data/tweet-library/tweets.json"; + +export async function GET() { + try { + if (!fs.existsSync(TWEETS_FILE)) { + return NextResponse.json([]); + } + const data = JSON.parse(fs.readFileSync(TWEETS_FILE, "utf-8")); + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const tweets = (data.tweets || data || []) + .map((t: Record<string, unknown>) => ({ + text: (t.text as string) || "", + views: (t.metrics as Record<string, number>)?.views ?? (t.views as number) ?? 0, + likes: (t.metrics as Record<string, number>)?.likes ?? (t.likes as number) ?? 0, + retweets: (t.metrics as Record<string, number>)?.retweets ?? (t.retweets as number) ?? 0, + replies: (t.metrics as Record<string, number>)?.replies ?? (t.replies as number) ?? 0, + bookmarks: (t.metrics as Record<string, number>)?.bookmarks ?? (t.bookmarks as number) ?? 0, + date: (t.date as string) || "", + url: (t.url as string) || "", + })) + .filter((t: { date: string }) => { + if (!t.date) return false; + try { return new Date(t.date) >= thirtyDaysAgo; } catch { return false; } + }) + .sort((a: { views: number; likes: number }, b: { views: number; likes: number }) => (b.views || b.likes) - (a.views || a.likes)) + .slice(0, 20); + return NextResponse.json(tweets); + } catch { + return NextResponse.json([]); + } +} diff --git a/src/app/api/x-content/tweak/route.ts b/src/app/api/x-content/tweak/route.ts new file mode 100644 index 0000000..8f83b08 --- /dev/null +++ b/src/app/api/x-content/tweak/route.ts @@ -0,0 +1,146 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; +import { prisma } from "@/lib/prisma"; + +// TODO: This path won't exist on Vercel — consider bundling voice-rules or storing in DB +const VOICE_RULES = path.join("./data/tweet-library", "voice-rules.md"); + +export async function POST(req: NextRequest) { + const { draftId, comment } = await req.json(); + if (!draftId || !comment) { + return NextResponse.json({ error: "draftId and comment required" }, { status: 400 }); + } + + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (!draft) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + // Handle visual tweaks separately + if (comment.startsWith("[VISUAL]")) { + // Trigger visual regeneration + try { + const baseUrl = req.nextUrl.origin; + execSync( + `curl -s -X POST "${baseUrl}/api/x-content/generate-visual" -H "Content-Type: application/json" -d '{"draftId":"${draftId}"}'`, + { timeout: 30000 } + ); + // Re-read after visual gen + const updatedDraft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (updatedDraft) { + const { feedbackRating, feedbackReason, updatedAt, ...rest } = updatedDraft as any; + return NextResponse.json({ + ok: true, + draft: { + ...rest, + feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" }, + }, + }); + } + } catch { + return NextResponse.json({ error: "Visual generation failed" }, { status: 500 }); + } + } + + // Text tweak: use AI to rewrite + try { + const apiKey = process.env.OPENAI_API_KEY || ""; + if (!apiKey) { + return NextResponse.json({ error: "No API key" }, { status: 500 }); + } + + let voiceRules = ""; + try { + voiceRules = fs.readFileSync(VOICE_RULES, "utf-8"); + } catch { + // Gracefully degrade if voice-rules.md is not available (e.g. on Vercel) + } + + const systemPrompt = `You rewrite tweets based on user feedback. Output ONLY the rewritten tweet text, nothing else. + +Voice rules to follow: +${voiceRules} + +Key rules: +- Never use em dashes (\u2014) +- Write lowercase, casual, like texting a friend +- Use real numbers and data when present +- Keep the core idea but apply the requested changes +- Don't add generic filler or AI-sounding phrases`; + + const userPrompt = `Original tweet: +${draft.text} + +Feedback/tweak request: +${comment} + +Rewrite the tweet applying this feedback. Output ONLY the new tweet text.`; + + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt } + ], + temperature: 0.7, + max_tokens: 1000 + }), + }); + + const data = await res.json(); + const newText = data.choices?.[0]?.message?.content?.trim(); + + if (newText) { + // Re-read to avoid race conditions + const freshDraft = await prisma.draft.findUnique({ where: { id: draftId } }); + if (freshDraft) { + const currentHistory = (freshDraft.editHistory as any[]) || []; + const updated = await prisma.draft.update({ + where: { id: draftId }, + data: { + text: newText, + editHistory: [ + ...currentHistory, + { + original: freshDraft.text, + edited: newText, + editedAt: new Date().toISOString(), + }, + ], + }, + }); + + const { feedbackRating, feedbackReason, updatedAt, ...rest } = updated as any; + return NextResponse.json({ + ok: true, + draft: { + ...rest, + feedback: { rating: feedbackRating ?? null, reason: feedbackReason ?? "" }, + previousText: freshDraft.text, + tweakRequested: { comment, status: "done" }, + tweakHistory: [ + { + originalText: freshDraft.text, + comment: comment, + resultText: newText, + timestamp: new Date().toISOString(), + }, + ], + }, + }); + } + } + + return NextResponse.json({ error: "No result from AI" }, { status: 500 }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/x-content/viral-score/route.ts b/src/app/api/x-content/viral-score/route.ts new file mode 100644 index 0000000..5261a49 --- /dev/null +++ b/src/app/api/x-content/viral-score/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +// Ported directly from willitgoviral.xyz scoring algorithm +function analyzeContent(text: string) { + const signals: { name: string; status: string; description: string }[] = []; + let score = 0; + const recommendations: string[] = []; + + // Signal 1: Has a Hook + const firstSentence = text.split(/[.!?]/)[0]; + const hasHook = firstSentence.length > 0 && ( + text.toLowerCase().includes('how to') || + text.toLowerCase().includes('why') || + text.match(/\?/) || + text.toLowerCase().includes('secret') || + text.toLowerCase().includes('never') || + firstSentence.length < 100 + ); + if (hasHook) { score += 15; signals.push({ name: 'Strong Hook', status: '✅', description: 'Opens with curiosity or value' }); } + else { signals.push({ name: 'Weak Hook', status: '❌', description: 'First sentence doesn\'t grab attention' }); recommendations.push('Start with a question, bold claim, or immediate value'); } + + // Signal 2: Asks a Question + if (text.includes('?')) { score += 12; signals.push({ name: 'Asks Question', status: '✅', description: 'Questions drive replies - highest-weighted signal' }); } + else { signals.push({ name: 'No Question', status: '⚠️', description: 'Missing reply trigger' }); recommendations.push('Add a question to encourage replies'); } + + // Signal 3: Optimal Length + const wordCount = text.split(/\s+/).length; + if (wordCount >= 30 && wordCount <= 150) { score += 10; signals.push({ name: 'Good Length', status: '✅', description: `${wordCount} words - optimal dwell time` }); } + else if (wordCount < 30) { signals.push({ name: 'Too Short', status: '⚠️', description: `${wordCount} words - may not drive enough dwell time` }); recommendations.push('Aim for 30-150 words'); } + else { score += 5; signals.push({ name: 'Very Long', status: '⚠️', description: `${wordCount} words - might lose attention` }); } + + // Signal 4: Numbers/Data + if (/\d+/.test(text)) { score += 8; signals.push({ name: 'Data-Driven', status: '✅', description: 'Numbers increase credibility and shareability' }); } + else { signals.push({ name: 'No Data', status: '⚠️', description: 'Add specific numbers for trust' }); recommendations.push('Add specific numbers or data points'); } + + // Signal 5: Line Breaks + const lineBreaks = (text.match(/\n/g) || []).length; + if (lineBreaks >= 2 || wordCount < 40) { score += 8; signals.push({ name: 'Readable Format', status: '✅', description: 'Good formatting keeps people reading' }); } + else { signals.push({ name: 'Wall of Text', status: '❌', description: 'Dense text hurts dwell time' }); recommendations.push('Break into shorter paragraphs with line breaks'); } + + // Signal 6: Call-to-Action + if (text.toLowerCase().match(/reply|comment|share|repost|like|follow|click|check|read|watch|listen/)) { score += 10; signals.push({ name: 'Clear CTA', status: '✅', description: 'Tells people what to do' }); } + else { signals.push({ name: 'No CTA', status: '⚠️', description: 'No explicit call-to-action' }); recommendations.push('End with a clear call-to-action'); } + + // Signal 7: Spam Triggers + if (!text.toLowerCase().match(/buy now|click here|limited time|act now|guarantee|100%|free money|dm me/g)) { score += 12; signals.push({ name: 'Not Spammy', status: '✅', description: 'Clean of spam triggers' }); } + else { score -= 15; signals.push({ name: 'Spam Triggers', status: '❌', description: 'Contains phrases that trigger blocks' }); recommendations.push('Remove spam-like language'); } + + // Signal 8: Emotional Trigger + if (text.match(/[!]{1,3}/) || text.toLowerCase().match(/love|hate|amazing|terrible|shocking|incredible|wild|insane|genius|stupid|brilliant/)) { score += 10; signals.push({ name: 'Emotional Hook', status: '✅', description: 'Emotional language drives engagement' }); } + else { signals.push({ name: 'Neutral Tone', status: '😐', description: 'May not trigger strong reactions' }); recommendations.push('Add emotional language or stakes'); } + + // Signal 9: Mentions + if (text.includes('@')) { score += 5; signals.push({ name: 'Tags Others', status: '✅', description: 'Mentions expand reach' }); } + + // Signal 10: Thread/List + if (text.toLowerCase().match(/thread|1\/|here's|here are|\d+\s+(things|ways|reasons|tips)/)) { score += 8; signals.push({ name: 'Thread Potential', status: '✅', description: 'Lists drive sustained engagement' }); } + + // Normalize to 0-100 + score = Math.min(100, Math.max(0, score)); + + // Derive breakdown scores from signals — IMPRESSION-FIRST scoring + const hookScore = hasHook ? 8 : 3; + const emotionScore = (text.match(/[!]{1,3}/) || text.toLowerCase().match(/love|hate|amazing|terrible|shocking|incredible|wild|insane/)) ? 7 : 3; + const shareScore = (/\d+/.test(text) && lineBreaks >= 2) ? 8 : (/\d+/.test(text) ? 6 : 3); + const uniqueScore = Math.min(10, Math.round(score / 10)); // rough proxy + + // Impression-first scoring (weighted: impressions 50%, bookmarks 30%, engagement 20%) + const impressionScore = Math.round( + hookScore * 5 + // Hook = scroll stop = impressions (50% weight) + shareScore * 3 + // Shareability/bookmarks (30% weight) + emotionScore * 1 + // Emotion drives replies/likes (10% weight) + uniqueScore * 1 // Uniqueness (10% weight) + ); + + return { score, impressionScore: Math.min(100, impressionScore), signals, recommendations, breakdown: { hook: hookScore, emotion: emotionScore, shareability: shareScore, uniqueness: uniqueScore } }; +} + +const REWRITE_PROMPT = `You are a viral tweet optimization agent based on X's algorithm signals. + +The X algorithm scores tweets on: P(like), P(reply), P(repost), P(quote), P(click), P(dwell), P(share), P(follow_author). + +Negative signals: P(not_interested), P(block_author), P(mute_author). + +Key optimization levers: +1. Hook Engineering: first 7 words must pattern-interrupt or create curiosity gap +2. Emotional Resonance: map to high-arousal emotions (awe, surprise, validation, FOMO) +3. Reply Maximization: questions, hot takes, intentional incompleteness +4. Repost Psychology: make sharing identity-reinforcing + +Given the original tweet and its issues, provide exactly 3 improved rewrites that would score higher while keeping the same core message and the author's casual, lowercase voice. + +Return ONLY a JSON array of 3 strings: ["rewrite1", "rewrite2", "rewrite3"]`; + +export async function POST(req: NextRequest) { + try { + const { text } = await req.json(); + if (!text) return NextResponse.json({ error: "No text provided" }, { status: 400 }); + + // Step 1: Local scoring (instant, free) + const analysis = analyzeContent(text); + + // Step 2: AI rewrites (if API key available) + let suggestions: string[] = []; + const apiKey = process.env.OPENAI_API_KEY; + if (apiKey) { + try { + const issueList = analysis.signals + .filter(s => s.status !== '✅') + .map(s => `- ${s.name}: ${s.description}`) + .join('\n'); + + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: REWRITE_PROMPT }, + { role: "user", content: `Original tweet:\n${text}\n\nIssues found:\n${issueList}\n\nRecommendations:\n${analysis.recommendations.join('\n')}\n\nProvide 3 improved rewrites as a JSON array.` }, + ], + temperature: 0.8, + }), + }); + const data = await res.json(); + const content = data.choices?.[0]?.message?.content || "[]"; + suggestions = JSON.parse(content.replace(/```json?\n?/g, '').replace(/```/g, '').trim()); + } catch (e) { + console.error("Rewrite error:", e); + suggestions = analysis.recommendations.slice(0, 3); + } + } else { + suggestions = analysis.recommendations.slice(0, 3); + } + + return NextResponse.json({ + score: analysis.score, + breakdown: analysis.breakdown, + signals: analysis.signals, + reasoning: analysis.recommendations.length > 0 + ? `Issues: ${analysis.signals.filter(s => s.status !== '✅').map(s => s.name).join(', ')}` + : 'Strong across all algorithm signals!', + suggestions, + }); + } catch (e) { + return NextResponse.json({ error: "Scoring failed", details: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/x-content/visual/route.ts b/src/app/api/x-content/visual/route.ts new file mode 100644 index 0000000..c3614a0 --- /dev/null +++ b/src/app/api/x-content/visual/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = 'force-dynamic'; + +const PUBLIC_VISUAL_DIR = path.join(process.cwd(), "public", "draft-visuals"); + +// Ensure the directory exists +if (!fs.existsSync(PUBLIC_VISUAL_DIR)) { + fs.mkdirSync(PUBLIC_VISUAL_DIR, { recursive: true }); +} + +export async function POST(req: NextRequest) { + // Get the multipart/form-data + const formData = await req.formData(); + const file = formData.get('file') as File; + const draftId = formData.get('draftId') as string; + + if (!file || !draftId) { + return NextResponse.json({ error: "Missing file or draftId" }, { status: 400 }); + } + + // Check file size (10MB max) + if (file.size > 10 * 1024 * 1024) { + return NextResponse.json({ error: "File too large. Max 10MB." }, { status: 400 }); + } + + // Check file type + const validTypes = ['image/png', 'image/jpeg']; + if (!validTypes.includes(file.type)) { + return NextResponse.json({ error: "Invalid file type. PNG or JPG only." }, { status: 400 }); + } + + try { + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + + if (!draft) { + return NextResponse.json({ error: "Draft not found" }, { status: 404 }); + } + + // Determine file extension + const ext = file.type === 'image/png' ? '.png' : '.jpg'; + const newFilename = `${draftId}${ext}`; + const newPath = path.join(PUBLIC_VISUAL_DIR, newFilename); + + // Write file + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + fs.writeFileSync(newPath, buffer); + + // Update the draft with the visual URL + const visualUrl = `/draft-visuals/${newFilename}`; + await prisma.draft.update({ + where: { id: draftId }, + data: { visualUrl }, + }); + + return NextResponse.json({ visualUrl }, { status: 201 }); + } catch (error) { + console.error("Visual upload error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + const { draftId } = await req.json(); + + try { + const draft = await prisma.draft.findUnique({ where: { id: draftId } }); + + if (!draft) { + return NextResponse.json({ error: "Draft not found" }, { status: 404 }); + } + + // Remove the file if it exists + if (draft.visualUrl) { + const filename = path.basename(draft.visualUrl); + const filePath = path.join(PUBLIC_VISUAL_DIR, filename); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + // Clear the visualUrl in the draft + await prisma.draft.update({ + where: { id: draftId }, + data: { visualUrl: null }, + }); + } + + return NextResponse.json({ message: "Visual removed successfully" }, { status: 200 }); + } catch (error) { + console.error("Visual delete error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/youtube-scrape/route.ts b/src/app/api/youtube-scrape/route.ts new file mode 100644 index 0000000..d4a88dd --- /dev/null +++ b/src/app/api/youtube-scrape/route.ts @@ -0,0 +1,94 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + try { + const { url } = await req.json(); + if (!url) return NextResponse.json({ error: "url required" }, { status: 400 }); + + const match = url.match(/(?:v=|youtu\.be\/|\/shorts\/)([a-zA-Z0-9_-]{11})/); + if (!match) return NextResponse.json({ error: "Invalid YouTube URL" }, { status: 400 }); + const videoId = match[1]; + + // Try YouTube Data API v3 if key available + const ytKey = process.env.YOUTUBE_API_KEY; + if (ytKey) { + const apiRes = await fetch( + `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&part=snippet,statistics&key=${ytKey}`, + { cache: "no-store" } + ); + if (apiRes.ok) { + const data = await apiRes.json(); + const item = data.items?.[0]; + if (item) { + return NextResponse.json({ + title: item.snippet?.title || "", + description: item.snippet?.description || "", + thumbnail: item.snippet?.thumbnails?.maxres?.url || item.snippet?.thumbnails?.high?.url || `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`, + duration: "", + viewCount: parseInt(item.statistics?.viewCount || "0"), + likeCount: parseInt(item.statistics?.likeCount || "0"), + commentCount: parseInt(item.statistics?.commentCount || "0"), + transcript: "", + }); + } + } + } + + // Fallback: oEmbed + page scrape + let title = ""; + let thumbnail = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; + let viewCount = 0; + let likeCount = 0; + let description = ""; + + try { + const oembed = await fetch( + `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`, + { cache: "no-store" } + ); + if (oembed.ok) { + const d = await oembed.json(); + title = d.title || ""; + thumbnail = d.thumbnail_url || thumbnail; + } + } catch { /* ok */ } + + try { + const pageRes = await fetch( + `https://www.youtube.com/watch?v=${videoId}`, + { + headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)" }, + cache: "no-store", + } + ); + if (pageRes.ok) { + const html = await pageRes.text(); + const vcMatch = html.match(/"viewCount"\s*:\s*"(\d+)"/); + if (vcMatch) viewCount = parseInt(vcMatch[1]); + const lcMatch = html.match(/"likeCount"\s*:\s*"(\d+)"/); + if (lcMatch) likeCount = parseInt(lcMatch[1]); + const descMatch = html.match(/"shortDescription"\s*:\s*"((?:[^"\\]|\\.)*)"/); + if (descMatch) description = descMatch[1].replace(/\\n/g, "\n").replace(/\\"/g, '"'); + if (!title) { + const tMatch = html.match(/<title>([^<]+)<\/title>/); + if (tMatch) title = tMatch[1].replace(" - YouTube", "").trim(); + } + } + } catch { /* ok */ } + + return NextResponse.json({ + title, + description, + thumbnail, + duration: "", + viewCount, + likeCount, + commentCount: 0, + transcript: "", + }); + } catch (e) { + console.error("youtube-scrape error:", e); + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/youtube/generate/route.ts b/src/app/api/youtube/generate/route.ts new file mode 100644 index 0000000..b0ea822 --- /dev/null +++ b/src/app/api/youtube/generate/route.ts @@ -0,0 +1,289 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; + +function callOpenAI(apiKey: string, payload: object, timeout = 45000): string { + const tmpFile = path.join("/tmp", `yt-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(tmpFile, JSON.stringify(payload)); + try { + return execSync( + `curl -s -X POST "https://api.openai.com/v1/chat/completions" -H "Authorization: Bearer ${apiKey}" -H "Content-Type: application/json" -d @${tmpFile}`, + { timeout, encoding: "utf-8" } + ); + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ok */ } + } +} + +function braveSearch(query: string, braveKey: string, count = 5): { title: string; url: string; description: string }[] { + try { + const searchUrl = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`; + const res = execSync( + `curl -s "${searchUrl}" -H "Accept: application/json" -H "X-Subscription-Token: ${braveKey}"`, + { timeout: 10000, encoding: "utf-8" } + ); + const data = JSON.parse(res); + return (data.web?.results || []).map((r: any) => ({ title: r.title, url: r.url, description: r.description })); + } catch { + return []; + } +} + +function fetchArticle(url: string): string { + try { + // Fetch the page and extract text content (strip HTML tags, limit to 3000 chars) + const raw = execSync( + `curl -s -L --max-time 10 "${url}" | sed 's/<script[^>]*>.*<\\/script>//g; s/<style[^>]*>.*<\\/style>//g; s/<[^>]*>//g' | head -c 5000`, + { timeout: 15000, encoding: "utf-8" } + ); + // Clean up whitespace + return raw.replace(/\s+/g, " ").trim().slice(0, 3000); + } catch { + return ""; + } +} + +export async function POST(req: NextRequest) { + const { topic, sourceUrl, sourceSummary } = await req.json(); + if (!topic) return NextResponse.json({ error: "topic required" }, { status: 400 }); + + const apiKey = process.env.OPENAI_API_KEY || ""; + const braveKey = process.env.BRAVE_API_KEY || ""; + if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 }); + + // Load reference materials + let johnPrompt = ""; + try { johnPrompt = fs.readFileSync("./data/john-prompt.md", "utf-8"); } catch { /* ok */ } + + // Load past scripts as style/topic reference + let pastScriptsRef = ""; + try { + const raw = fs.readFileSync("./data/scripts-full.md", "utf-8"); + pastScriptsRef = `\n\n## PAST SCRIPTS (study these for tone, topic variety, and style) +Here are 50+ scripts the user already filmed. Notice the WIDE variety. Most are mainstream brands (Red Bull, Nike, Barbie, Tesla, McDonald's, Apple, etc.) with a business/marketing lesson. NEW scripts should follow this same pattern: mainstream hook, then founder lesson. +${raw.slice(0, 3000)}`; + } catch { /* ok */ } + + // Load ALL feedback (rejections + approvals) to learn from + let feedbackContext = ""; + try { + const feedback = JSON.parse(fs.readFileSync("./data/youtube-feedback.json", "utf-8")); + const scriptFeedback = feedback.filter((f: { type?: string }) => f.type === "script" || !f.type); + if (scriptFeedback.length > 0) { + const rejections = scriptFeedback.filter((f: { reason?: string }) => f.reason).slice(-20); + feedbackContext = `\n\n## CRITICAL: LEARN FROM PAST FEEDBACK +The user has reviewed scripts before. Here's what they rejected and WHY. Do NOT repeat these mistakes: +${rejections.map((f: { title?: string; reason?: string }) => `- REJECTED "${f.title}": ${f.reason}`).join("\n")} + +Key patterns from feedback: +- Don't sound "too AI" or "too polished" — write like a real person talking +- Always name the SPECIFIC brand/person — never be vague ("a company", "one firm") +- Every claim must be real and verifiable — no made-up stories +- Don't reference old/stale trends as if they're new +- No generic motivational endings or "download my free guide" CTAs +- The story must have substance and real value, not just a clickbait hook`; + } + } catch { /* ok */ } + + // ── STEP 1: Gather real source material BEFORE writing ── + const sourceUrls: string[] = []; + let sourceContext = ""; + + // 1a. Fetch the primary source article if we have a sourceUrl + if (sourceUrl) { + sourceUrls.push(sourceUrl); + const articleText = fetchArticle(sourceUrl); + if (articleText) { + sourceContext += `\n\n## PRIMARY SOURCE ARTICLE (from ${sourceUrl}):\n${articleText}`; + } + } + + // 1b. Generate search queries for additional context + const keyClaimsResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "Extract 3 specific search queries to verify and gather more context about this video topic. Return JSON: {\"queries\":[\"query1\",\"query2\",\"query3\"]}" }, + { role: "user", content: `Topic: ${topic}${sourceSummary ? `\nSource summary: ${sourceSummary}` : ""}` } + ], + temperature: 0.3, + response_format: { type: "json_object" } + }, 15000); + + let additionalQueries: string[] = []; + try { + const kc = JSON.parse(keyClaimsResult); + additionalQueries = JSON.parse(kc.choices[0].message.content).queries || []; + } catch { /* ok */ } + + // 1c. Run additional Brave searches + for (const query of additionalQueries.slice(0, 3)) { + if (!braveKey) break; + const results = braveSearch(query, braveKey, 3); + if (results.length > 0) { + sourceContext += `\n\n## SEARCH: "${query}"\n${results.map(r => { + sourceUrls.push(r.url); + return `- ${r.title} (${r.url})\n ${r.description}`; + }).join("\n")}`; + } + } + + // ── STEP 2: Generate script with REAL source material ── + const systemPrompt = `You are a viral short-form scriptwriter for the user, a founder and content creator. + +## YOUR PERSONALITY (from John) +${johnPrompt.slice(0, 1500)} + +## CRITICAL: FACT-FIRST APPROACH +You have been given REAL source material gathered from web searches and articles. +- ONLY use facts that appear in the source material below +- DO NOT add any facts, numbers, dates, quotes, or claims not supported by the sources +- If you need to generalize because exact data isn't available, use hedging language ("reportedly", "around", "roughly") +- If the sources don't have enough detail for a section, keep it shorter rather than inventing details + +## SOURCE MATERIAL (verified): +${sourceContext || "No sources available — be EXTRA cautious with claims, hedge everything."} + +## SCRIPT RULES +- Target length: 1-2 minutes when read aloud (~150-280 words) +- Written as SPOKEN WORD but WITH normal punctuation (commas, periods, question marks). +- Every line earns its place. Zero filler. +- Use the Hero's Journey: Hook → Story/Setup → Conflict/Pivot → Insight/Payoff → CTA → Caption +- Reference REAL pop culture brands/people/stories — must be factually accurate +- Always tie back to a lesson for founders, creators, or builders + +## HOOK RULES +- TOF hooks: Wide appeal, recognizable products/brands, desired outcomes +- "I did X, unexpected Y happened" = powerful hook format +- Call out common mistakes or counter common assumptions +- Use cultural icons, pop brands, unexpected comparisons +- Start like a viral rant — deliver massive insight up top + +## SCRIPT STRUCTURE +1. HOOK — Scroll-stopping (first 3 seconds decide everything) +2. STORY SETUP — Context, backstory, make viewer invested +3. CONFLICT — Create tension, counter assumptions +4. INSIGHT/PAYOFF — The aha moment, the lesson +5. CTA — ONE clear action only +6. CAPTION — One-line summary without spoilers + +## FACTUAL ACCURACY +- Include a "factClaims" array listing every specific factual claim in the script (dates, numbers, events, quotes) +- Each claim MUST be traceable to the source material above + +## THE USER'S ANGLE +- Runs a marketing-focused business +- Content: founder branding, marketing, distribution > product, culture > features +- Voice: conversational, no fancy words, lowercase energy, real numbers, honest/vulnerable +${pastScriptsRef} +${feedbackContext} + +OUTPUT FORMAT — valid JSON only: +{ + "title": "Brand/Person — Core Lesson (short)", + "hook": "Opening 2-4 lines. Scroll-stopping.", + "storySetup": "The backstory context. 3-5 sentences.", + "conflict": "The tension pivot or controversial statement. 3-5 sentences.", + "insight": "The aha moment payoff lesson for founders. 3-5 sentences.", + "cta": "One clear action. One line.", + "caption": "One punchy line without spoilers.", + "onScreenText": "What appears on screen during the hook", + "hookType": "observation|experience|expert|controversial", + "funnelStage": "TOF|MOF|BOF", + "factClaims": ["Claim 1: specific fact used in script", "Claim 2: ..."] +}`; + + const genResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: `Write a 1-2 minute viral short-form video script about: ${topic}\n\nMake the hook FIRE. Make every line earn its place. ONLY use facts from the provided source material.` } + ], + temperature: 0.85, + response_format: { type: "json_object" } + }, 60000); + + const genData = JSON.parse(genResult); + const script = JSON.parse(genData.choices[0].message.content); + const factClaims: string[] = script.factClaims || []; + delete script.factClaims; + + // ── STEP 3: Fact-check via web search (safety net) ── + let factCheck: { status: string; issues: string[]; verified: string[]; sourceUrls: string[] } = { + status: "✅", issues: [], verified: [], sourceUrls: [...new Set(sourceUrls)] + }; + + if (factClaims.length > 0) { + try { + const searchResults: string[] = []; + for (const claim of factClaims.slice(0, 5)) { + try { + const results = braveSearch(claim, braveKey, 3); + const snippets = results.map(r => `${r.title}: ${r.description}`).join(" | "); + searchResults.push(`Claim: ${claim}\nSearch results: ${snippets || "No results found"}`); + } catch { + searchResults.push(`Claim: ${claim}\nSearch results: SEARCH FAILED — mark as uncertain`); + } + } + + const checkResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: `You are a strict fact-checker. You will receive claims paired with REAL web search results. Use ONLY the search results to determine if each claim is CORRECT, INCORRECT, or UNCERTAIN. Do NOT rely on your own knowledge — only what the search results show. Be very strict: if search results don't clearly confirm a claim, mark it UNCERTAIN. + +Return JSON: { "results": [{ "claim": "...", "verdict": "correct|incorrect|uncertain", "correction": "only if incorrect/uncertain, what the real fact is based on search results" }] }` }, + { role: "user", content: `Fact-check these claims using the provided search results:\n\n${searchResults.join("\n\n")}` } + ], + temperature: 0, + response_format: { type: "json_object" } + }, 30000); + + const checkData = JSON.parse(checkResult); + const checks = JSON.parse(checkData.choices[0].message.content); + + for (const r of checks.results || []) { + if (r.verdict === "correct") { + factCheck.verified.push(r.claim); + } else { + factCheck.issues.push(`${r.verdict.toUpperCase()}: ${r.claim}${r.correction ? ` → ${r.correction}` : ""}`); + } + } + + if (factCheck.issues.length > 0) factCheck.status = "⚠️"; + + // ── STEP 4: If issues found, regenerate with corrections ── + if (factCheck.issues.length > 0) { + const fixResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "You fix factual errors in video scripts. Return the SAME JSON structure with corrected facts. Keep the style, voice, and structure identical — only fix the wrong facts." }, + { role: "user", content: `Here's a script with factual issues:\n${JSON.stringify(script)}\n\nIssues found:\n${factCheck.issues.join("\n")}\n\nFix ONLY the factual errors. Return valid JSON with same keys: title, hook, storySetup, conflict, insight, cta, caption, onScreenText, hookType, funnelStage` } + ], + temperature: 0.3, + response_format: { type: "json_object" } + }, 30000); + + const fixData = JSON.parse(fixResult); + const fixed = JSON.parse(fixData.choices[0].message.content); + Object.assign(script, fixed); + factCheck.status = "🔧"; + } + } catch { + factCheck.status = "❓"; + factCheck.issues.push("Fact-check failed — review manually"); + } + } + + return NextResponse.json({ + id: `gen-${Date.now()}`, + ...script, + factCheck: { + status: factCheck.status, + verified: factCheck.verified.length, + issues: factCheck.issues, + sourceUrls: factCheck.sourceUrls, + }, + status: "draft", + createdAt: new Date().toISOString().split("T")[0], + }); +} diff --git a/src/app/api/youtube/ideas/dismiss/route.ts b/src/app/api/youtube/ideas/dismiss/route.ts new file mode 100644 index 0000000..c40fc27 --- /dev/null +++ b/src/app/api/youtube/ideas/dismiss/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function POST(req: NextRequest) { + const { title } = await req.json(); + if (!title) return NextResponse.json({ error: "title required" }, { status: 400 }); + + // Delete ideas matching the title + const result = await prisma.youtubeIdea.deleteMany({ + where: { title }, + }); + + // Count remaining ideas + const remaining = await prisma.youtubeIdea.count(); + + return NextResponse.json({ remaining }); +} diff --git a/src/app/api/youtube/ideas/generate/route.ts b/src/app/api/youtube/ideas/generate/route.ts new file mode 100644 index 0000000..984c9e5 --- /dev/null +++ b/src/app/api/youtube/ideas/generate/route.ts @@ -0,0 +1,161 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import { execSync } from "child_process"; +import path from "path"; +import { prisma } from "@/lib/prisma"; + +const SCRIPTS_FILE = "./data/scripts-full.md"; + +function callOpenAI(apiKey: string, payload: object, timeout = 45000): string { + const tmpFile = path.join("/tmp", `yt-ideas-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(tmpFile, JSON.stringify(payload)); + try { + return execSync( + `curl -s -X POST "https://api.openai.com/v1/chat/completions" -H "Authorization: Bearer ${apiKey}" -H "Content-Type: application/json" -d @${tmpFile}`, + { timeout, encoding: "utf-8" } + ); + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ok */ } + } +} + +function braveSearch(query: string, braveKey: string, count = 5): { title: string; url: string; description: string }[] { + try { + const searchUrl = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`; + const res = execSync( + `curl -s "${searchUrl}" -H "Accept: application/json" -H "X-Subscription-Token: ${braveKey}"`, + { timeout: 10000, encoding: "utf-8" } + ); + const data = JSON.parse(res); + return (data.web?.results || []).map((r: any) => ({ title: r.title, url: r.url, description: r.description })); + } catch { + return []; + } +} + +export async function POST() { + const apiKey = process.env.OPENAI_API_KEY || ""; + const braveKey = process.env.BRAVE_API_KEY || ""; + if (!apiKey) return NextResponse.json({ error: "No API key" }, { status: 500 }); + if (!braveKey) return NextResponse.json({ error: "No Brave API key" }, { status: 500 }); + + // Load existing ideas to avoid duplicates + let existing: { id: string; title: string; status: string }[] = []; + try { + existing = await prisma.youtubeIdea.findMany({ + select: { id: true, title: true, status: true }, + }); + } catch { /* ok */ } + const existingTitles = existing.map(i => i.title).join(", "); + + // Load rejection feedback to learn taste + let feedback: { title: string; reason: string | null }[] = []; + try { + feedback = await prisma.youtubeFeedback.findMany({ + where: { type: "idea" }, + select: { title: true, reason: true }, + }); + } catch { /* ok */ } + const rejectionContext = feedback.length > 0 + ? `\n\nIDEAS THE USER REJECTED (learn from this):\n${feedback.map(f => `- "${f.title}" — reason: ${f.reason}`).join("\n")}` + : ""; + + // Load existing scripts for covered topics (keep .md file read, wrap in try/catch for Vercel) + let coveredTopics = ""; + try { coveredTopics = fs.readFileSync(SCRIPTS_FILE, "utf-8").match(/### .+/g)?.join(", ") || ""; } catch { /* ok - file may not exist on Vercel */ } + + // -- STEP 1: Ask GPT to generate search queries -- + const queryGenResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: `You generate search queries to find fascinating REAL stories about brands, businesses, and cultural moments for a viral short-form video creator. + +The creator covers a WIDE mix: mainstream brands (Red Bull, Nike, Barbie, Tesla, Apple, McDonald's, etc.), tech, pop culture, and business strategy. Most videos are about mainstream brands with a marketing/founder lesson. + +Generate 10 diverse search queries that will find REAL, fascinating, lesser-known stories. Mix: +- 6-7 mainstream brand/business/pop culture stories +- 2-3 crypto/web3 stories +- Go for "WAIT REALLY?" stories — surprising pivots, near-failures, hidden strategies, controversial decisions + +DO NOT search for stories already covered: ${coveredTopics} +DO NOT search for ideas already generated: ${existingTitles} +${rejectionContext} + +Output JSON: {"queries":["search query 1","search query 2",...]}` }, + { role: "user", content: "10 diverse search queries for fascinating real stories. Mix mainstream brands, crypto, pop culture." } + ], + temperature: 0.95, + response_format: { type: "json_object" } + }); + + const queryData = JSON.parse(queryGenResult); + const queries: string[] = JSON.parse(queryData.choices[0].message.content).queries || []; + + // -- STEP 2: Run each query through Brave Search -- + const allSearchResults: { query: string; results: { title: string; url: string; description: string }[] }[] = []; + for (const query of queries.slice(0, 10)) { + const results = braveSearch(query, braveKey, 5); + allSearchResults.push({ query, results }); + } + + // Compile search results into a readable format + const searchContext = allSearchResults.map(sr => + `Query: "${sr.query}"\n${sr.results.map(r => `- ${r.title} (${r.url})\n ${r.description}`).join("\n")}` + ).join("\n\n"); + + // -- STEP 3: Feed real articles to GPT to create ideas -- + const ideaGenResult = callOpenAI(apiKey, { + model: "gpt-4o-mini", + messages: [ + { role: "system", content: `You create viral short-form video ideas for the user based on REAL articles and stories found via web search. + +CRITICAL RULES: +- Each idea MUST be based on a REAL story from the search results below +- Each idea MUST include a sourceUrl from the search results +- Each idea MUST include a sourceSummary (1-2 sentence summary of the real story) +- DO NOT invent or embellish stories beyond what the sources say +- Hook must stop the scroll in 3 seconds +- Use proven hook formats: "I did X unexpected Y happened", observation hooks, desired outcome hooks, counter-assumption hooks +- Mix funnel stages: 5 TOF (wide appeal), 3 MOF (expertise), 2 BOF (authority) +- MAX 2-3 crypto-related ideas out of 10. Rest should be mainstream brands/culture. +- DO NOT repeat these existing ideas: ${existingTitles} +- DO NOT use stories already covered: ${coveredTopics} +- Each hook should have normal punctuation (commas, periods) +- Always name the SPECIFIC brand/person — never be vague +${rejectionContext} + +Output JSON: {"ideas":[{"title":"short title","hook":"2-3 line spoken word scroll-stopper","angle":"founder/crypto lesson","hookType":"observation|experience|expert|controversial","funnelStage":"TOF|MOF|BOF","sourceUrl":"url from search results","sourceSummary":"1-2 sentence summary of the real story"}]}` }, + { role: "user", content: `Based on these REAL articles found via web search, create 10 video ideas. Each idea MUST reference a real story and include the source URL.\n\nSEARCH RESULTS:\n${searchContext}\n\nMake the hooks FIRE.` } + ], + temperature: 0.85, + response_format: { type: "json_object" } + }, 60000); + + try { + const data = JSON.parse(ideaGenResult); + const content = JSON.parse(data.choices[0].message.content); + const newIdeas = content.ideas || content; + + // Append to existing (only truly new ones) + const existingSet = new Set(existing.map(i => i.title)); + const fresh = newIdeas.filter((i: { title: string }) => !existingSet.has(i.title)); + + // Insert fresh ideas into the database + for (const idea of fresh) { + await prisma.youtubeIdea.create({ + data: { + title: idea.title, + hook: idea.hook || null, + angle: idea.angle || null, + hookType: idea.hookType || null, + funnelStage: idea.funnelStage || null, + status: "pending", + }, + }); + } + + return NextResponse.json({ added: fresh.length }); + } catch (e) { + return NextResponse.json({ error: "Failed", details: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/youtube/ideas/route.ts b/src/app/api/youtube/ideas/route.ts new file mode 100644 index 0000000..c860616 --- /dev/null +++ b/src/app/api/youtube/ideas/route.ts @@ -0,0 +1,49 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + const ideas = await prisma.youtubeIdea.findMany({ + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(ideas, { + headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, + }); + } catch { + return NextResponse.json([]); + } +} + +/** Bulk-import or create a single idea. Requires internal secret header. */ +export async function POST(req: NextRequest) { + const secret = req.headers.get("x-internal-secret"); + if (secret !== process.env.INTERNAL_API_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const items: any[] = Array.isArray(body) ? body : [body]; + + // Fetch existing titles to deduplicate + const existing = await prisma.youtubeIdea.findMany({ select: { title: true } }); + const existingTitles = new Set(existing.map((i) => i.title)); + + const fresh = items.filter((i) => i.title && !existingTitles.has(i.title)); + + for (const idea of fresh) { + await prisma.youtubeIdea.create({ + data: { + title: idea.title, + hook: idea.hook || null, + angle: idea.angle || null, + hookType: idea.hookType || null, + funnelStage: idea.funnelStage || null, + status: idea.status || "pending", + rejectedReason: idea.rejectedReason || null, + }, + }); + } + + return NextResponse.json({ added: fresh.length, skipped: items.length - fresh.length }); +} diff --git a/src/app/api/youtube/ideas/update/route.ts b/src/app/api/youtube/ideas/update/route.ts new file mode 100644 index 0000000..a980e55 --- /dev/null +++ b/src/app/api/youtube/ideas/update/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function POST(req: NextRequest) { + const { title, status, rejectedReason } = await req.json(); + if (!title) return NextResponse.json({ error: "title required" }, { status: 400 }); + + // Update the idea by title + await prisma.youtubeIdea.updateMany({ + where: { title }, + data: { + status: status || "rejected", + rejectedReason: rejectedReason || undefined, + }, + }); + + // Also save feedback to learning table + if (status === "rejected" && rejectedReason) { + await prisma.youtubeFeedback.create({ + data: { + title, + reason: rejectedReason, + type: "idea", + }, + }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/youtube/outliers/route.ts b/src/app/api/youtube/outliers/route.ts new file mode 100644 index 0000000..f136976 --- /dev/null +++ b/src/app/api/youtube/outliers/route.ts @@ -0,0 +1,27 @@ +export const dynamic = 'force-dynamic'; + +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + // Try DataStore first (synced by scanner) + const row = await prisma.dataStore.findUnique({ where: { key: "youtube-outliers" } }); + if (row?.data) { + return NextResponse.json(row.data); + } + + return NextResponse.json({ + videos: [], + totalOutliers: 0, + nicheCounts: {}, + scannedAt: null, + error: 'No outlier data synced yet.', + }); + } catch (err) { + return NextResponse.json( + { error: 'Failed to read outlier data', details: String(err) }, + { status: 500 } + ); + } +} diff --git a/src/app/api/youtube/performance/route.ts b/src/app/api/youtube/performance/route.ts new file mode 100644 index 0000000..860a6b8 --- /dev/null +++ b/src/app/api/youtube/performance/route.ts @@ -0,0 +1,129 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; + +const CHANNEL_ID = process.env.YOUTUBE_CHANNEL_ID || ""; + +function parseIsoDuration(iso: string): number { + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return 0; + return (parseInt(match[1] || "0") * 3600) + (parseInt(match[2] || "0") * 60) + parseInt(match[3] || "0"); +} + +async function getAllVideos(ytKey: string) { + const videos: { + id: string; + title: string; + thumbnail: string; + publishedAt: string; + views: number; + likes: number; + comments: number; + url: string; + isShort: boolean; + durationSecs: number; + }[] = []; + + let pageToken = ""; + let page = 0; + + do { + page++; + if (page > 10) break; // safety cap + + const searchUrl = + `https://www.googleapis.com/youtube/v3/search` + + `?channelId=${CHANNEL_ID}&part=snippet&type=video&maxResults=50&order=date` + + (pageToken ? `&pageToken=${pageToken}` : "") + + `&key=${ytKey}`; + + const searchRes = await fetch(searchUrl, { next: { revalidate: 3600 } }); + if (!searchRes.ok) break; + const searchData = await searchRes.json(); + + const ids: string[] = (searchData.items || []) + .map((i: { id?: { videoId?: string } }) => i.id?.videoId) + .filter(Boolean); + + if (ids.length > 0) { + const statsUrl = + `https://www.googleapis.com/youtube/v3/videos` + + `?id=${ids.join(",")}&part=snippet,statistics,contentDetails&key=${ytKey}`; + const statsRes = await fetch(statsUrl, { next: { revalidate: 3600 } }); + if (statsRes.ok) { + const statsData = await statsRes.json(); + for (const item of statsData.items || []) { + const durationSecs = parseIsoDuration(item.contentDetails?.duration || ""); + const titleLower = (item.snippet?.title || "").toLowerCase(); + const descLower = (item.snippet?.description || "").toLowerCase(); + const hasShortTag = ["#shorts", "#short", "#youtubeshorts", "#ytshorts"].some( + tag => titleLower.includes(tag) || descLower.includes(tag) + ); + // Shorts: ≤180s (YouTube now allows up to 3min) OR has #shorts tag + const isShort = (durationSecs > 0 && durationSecs <= 180) || hasShortTag; + videos.push({ + id: item.id, + title: item.snippet?.title || "", + thumbnail: + item.snippet?.thumbnails?.medium?.url || + `https://img.youtube.com/vi/${item.id}/mqdefault.jpg`, + publishedAt: item.snippet?.publishedAt || "", + views: parseInt(item.statistics?.viewCount || "0", 10), + likes: parseInt(item.statistics?.likeCount || "0", 10), + comments: parseInt(item.statistics?.commentCount || "0", 10), + url: `https://youtube.com/watch?v=${item.id}`, + isShort, + durationSecs, + }); + } + } + } + + pageToken = searchData.nextPageToken || ""; + } while (pageToken); + + return videos.sort((a, b) => b.views - a.views); +} + +export async function GET() { + try { + const ytKey = process.env.YOUTUBE_API_KEY; + if (!ytKey) { + return NextResponse.json({ + videos: [], + totalVideos: 0, + avgViews: 0, + avgEngagement: 0, + error: "YOUTUBE_API_KEY not set", + }); + } + + const videos = await getAllVideos(ytKey); + const totalViews = videos.reduce((s, v) => s + v.views, 0); + const avgViews = videos.length ? Math.round(totalViews / videos.length) : 0; + const avgEngagement = videos.length + ? Math.round( + (videos.reduce( + (s, v) => + s + (v.views > 0 ? ((v.likes + v.comments) / v.views) * 100 : 0), + 0 + ) / + videos.length) * + 100 + ) / 100 + : 0; + + return NextResponse.json({ + videos, + totalVideos: videos.length, + avgViews, + avgEngagement, + }); + } catch { + return NextResponse.json({ + videos: [], + totalVideos: 0, + avgViews: 0, + avgEngagement: 0, + }); + } +} diff --git a/src/app/api/youtube/scripts/route.ts b/src/app/api/youtube/scripts/route.ts new file mode 100644 index 0000000..39cc696 --- /dev/null +++ b/src/app/api/youtube/scripts/route.ts @@ -0,0 +1,76 @@ +export const dynamic = "force-dynamic"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const scripts = await prisma.youtubeScript.findMany({ + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(scripts); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + + const script = await prisma.youtubeScript.create({ + data: { + id: body.id || undefined, + title: body.title, + hook: body.hook || null, + storySetup: body.storySetup || null, + conflict: body.conflict || null, + insight: body.insight || null, + cta: body.cta || null, + caption: body.caption || null, + onScreenText: body.onScreenText || null, + hookType: body.hookType || null, + funnelStage: body.funnelStage || null, + factCheck: body.factCheck || null, + fullScript: body.fullScript || null, + status: body.status || "draft", + rejectedReason: body.rejectedReason || null, + // SEO Package + seoTitle: body.seoTitle || null, + seoDescription: body.seoDescription || null, + seoTags: body.seoTags || null, + seoChapters: body.seoChapters || null, + titleVariants: body.titleVariants || null, + thumbnailConcept: body.thumbnailConcept || null, + }, + }); + + return NextResponse.json(script); +} + +export async function PATCH(req: NextRequest) { + const body = await req.json(); + const { id, deleted, ...updates } = body; + if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); + + if (deleted) { + await prisma.youtubeScript.delete({ where: { id } }).catch(() => {}); + return NextResponse.json({ ok: true }); + } + + try { + const script = await prisma.youtubeScript.update({ + where: { id }, + data: updates, + }); + + // Save rejection feedback for learning + if (updates.status === "rejected" && updates.rejectedReason) { + await prisma.youtubeFeedback.create({ + data: { + title: script.title, + reason: updates.rejectedReason, + type: "script", + }, + }); + } + + return NextResponse.json(script); + } catch { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } +} diff --git a/src/app/articles/page.tsx b/src/app/articles/page.tsx new file mode 100644 index 0000000..3538636 --- /dev/null +++ b/src/app/articles/page.tsx @@ -0,0 +1,2401 @@ +"use client"; + +import { useEffect, useState, useCallback, Suspense } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { rise, Skeleton } from "@/components/ui/kit"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Article { + id: string; + title: string; + body: string; + track: "mega-viral" | "local-viral" | "icp-viral"; + status: "idea" | "draft" | "ready" | "posted"; + qtTweet: string | null; + qtUrl: string | null; + heroImageUrl: string | null; + heroImageHtml: string | null; + inspirationUrls: string | null; + themes: string | null; + scheduledDate: string | null; + postedAt: string | null; + postedUrl: string | null; + impressions: number | null; + likes: number | null; + bookmarks: number | null; + createdAt: string; + updatedAt: string; +} + +interface SavedTitle { + id: string; + title: string; + track: string; + themes: string | null; + createdAt: string; +} + +interface ChatMessage { + role: "user" | "assistant"; + content: string; + searchUsed?: boolean; +} + +type Track = "mega-viral" | "local-viral" | "icp-viral"; +type Tab = "compose" | "library" | "calendar"; + +const TRACK_CONFIG: Record<Track, { emoji: string; label: string; description: string; color: string; bgColor: string; borderColor: string }> = { + "mega-viral": { + emoji: "🔴", + label: "Mega Viral", + description: "reCAPTCHA, Alexa, 23andMe energy. Designed to reach millions.", + color: "text-[var(--text)]", + bgColor: "bg-[var(--surface-2)]", + borderColor: "border-[var(--line)]", + }, + "local-viral": { + emoji: "🟡", + label: "Local Viral", + description: "Claude features, AI tools, builder niche. Viral in our community.", + color: "text-[var(--text)]", + bgColor: "bg-[var(--surface-2)]", + borderColor: "border-[var(--line)]", + }, + "icp-viral": { + emoji: "🟢", + label: "ICP Viral", + description: "Trojan horse content. Goes viral AND books calls from ideal clients.", + color: "text-[var(--text)]", + bgColor: "bg-[var(--surface-2)]", + borderColor: "border-[var(--line)]", + }, +}; + +const THEMES = ["AI", "Claude", "OpenClaw", "Marketing", "Crypto", "Productivity", "Builder Tools", "Agency", "Founders"]; + +const STATUS_COLUMNS = ["idea", "draft", "ready", "posted"] as const; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export default function ArticlesPage() { + return ( + <Suspense fallback={<div className="min-h-screen p-4 md:p-8"><div className="text-[var(--text-3)] text-sm">Loading...</div></div>}> + <ArticlesPageContent /> + </Suspense> + ); +} + +function ArticlesPageContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const tab = (searchParams.get("tab") as Tab) || "compose"; + + const [articles, setArticles] = useState<Article[]>([]); + const [loading, setLoading] = useState(true); + + // Compose state + const [composeStep, setComposeStep] = useState(1); + const [selectedTrack, setSelectedTrack] = useState<Track | null>(null); + const [inspirationUrls, setInspirationUrls] = useState(""); + const [selectedThemes, setSelectedThemes] = useState<string[]>([]); + const [generatedTitles, setGeneratedTitles] = useState<string[]>([]); + const [selectedTitle, setSelectedTitle] = useState<string | null>(null); + const [generatingTitles, setGeneratingTitles] = useState(false); + const [generatingArticle, setGeneratingArticle] = useState(false); + const [currentArticle, setCurrentArticle] = useState<Partial<Article> | null>(null); + const [editingArticle, setEditingArticle] = useState<Article | null>(null); + + // Saved titles state + const [savedTitles, setSavedTitles] = useState<SavedTitle[]>([]); + + // Chat revision state + const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]); + const [chatInput, setChatInput] = useState(""); + const [chatLoading, setChatLoading] = useState(false); + const [searchEnabled, setSearchEnabled] = useState(false); + + // QT generation state + const [generatingQT, setGeneratingQT] = useState(false); + + // Load articles + const loadArticles = useCallback(async () => { + try { + const res = await fetch("/api/articles", { cache: "no-store" }); + if (res.ok) { + setArticles(await res.json()); + } + } finally { + setLoading(false); + } + }, []); + + // Load saved titles + const loadSavedTitles = useCallback(async () => { + try { + const res = await fetch("/api/articles/saved-titles", { cache: "no-store" }); + if (res.ok) { + setSavedTitles(await res.json()); + } + } catch {} + }, []); + + useEffect(() => { + loadArticles(); + loadSavedTitles(); + }, [loadArticles, loadSavedTitles]); + + const setTab = (newTab: Tab) => { + router.push(`/articles?tab=${newTab}`); + }; + + // ─── Compose Handlers ─────────────────────────────────────────────────────── + + const handleSelectTrack = (track: Track) => { + setSelectedTrack(track); + setComposeStep(2); + }; + + // Build inspiration data: full bodies from library articles, URLs for external + const getInspirationData = () => { + const urls = inspirationUrls.split("\n").filter(Boolean); + const libraryInspirations: { title: string; body: string; impressions: number | null; likes: number | null; bookmarks: number | null }[] = []; + const externalUrls: string[] = []; + + for (const url of urls) { + const match = articles.find((a) => a.postedUrl === url); + if (match && match.body) { + libraryInspirations.push({ + title: match.title, + body: match.body, + impressions: match.impressions, + likes: match.likes, + bookmarks: match.bookmarks, + }); + } else { + externalUrls.push(url); + } + } + return { libraryInspirations, externalUrls }; + }; + + const handleGenerateTitles = async () => { + if (!selectedTrack) return; + setGeneratingTitles(true); + const { libraryInspirations, externalUrls } = getInspirationData(); + try { + const res = await fetch("/api/articles/generate-titles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + track: selectedTrack, + themes: JSON.stringify(selectedThemes), + inspirationUrls: JSON.stringify(externalUrls), + libraryInspirations, + }), + }); + if (res.ok) { + const data = await res.json(); + setGeneratedTitles(data.titles); + setComposeStep(3); + } + } finally { + setGeneratingTitles(false); + } + }; + + const handleSelectTitle = async (title: string) => { + setSelectedTitle(title); + setGeneratingArticle(true); + setComposeStep(4); + const { libraryInspirations, externalUrls } = getInspirationData(); + try { + const res = await fetch("/api/articles/generate-article", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + track: selectedTrack, + themes: JSON.stringify(selectedThemes), + inspirationUrls: JSON.stringify(externalUrls), + libraryInspirations, + }), + }); + if (res.ok) { + const data = await res.json(); + setCurrentArticle({ + title: data.title, + body: data.body, + track: selectedTrack!, + themes: JSON.stringify(selectedThemes), + inspirationUrls: JSON.stringify(inspirationUrls.split("\n").filter(Boolean)), + }); + } + } finally { + setGeneratingArticle(false); + } + }; + + const handleSaveArticle = async (status: "draft" | "ready") => { + if (!currentArticle) return; + try { + const res = await fetch("/api/articles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...currentArticle, + status, + }), + }); + if (res.ok) { + await loadArticles(); + resetCompose(); + setTab("library"); + } + } catch (err) { + console.error("Save error:", err); + } + }; + + const handleGenerateQT = async () => { + if (!currentArticle?.title || !currentArticle?.body) return; + setGeneratingQT(true); + try { + const res = await fetch("/api/articles/generate-qt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: currentArticle.title, + body: currentArticle.body, + track: selectedTrack || currentArticle.track, + }), + }); + if (res.ok) { + const data = await res.json(); + setCurrentArticle(prev => ({ + ...prev, + qtTweet: data.qtTweet, + })); + } + } finally { + setGeneratingQT(false); + } + }; + + const resetCompose = () => { + setComposeStep(1); + setSelectedTrack(null); + setInspirationUrls(""); + setSelectedThemes([]); + setGeneratedTitles([]); + setSelectedTitle(null); + setCurrentArticle(null); + setChatMessages([]); + setChatInput(""); + }; + + // ─── Chat Revision Handlers ───────────────────────────────────────────────── + + const handleChatSend = async () => { + if (!chatInput.trim() || !currentArticle?.body || chatLoading) return; + + const userMessage: ChatMessage = { role: "user", content: chatInput.trim() }; + const updatedMessages = [...chatMessages, userMessage]; + setChatMessages(updatedMessages); + setChatInput(""); + setChatLoading(true); + + try { + const res = await fetch("/api/articles/revise", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: currentArticle.title, + articleBody: currentArticle.body, + track: selectedTrack || currentArticle.track, + instruction: userMessage.content, + searchEnabled, + messages: updatedMessages.slice(-10), // last 10 messages for context + }), + }); + + if (res.ok) { + const data = await res.json(); + const assistantMessage: ChatMessage = { + role: "assistant", + content: data.message, + searchUsed: data.searchUsed, + }; + setChatMessages(prev => [...prev, assistantMessage]); + + if (data.type === "revision" && data.revisedBody) { + setCurrentArticle(prev => ({ ...prev, body: data.revisedBody })); + } + } else { + setChatMessages(prev => [ + ...prev, + { role: "assistant", content: "Something went wrong. Try again." }, + ]); + } + } catch { + setChatMessages(prev => [ + ...prev, + { role: "assistant", content: "Network error. Try again." }, + ]); + } finally { + setChatLoading(false); + } + }; + + // ─── Saved Title Handlers ─────────────────────────────────────────────────── + + const handleSaveTitle = async (title: string) => { + try { + const res = await fetch("/api/articles/saved-titles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + track: selectedTrack || "local-viral", + themes: JSON.stringify(selectedThemes), + }), + }); + if (res.ok) { + await loadSavedTitles(); + } + } catch {} + }; + + const handleAddArticle = async (articleData: Partial<Article>) => { + try { + const res = await fetch("/api/articles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(articleData), + }); + if (res.ok) { + await loadArticles(); + } + } catch (err) { + console.error("Add article error:", err); + } + }; + + const handleRemoveSavedTitle = async (id: string) => { + try { + const res = await fetch("/api/articles/saved-titles", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (res.ok) { + await loadSavedTitles(); + } + } catch {} + }; + + // ─── Library Handlers ─────────────────────────────────────────────────────── + + const handleUpdateArticle = async (id: string, updates: Partial<Article>) => { + try { + const res = await fetch("/api/articles", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, ...updates }), + }); + if (res.ok) { + await loadArticles(); + } + } catch (err) { + console.error("Update error:", err); + } + }; + + const handleDeleteArticle = async (id: string) => { + if (!confirm("Delete this article?")) return; + try { + const res = await fetch("/api/articles", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (res.ok) { + await loadArticles(); + setEditingArticle(null); + } + } catch (err) { + console.error("Delete error:", err); + } + }; + + // ─── Calendar Helpers ─────────────────────────────────────────────────────── + + const [calendarWeekOffset, setCalendarWeekOffset] = useState(0); + + const getWeekDays = () => { + const now = new Date(); + now.setDate(now.getDate() + calendarWeekOffset * 7); + const monday = new Date(now); + const day = now.getDay(); + // getDay(): 0=Sun, 1=Mon... Fix Sunday (0) to go back to previous Monday + const diff = day === 0 ? -6 : 1 - day; + monday.setDate(now.getDate() + diff); + + const days: { date: string; label: string; dayName: string; isToday: boolean }[] = []; + const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + + for (let i = 0; i < 7; i++) { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + days.push({ + date: dateStr, + label: `${d.getDate()}/${d.getMonth() + 1}`, + dayName: dayNames[i], + isToday: dateStr === todayStr, + }); + } + return days; + }; + + // ─── Render ───────────────────────────────────────────────────────────────── + + return ( + <> + <div className="relative z-10 min-h-screen w-full mx-auto p-4 md:p-8 text-[var(--text)]"> + {/* Header */} + <div className="hq-rise mb-8" style={rise(0)}> + <div className="eyebrow mb-2.5">Article Studio</div> + <h1 className="text-[32px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]">Compose & publish</h1> + + {/* Tab Nav */} + <div className="flex gap-1 mt-6"> + {(["compose", "library", "calendar"] as Tab[]).map(t => ( + <button + key={t} + onClick={() => setTab(t)} + className={`px-3.5 py-2 rounded-[var(--r-md)] text-[13px] font-medium transition-colors ${ + tab === t + ? "bg-[var(--surface-2)] text-[var(--text)]" + : "text-[var(--text-3)] hover:text-[var(--text)]" + }`} + > + {t === "compose" ? "Compose" : t === "library" ? "Library" : "Calendar"} + </button> + ))} + </div> + <div className="rule mt-2" /> + </div> + + {/* Tab Content */} + {tab === "compose" && ( + <ComposeTab + step={composeStep} + selectedTrack={selectedTrack} + inspirationUrls={inspirationUrls} + selectedThemes={selectedThemes} + generatedTitles={generatedTitles} + generatingTitles={generatingTitles} + generatingArticle={generatingArticle} + generatingQT={generatingQT} + currentArticle={currentArticle} + savedTitles={savedTitles} + articles={articles} + chatMessages={chatMessages} + chatInput={chatInput} + chatLoading={chatLoading} + searchEnabled={searchEnabled} + onSelectTrack={handleSelectTrack} + onSetInspirationUrls={setInspirationUrls} + onToggleTheme={(theme) => { + setSelectedThemes(prev => + prev.includes(theme) ? prev.filter(t => t !== theme) : [...prev, theme] + ); + }} + onGenerateTitles={handleGenerateTitles} + onSelectTitle={handleSelectTitle} + onRegenerateTitles={handleGenerateTitles} + onUpdateArticle={(updates) => setCurrentArticle(prev => ({ ...prev, ...updates }))} + onSaveArticle={handleSaveArticle} + onGenerateQT={handleGenerateQT} + onSaveTitle={handleSaveTitle} + onRemoveSavedTitle={handleRemoveSavedTitle} + onChatSend={handleChatSend} + onChatInputChange={setChatInput} + onToggleSearch={() => setSearchEnabled(prev => !prev)} + onBack={() => setComposeStep(prev => Math.max(1, prev - 1))} + onReset={resetCompose} + /> + )} + + {tab === "library" && ( + <LibraryTab + articles={articles} + loading={loading} + editingArticle={editingArticle} + onSetEditingArticle={setEditingArticle} + onUpdateArticle={handleUpdateArticle} + onDeleteArticle={handleDeleteArticle} + onAddArticle={handleAddArticle} + /> + )} + + {tab === "calendar" && ( + <CalendarTab + articles={articles} + weekDays={getWeekDays()} + weekOffset={calendarWeekOffset} + onPrevWeek={() => setCalendarWeekOffset(prev => prev - 1)} + onNextWeek={() => setCalendarWeekOffset(prev => prev + 1)} + onToday={() => setCalendarWeekOffset(0)} + onUpdateArticle={handleUpdateArticle} + onOpenArticle={(article) => { + setEditingArticle(article); + setTab("library"); + }} + /> + )} + </div> + </> + ); +} + +// ─── Compose Tab ────────────────────────────────────────────────────────────── + +interface ComposeTabProps { + step: number; + selectedTrack: Track | null; + inspirationUrls: string; + selectedThemes: string[]; + generatedTitles: string[]; + generatingTitles: boolean; + generatingArticle: boolean; + generatingQT: boolean; + currentArticle: Partial<Article> | null; + savedTitles: SavedTitle[]; + articles: Article[]; + chatMessages: ChatMessage[]; + chatInput: string; + chatLoading: boolean; + searchEnabled: boolean; + onSelectTrack: (track: Track) => void; + onSetInspirationUrls: (v: string) => void; + onToggleTheme: (theme: string) => void; + onGenerateTitles: () => void; + onSelectTitle: (title: string) => void; + onRegenerateTitles: () => void; + onUpdateArticle: (updates: Partial<Article>) => void; + onSaveArticle: (status: "draft" | "ready") => void; + onGenerateQT: () => void; + onSaveTitle: (title: string) => void; + onRemoveSavedTitle: (id: string) => void; + onChatSend: () => void; + onChatInputChange: (v: string) => void; + onToggleSearch: () => void; + onBack: () => void; + onReset: () => void; +} + +function ComposeTab({ + step, + selectedTrack, + inspirationUrls, + selectedThemes, + generatedTitles, + generatingTitles, + generatingArticle, + generatingQT, + currentArticle, + savedTitles, + articles, + chatMessages, + chatInput, + chatLoading, + searchEnabled, + onSelectTrack, + onSetInspirationUrls, + onToggleTheme, + onGenerateTitles, + onSelectTitle, + onRegenerateTitles, + onUpdateArticle, + onSaveArticle, + onGenerateQT, + onSaveTitle, + onRemoveSavedTitle, + onChatSend, + onChatInputChange, + onToggleSearch, + onBack, + onReset, +}: ComposeTabProps) { + // Step indicator + const steps = [ + { num: 1, label: "Track" }, + { num: 2, label: "Style" }, + { num: 3, label: "Title" }, + { num: 4, label: "Write" }, + { num: 5, label: "Visuals" }, + ]; + + return ( + <div className="space-y-6"> + {/* Progress Steps */} + <div className="flex items-center gap-2 mb-10"> + {steps.map((s, i) => ( + <div key={s.num} className="flex items-center"> + <div + className={`w-7 h-7 rounded-full flex items-center justify-center text-[12px] num font-medium border transition ${ + step === s.num + ? "border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)]" + : step > s.num + ? "border-[var(--line)] text-[var(--text-2)] bg-[var(--surface-1)]" + : "border-[var(--line)] text-[var(--text-4)] bg-transparent" + }`} + > + {step > s.num ? "✓" : s.num} + </div> + <span + className={`ml-2 text-[13px] font-medium ${ + step === s.num ? "text-[var(--text)]" : "text-[var(--text-4)]" + }`} + > + {s.label} + </span> + {i < steps.length - 1 && ( + <div className="w-8 h-px bg-[var(--line)] mx-3" /> + )} + </div> + ))} + {step > 1 && ( + <button + onClick={onReset} + className="ml-auto text-[12px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors" + > + Start Over + </button> + )} + </div> + + {/* Step 1: Track Selection */} + {step === 1 && ( + <div className="space-y-5"> + <h2 className="text-[19px] font-semibold tracking-[-0.015em] text-[var(--text)]">Choose your track</h2> + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> + {(Object.entries(TRACK_CONFIG) as [Track, typeof TRACK_CONFIG[Track]][]).map( + ([track, config]) => ( + <button + key={track} + onClick={() => onSelectTrack(track)} + className="panel panel-interactive p-6 text-left" + > + <div className="text-2xl mb-3">{config.emoji}</div> + <h3 className="text-[15px] font-semibold mb-2 text-[var(--text)]"> + {config.label} + </h3> + <p className="text-[13px] leading-relaxed text-[var(--text-2)]">{config.description}</p> + </button> + ) + )} + </div> + </div> + )} + + {/* Step 2: Style & Themes */} + {step === 2 && selectedTrack && ( + <div className="space-y-6"> + <div className="flex items-center gap-3"> + <button onClick={onBack} className="text-[13px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors"> + ← Back + </button> + <h2 className="text-[19px] font-semibold tracking-[-0.015em] text-[var(--text)]">Style & themes</h2> + <span className="text-[11px] font-medium px-2.5 py-1 rounded-full border border-[var(--line)] bg-[var(--surface-2)] text-[var(--text-2)]"> + {TRACK_CONFIG[selectedTrack].emoji} {TRACK_CONFIG[selectedTrack].label} + </span> + </div> + + {/* Voice Library — pick from your posted articles (filtered by track) */} + {(() => { + const postedArticles = articles + .filter((a) => a.status === "posted" && a.postedUrl && a.track === selectedTrack) + .sort((a, b) => (b.impressions || 0) - (a.impressions || 0)); + if (postedArticles.length === 0) return null; + return ( + <div className="space-y-2.5"> + <label className="text-[13px] text-[var(--text-2)]"> + Pick from your {TRACK_CONFIG[selectedTrack].label} articles as style inspiration + </label> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2.5"> + {postedArticles.map((a) => { + const isSelected = inspirationUrls.includes(a.postedUrl!); + return ( + <button + key={a.id} + onClick={() => { + if (isSelected) { + onSetInspirationUrls( + inspirationUrls + .split("\n") + .filter((u) => u.trim() !== a.postedUrl) + .join("\n") + ); + } else { + onSetInspirationUrls( + [inspirationUrls, a.postedUrl].filter(Boolean).join("\n") + ); + } + }} + className={`rounded-[var(--r-md)] text-left transition overflow-hidden border ${ + isSelected + ? "border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,transparent)]" + : "border-[var(--line)] bg-[var(--surface-1)] hover:border-[var(--line-strong)] hover:bg-[var(--surface-2)]" + }`} + > + {a.heroImageUrl && ( + <div + className="w-full h-20 bg-cover bg-center" + style={{ backgroundImage: `url(${a.heroImageUrl})` }} + /> + )} + <div className="p-3"> + <span className="line-clamp-2 text-[12px] font-medium text-[var(--text)]">{a.title}</span> + {a.impressions != null && ( + <span className="text-[10px] num text-[var(--text-3)] block mt-1"> + 👀 {a.impressions.toLocaleString()} + {a.likes != null && ` · ❤️ ${a.likes.toLocaleString()}`} + {a.bookmarks != null && ` · 🔖 ${a.bookmarks.toLocaleString()}`} + </span> + )} + </div> + </button> + ); + })} + </div> + </div> + ); + })()} + + {/* Inspiration URLs */} + <div className="space-y-2.5"> + <label className="text-[13px] text-[var(--text-2)]"> + Or paste external article URLs (one per line) + </label> + <textarea + value={inspirationUrls} + onChange={(e) => onSetInspirationUrls(e.target.value)} + placeholder="https://x.com/yourhandle/status/... https://example.com/great-article" + className="w-full h-24 bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-4 text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus-visible:outline-none focus:border-[var(--line-strong)] resize-none" + /> + </div> + + {/* Themes */} + <div className="space-y-2.5"> + <label className="text-[13px] text-[var(--text-2)]">Select themes</label> + <div className="flex flex-wrap gap-2"> + {THEMES.map((theme) => ( + <button + key={theme} + onClick={() => onToggleTheme(theme)} + className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition ${ + selectedThemes.includes(theme) + ? "border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)]" + : "border-[var(--line)] text-[var(--text-3)] hover:text-[var(--text)] hover:border-[var(--line-strong)]" + }`} + > + {theme} + </button> + ))} + </div> + </div> + + {/* Generate Button */} + <button + onClick={onGenerateTitles} + disabled={generatingTitles} + className={`btn-primary px-5 py-2.5 text-[13px] inline-flex items-center gap-2 ${generatingTitles ? "opacity-40 pointer-events-none" : ""}`} + > + {generatingTitles ? ( + <> + <span className="animate-spin">⏳</span> Generating... + </> + ) : ( + <>Generate Titles →</> + )} + </button> + </div> + )} + + {/* Step 3: Title Selection */} + {step === 3 && ( + <div className="space-y-6"> + <div className="flex items-center gap-3"> + <button onClick={onBack} className="text-[13px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors"> + ← Back + </button> + <h2 className="text-[19px] font-semibold tracking-[-0.015em] text-[var(--text)]">Choose a title</h2> + </div> + + {/* Saved Titles */} + {savedTitles.length > 0 && ( + <div className="space-y-3"> + <h3 className="eyebrow">Saved Titles</h3> + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> + {savedTitles.map((saved) => ( + <div + key={saved.id} + className="panel panel-interactive p-4 text-left group relative" + > + <button + onClick={() => onSelectTitle(saved.title)} + className="w-full text-left" + > + <p className="text-[13px] text-[var(--text)] pr-8 leading-snug">{saved.title}</p> + <div className="flex items-center gap-2 mt-2"> + <span className="text-[10px] num text-[var(--text-3)]"> + {new Date(saved.createdAt).toLocaleDateString()} + </span> + <span className="text-[11px] text-[var(--accent)] opacity-0 group-hover:opacity-100 transition"> + Use this → + </span> + </div> + </button> + <button + onClick={(e) => { + e.stopPropagation(); + onRemoveSavedTitle(saved.id); + }} + className="absolute top-3 right-3 text-[var(--text-4)] hover:text-[var(--down)] transition text-sm" + title="Remove saved title" + > + × + </button> + </div> + ))} + </div> + </div> + )} + + {/* Generated Titles */} + {generatedTitles.length > 0 && ( + <div className="space-y-3"> + {savedTitles.length > 0 && ( + <h3 className="eyebrow">Generated Titles</h3> + )} + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> + {generatedTitles.map((title, i) => { + const isSaved = savedTitles.some((s) => s.title === title); + return ( + <div + key={i} + className="panel panel-interactive p-4 text-left group relative" + > + <button + onClick={() => onSelectTitle(title)} + className="w-full text-left" + > + <p className="text-[13px] text-[var(--text)] pr-8 leading-snug">{title}</p> + <span className="text-[11px] text-[var(--accent)] opacity-0 group-hover:opacity-100 transition mt-2 block"> + Use this title → + </span> + </button> + <button + onClick={(e) => { + e.stopPropagation(); + if (!isSaved) onSaveTitle(title); + }} + className={`absolute top-3 right-3 transition text-lg ${ + isSaved + ? "text-[var(--warn)] cursor-default" + : "text-[var(--text-4)] hover:text-[var(--warn)]" + }`} + title={isSaved ? "Already saved" : "Save for later"} + disabled={isSaved} + > + {isSaved ? "★" : "☆"} + </button> + </div> + ); + })} + </div> + </div> + )} + + <button + onClick={onRegenerateTitles} + disabled={generatingTitles} + className="text-[13px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors disabled:opacity-50" + > + {generatingTitles ? "Regenerating..." : "🔄 Regenerate titles"} + </button> + </div> + )} + + {/* Step 4: Writing */} + {step === 4 && ( + <div className="space-y-6"> + <div className="flex items-center gap-3"> + <button onClick={onBack} className="text-[13px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors"> + ← Back + </button> + <h2 className="text-[19px] font-semibold tracking-[-0.015em] text-[var(--text)]">Write article</h2> + </div> + + {generatingArticle ? ( + <div className="flex flex-col items-center justify-center py-20"> + <div className="text-4xl mb-4 animate-pulse">✍️</div> + <p className="text-[var(--text-2)] text-sm">Researching and writing...</p> + <p className="text-[12px] text-[var(--text-3)] mt-2">This may take a moment</p> + </div> + ) : currentArticle ? ( + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> + {/* Main Editor */} + <div className="lg:col-span-2 space-y-4"> + <input + type="text" + value={currentArticle.title || ""} + onChange={(e) => onUpdateArticle({ title: e.target.value })} + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-4 text-[18px] font-semibold text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + <textarea + value={currentArticle.body || ""} + onChange={(e) => onUpdateArticle({ body: e.target.value })} + className="w-full h-[500px] bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-4 text-[14px] leading-relaxed text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)] resize-none" + /> + <div className="text-[12px] num text-[var(--text-3)]"> + {(currentArticle.body || "").split(/\s+/).filter(Boolean).length} words + </div> + + {/* Actions */} + <div className="flex gap-2.5"> + <button + onClick={() => onSaveArticle("draft")} + className="btn-ghost px-4 py-2 text-[13px]" + > + Save Draft + </button> + <button + onClick={() => onSaveArticle("ready")} + className="btn-primary px-4 py-2 text-[13px]" + > + Mark Ready + </button> + </div> + + {/* QT Tweet Section */} + <div className="rule mt-6" /> + <div className="pt-6"> + <h3 className="eyebrow mb-3">QT Tweet Hook</h3> + {currentArticle.qtTweet ? ( + <div className="space-y-2"> + <textarea + value={currentArticle.qtTweet} + onChange={(e) => onUpdateArticle({ qtTweet: e.target.value })} + className="w-full h-32 bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-4 text-[14px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)] resize-none" + /> + <button + onClick={onGenerateQT} + disabled={generatingQT} + className="text-[12px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors disabled:opacity-50" + > + {generatingQT ? "Generating..." : "🔄 Regenerate"} + </button> + </div> + ) : ( + <button + onClick={onGenerateQT} + disabled={generatingQT} + className="btn-ghost px-4 py-2 text-[13px] disabled:opacity-50" + > + {generatingQT ? "Generating QT..." : "Generate QT Tweet"} + </button> + )} + </div> + </div> + + {/* Right Panel: Info + Chat */} + <div className="space-y-4"> + {/* Article Info */} + <div className="panel p-4 space-y-3"> + <h4 className="eyebrow">Article Info</h4> + {selectedTrack && ( + <div className="text-[11px] font-medium px-2.5 py-1 rounded-full inline-block border border-[var(--line)] bg-[var(--surface-2)] text-[var(--text-2)]"> + {TRACK_CONFIG[selectedTrack].emoji} {TRACK_CONFIG[selectedTrack].label} + </div> + )} + {selectedThemes.length > 0 && ( + <div className="flex flex-wrap gap-1.5"> + {selectedThemes.map((t) => ( + <span key={t} className="text-[11px] bg-[var(--surface-2)] text-[var(--text-2)] px-2 py-0.5 rounded-[var(--r-sm)]"> + {t} + </span> + ))} + </div> + )} + {inspirationUrls && ( + <div className="text-[12px] num text-[var(--text-3)]"> + {inspirationUrls.split("\n").filter(Boolean).length} inspiration URLs + </div> + )} + </div> + + {/* Chat Revision Panel */} + <div className="panel overflow-hidden flex flex-col" style={{ height: "460px" }}> + <div className="px-4 py-3 border-b border-[var(--line)] flex items-center justify-between"> + <h4 className="eyebrow">Revision Chat</h4> + <button + onClick={onToggleSearch} + className={`flex items-center gap-1.5 px-2 py-1 rounded-[var(--r-sm)] text-[10px] font-medium border transition ${ + searchEnabled + ? "border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)]" + : "border-[var(--line)] text-[var(--text-3)] hover:text-[var(--text)]" + }`} + title={searchEnabled ? "Web search enabled" : "Enable web search for fact-checking"} + > + 🌐 Web Search {searchEnabled ? "ON" : "OFF"} + </button> + </div> + + {/* Messages */} + <div className="flex-1 overflow-y-auto p-3 space-y-3"> + {chatMessages.length === 0 && ( + <div className="text-center py-8 space-y-2.5"> + <p className="text-[var(--text-3)] text-[12px]">Ask Sonnet to revise sections, fact-check claims, or improve the article.</p> + <div className="flex flex-wrap justify-center gap-1.5"> + {["Make the opening punchier", "Is this data accurate?", "Shorten the conclusion"].map((s) => ( + <button + key={s} + onClick={() => { onChatInputChange(s); }} + className="text-[10px] px-2 py-1 border border-[var(--line)] text-[var(--text-3)] rounded-[var(--r-sm)] hover:text-[var(--text)] hover:border-[var(--line-strong)] transition" + > + {s} + </button> + ))} + </div> + </div> + )} + {chatMessages.map((msg, i) => ( + <div + key={i} + className={`text-sm ${ + msg.role === "user" + ? "text-right" + : "text-left" + }`} + > + <div + className={`inline-block max-w-[95%] p-3 rounded-[var(--r-md)] text-[12px] leading-relaxed ${ + msg.role === "user" + ? "bg-[color-mix(in_srgb,var(--accent)_14%,transparent)] text-[var(--text)] border border-[color-mix(in_srgb,var(--accent)_22%,transparent)]" + : "bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--line)]" + }`} + > + <div className="whitespace-pre-wrap">{msg.content}</div> + {msg.searchUsed && ( + <div className="mt-1.5 text-[10px] text-[var(--accent)] flex items-center gap-1"> + 🌐 Used web search + </div> + )} + </div> + </div> + ))} + {chatLoading && ( + <div className="text-left"> + <div className="inline-block p-3 rounded-[var(--r-md)] bg-[var(--surface-2)] border border-[var(--line)]"> + <div className="flex items-center gap-2 text-[12px] text-[var(--text-2)]"> + <span className="animate-pulse">●</span> + {searchEnabled ? "Searching & thinking..." : "Thinking..."} + </div> + </div> + </div> + )} + </div> + + {/* Input */} + <div className="p-3 border-t border-[var(--line)]"> + <div className="flex gap-2"> + <input + type="text" + value={chatInput} + onChange={(e) => onChatInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onChatSend(); + } + }} + placeholder="Ask to revise, fact-check..." + className="flex-1 bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] px-3 py-2 text-[12px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + disabled={chatLoading} + /> + <button + onClick={onChatSend} + disabled={chatLoading || !chatInput.trim()} + className="btn-primary px-3 py-2 text-[12px] disabled:opacity-40 disabled:pointer-events-none" + > + Send + </button> + </div> + </div> + </div> + </div> + </div> + ) : null} + </div> + )} + + {/* Step 5: Visuals */} + {step === 5 && ( + <div className="space-y-6"> + <div className="flex items-center gap-3"> + <button onClick={onBack} className="text-[13px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors"> + ← Back + </button> + <h2 className="text-[19px] font-semibold tracking-[-0.015em] text-[var(--text)]">Article visuals</h2> + </div> + + <div className="panel p-6 space-y-4"> + <h3 className="eyebrow">Suggested Visuals</h3> + <div className="space-y-2.5"> + <div className="flex items-center justify-between p-3 rounded-[var(--r-md)] bg-[var(--surface-2)] border border-[var(--line)]"> + <div> + <p className="text-[13px] font-medium text-[var(--text)]">Hero Image</p> + <p className="text-[12px] text-[var(--text-3)]">Wide banner for the top of the article</p> + </div> + <button className="btn-ghost px-3 py-1.5 text-[12px]"> + Generate + </button> + </div> + <div className="flex items-center justify-between p-3 rounded-[var(--r-md)] bg-[var(--surface-2)] border border-[var(--line)]"> + <div> + <p className="text-[13px] font-medium text-[var(--text)]">Timeline Graphic</p> + <p className="text-[12px] text-[var(--text-3)]">For the story progression section</p> + </div> + <button className="btn-ghost px-3 py-1.5 text-[12px]"> + Generate + </button> + </div> + <div className="flex items-center justify-between p-3 rounded-[var(--r-md)] bg-[var(--surface-2)] border border-[var(--line)]"> + <div> + <p className="text-[13px] font-medium text-[var(--text)]">Data Visualization</p> + <p className="text-[12px] text-[var(--text-3)]">Charts or stats callouts</p> + </div> + <button className="btn-ghost px-3 py-1.5 text-[12px]"> + Generate + </button> + </div> + </div> + </div> + + <div className="rounded-[var(--r-lg)] border border-dashed border-[var(--line-strong)] p-8 text-center"> + <p className="text-[var(--text-2)] text-[13px]">Visual generation coming soon</p> + <p className="text-[var(--text-3)] text-[12px] mt-1">Placeholder for hero image preview</p> + </div> + </div> + )} + </div> + ); +} + +// ─── Library Tab ────────────────────────────────────────────────────────────── + +interface LibraryTabProps { + articles: Article[]; + loading: boolean; + editingArticle: Article | null; + onSetEditingArticle: (a: Article | null) => void; + onUpdateArticle: (id: string, updates: Partial<Article>) => void; + onDeleteArticle: (id: string) => void; + onAddArticle: (article: Partial<Article>) => void; +} + +function LibraryTab({ + articles, + loading, + editingArticle, + onSetEditingArticle, + onUpdateArticle, + onDeleteArticle, + onAddArticle, +}: LibraryTabProps) { + const [showAddForm, setShowAddForm] = useState(false); + const [addTitle, setAddTitle] = useState(""); + const [addUrl, setAddUrl] = useState(""); + const [addQtUrl, setAddQtUrl] = useState(""); + const [addTrack, setAddTrack] = useState<Track>("mega-viral"); + const [addDate, setAddDate] = useState(new Date().toISOString().split("T")[0]); + const [addImpressions, setAddImpressions] = useState(""); + const [addLikes, setAddLikes] = useState(""); + const [addBookmarks, setAddBookmarks] = useState(""); + const [addHeroImage, setAddHeroImage] = useState(""); + const [scraping, setScraping] = useState(false); + + const [addBody, setAddBody] = useState(""); + + const [addQtText, setAddQtText] = useState(""); + const [scrapingQt, setScrapingQt] = useState(false); + + const handleFetchQt = async (url: string) => { + setAddQtUrl(url); + const tweetIdMatch = url.match(/status\/(\d+)/); + if (!tweetIdMatch) return; + setScrapingQt(true); + try { + const res = await fetch("/api/articles/scrape", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + if (res.ok) { + const data = await res.json(); + // The QT tweet's body IS the QT text + if (data.body) setAddQtText(data.body); + else if (data.title) setAddQtText(data.title); + } + } finally { + setScrapingQt(false); + } + }; + + const handleScrapeUrl = async (url: string) => { + setAddUrl(url); + if (!url.startsWith("http")) return; + setScraping(true); + try { + const res = await fetch("/api/articles/scrape", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + if (res.ok) { + const data = await res.json(); + if (data.title) setAddTitle(data.title); + if (data.body) setAddBody(data.body); + if (data.heroImageUrl) setAddHeroImage(data.heroImageUrl); + if (data.impressions != null) setAddImpressions(String(data.impressions)); + if (data.likes != null) setAddLikes(String(data.likes)); + if (data.bookmarks != null) setAddBookmarks(String(data.bookmarks)); + if (data.postedDate) setAddDate(data.postedDate); + if (data.qtTweet) setAddQtText(data.qtTweet); + if (data.qtUrl) setAddQtUrl(data.qtUrl); + } + } finally { + setScraping(false); + } + }; + + if (loading) { + return ( + <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> + {STATUS_COLUMNS.map((status) => ( + <div key={status} className="space-y-3"> + <Skeleton className="h-4 w-20" /> + <Skeleton className="h-24 w-full" /> + <Skeleton className="h-24 w-full" /> + </div> + ))} + </div> + ); + } + + // Article Editor Modal + if (editingArticle) { + return ( + <ArticleEditor + article={editingArticle} + onClose={() => onSetEditingArticle(null)} + onUpdate={(updates) => { + onUpdateArticle(editingArticle.id, updates); + onSetEditingArticle({ ...editingArticle, ...updates }); + }} + onDelete={() => onDeleteArticle(editingArticle.id)} + /> + ); + } + + return ( + <div className="space-y-4"> + {/* Add Past Article */} + <div className="flex justify-end"> + <button + onClick={() => setShowAddForm(!showAddForm)} + className="btn-ghost px-3 py-1.5 text-[12px]" + > + {showAddForm ? "Cancel" : "+ Add Past Article"} + </button> + </div> + + {showAddForm && ( + <div className="panel p-5 space-y-4"> + <h3 className="text-[14px] font-semibold text-[var(--text)]">Add an article you already posted</h3> + + {/* URL input — auto-fetches on paste */} + <div className="space-y-1.5"> + <label className="eyebrow">Article URL (paste to auto-fill)</label> + <div className="flex gap-2"> + <input + type="url" + value={addUrl} + onChange={(e) => setAddUrl(e.target.value)} + onPaste={(e) => { + const pasted = e.clipboardData.getData("text"); + if (pasted.startsWith("http")) { + setTimeout(() => handleScrapeUrl(pasted), 100); + } + }} + placeholder="https://x.com/yourhandle/status/..." + className="flex-1 bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + <button + onClick={() => handleScrapeUrl(addUrl)} + disabled={scraping || !addUrl.startsWith("http")} + className="btn-ghost px-3 py-2 text-[12px] disabled:opacity-50" + > + {scraping ? "Fetching..." : "Fetch"} + </button> + </div> + {scraping && ( + <p className="text-[10px] text-[var(--accent)] animate-pulse">Fetching title, image, and metrics...</p> + )} + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-1.5"> + <label className="eyebrow">Title</label> + <input + type="text" + value={addTitle} + onChange={(e) => setAddTitle(e.target.value)} + placeholder="Article title..." + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + </div> + <div className="space-y-1.5"> + <label className="eyebrow">QT Tweet URL (paste to fetch text)</label> + <div className="flex gap-2"> + <input + type="url" + value={addQtUrl} + onChange={(e) => setAddQtUrl(e.target.value)} + onPaste={(e) => { + const pasted = e.clipboardData.getData("text"); + if (pasted.includes("status/")) { + setTimeout(() => handleFetchQt(pasted), 100); + } + }} + placeholder="https://x.com/yourhandle/status/..." + className="flex-1 bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + <button + onClick={() => handleFetchQt(addQtUrl)} + disabled={scrapingQt || !addQtUrl.includes("status/")} + className="btn-ghost px-3 py-2 text-[12px] disabled:opacity-50" + > + {scrapingQt ? "..." : "Fetch"} + </button> + </div> + {addQtText && ( + <div className="mt-2 p-3 bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-sm)]"> + <p className="eyebrow mb-1">QT Text (fetched)</p> + <p className="text-[12px] text-[var(--text-2)] whitespace-pre-wrap">{addQtText}</p> + </div> + )} + </div> + <div className="space-y-1.5"> + <label className="eyebrow">Track</label> + <select + value={addTrack} + onChange={(e) => setAddTrack(e.target.value as Track)} + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] text-[var(--text)] focus:outline-none focus:border-[var(--line-strong)]" + > + <option value="mega-viral">Mega Viral</option> + <option value="local-viral">Local Viral</option> + <option value="icp-viral">ICP Viral</option> + </select> + </div> + <div className="space-y-1.5"> + <label className="eyebrow">Posted Date</label> + <input + type="date" + value={addDate} + onChange={(e) => setAddDate(e.target.value)} + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] num text-[var(--text)] focus:outline-none focus:border-[var(--line-strong)] [color-scheme:dark]" + /> + </div> + <div className="space-y-1.5"> + <label className="eyebrow">Impressions</label> + <input + type="number" + value={addImpressions} + onChange={(e) => setAddImpressions(e.target.value)} + placeholder="0" + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] num text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + </div> + <div className="grid grid-cols-2 gap-2"> + <div className="space-y-1.5"> + <label className="eyebrow">Likes</label> + <input + type="number" + value={addLikes} + onChange={(e) => setAddLikes(e.target.value)} + placeholder="0" + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] num text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + </div> + <div className="space-y-1.5"> + <label className="eyebrow">Bookmarks</label> + <input + type="number" + value={addBookmarks} + onChange={(e) => setAddBookmarks(e.target.value)} + placeholder="0" + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-sm)] p-2 text-[13px] num text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + </div> + </div> + </div> + + {/* Hero image preview */} + {addHeroImage && ( + <div className="space-y-1.5"> + <label className="eyebrow">Hero Image (auto-fetched)</label> + <div className="rounded-[var(--r-sm)] overflow-hidden border border-[var(--line)] max-h-32"> + <img src={addHeroImage} alt="Hero" className="w-full h-32 object-cover" /> + </div> + </div> + )} + + <button + onClick={() => { + if (!addTitle.trim()) return; + onAddArticle({ + title: addTitle, + body: addBody, + track: addTrack, + status: "posted", + postedUrl: addUrl || null, + qtTweet: addQtText || null, + qtUrl: addQtUrl || null, + heroImageUrl: addHeroImage || null, + postedAt: addDate ? new Date(addDate).toISOString() : new Date().toISOString(), + scheduledDate: addDate || null, + impressions: addImpressions ? Number(addImpressions) : null, + likes: addLikes ? Number(addLikes) : null, + bookmarks: addBookmarks ? Number(addBookmarks) : null, + }); + setShowAddForm(false); + setAddTitle(""); + setAddBody(""); + setAddUrl(""); + setAddQtUrl(""); + setAddQtText(""); + setAddImpressions(""); + setAddLikes(""); + setAddBookmarks(""); + setAddHeroImage(""); + }} + disabled={!addTitle.trim()} + className="btn-primary px-4 py-2 text-[13px] disabled:opacity-40 disabled:pointer-events-none" + > + Add Article + </button> + </div> + )} + + <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> + {STATUS_COLUMNS.map((status) => { + const statusArticles = articles.filter((a) => a.status === status); + const statusConfig = { + idea: { label: "Ideas" }, + draft: { label: "Drafts" }, + ready: { label: "Ready" }, + posted: { label: "Posted" }, + }[status]; + + return ( + <div key={status} className="space-y-3"> + <div className="flex items-center gap-2"> + <span className="eyebrow"> + {statusConfig.label} + </span> + <span className="text-[11px] num text-[var(--text-4)]">{statusArticles.length}</span> + </div> + <div className="space-y-2.5"> + {statusArticles.map((article) => ( + <ArticleCard + key={article.id} + article={article} + onClick={() => onSetEditingArticle(article)} + /> + ))} + {statusArticles.length === 0 && ( + <div className="text-[12px] text-[var(--text-4)] py-4 text-center border border-dashed border-[var(--line)] rounded-[var(--r-md)]"> + No {status} articles + </div> + )} + </div> + </div> + ); + })} + </div> + </div> + ); +} + +// ─── Article Card ───────────────────────────────────────────────────────────── + +function ArticleCard({ article, onClick }: { article: Article; onClick: () => void }) { + const trackConfig = TRACK_CONFIG[article.track]; + const wordCount = article.body?.split(/\s+/).filter(Boolean).length || 0; + + return ( + <button + onClick={onClick} + className="panel panel-interactive w-full text-left overflow-hidden" + > + {article.heroImageUrl && ( + <div + className="w-full h-24 bg-cover bg-center" + style={{ backgroundImage: `url(${article.heroImageUrl})` }} + /> + )} + <div className="p-4"> + <div className="flex items-start gap-2 mb-2"> + <span className={`w-2 h-2 rounded-full mt-1.5 ${trackConfig.color.replace("text-", "bg-")}`} /> + <h3 className="text-sm font-medium line-clamp-2">{article.title || "Untitled"}</h3> + </div> + <div className="flex items-center gap-2 text-xs text-[var(--text-3)]"> + <span><span className="num">{wordCount}</span> words</span> + <span>·</span> + <span>{new Date(article.createdAt).toLocaleDateString()}</span> + </div> + {article.qtTweet && ( + <p className="text-xs text-[var(--text-4)] mt-2 line-clamp-2 italic"> + {article.qtTweet} + </p> + )} + {article.status === "posted" && article.impressions != null && ( + <div className="flex gap-2 text-xs text-[var(--text-3)] mt-2"> + <span>👀 {article.impressions.toLocaleString()}</span> + {article.likes != null && <span>❤️ {article.likes.toLocaleString()}</span>} + </div> + )} + </div> + </button> + ); +} + +// ─── Article Editor ─────────────────────────────────────────────────────────── + +function ArticleEditor({ + article, + onClose, + onUpdate, + onDelete, +}: { + article: Article; + onClose: () => void; + onUpdate: (updates: Partial<Article>) => void; + onDelete: () => void; +}) { + const [title, setTitle] = useState(article.title); + const [body, setBody] = useState(article.body); + const [qtTweet, setQtTweet] = useState(article.qtTweet || ""); + const [qtUrl, setQtUrl] = useState(article.qtUrl || ""); + const [status, setStatus] = useState(article.status); + const [track, setTrack] = useState<Track>(article.track); + const [postedUrl, setPostedUrl] = useState(article.postedUrl || ""); + const [impressions, setImpressions] = useState(article.impressions ?? ""); + const [likes, setLikes] = useState(article.likes ?? ""); + const [bookmarks, setBookmarks] = useState(article.bookmarks ?? ""); + const [heroImageUrl, setHeroImageUrl] = useState(article.heroImageUrl || ""); + const todayStr = new Date().toISOString().split("T")[0]; + const [postedDate, setPostedDate] = useState( + article.postedAt ? new Date(article.postedAt).toISOString().split("T")[0] + : article.scheduledDate || todayStr + ); + const [prevStatus, setPrevStatus] = useState(article.status); + + // Visual generation state + interface VisualPlanItem { + position: string; + type: string; + description: string; + content: string; + canGenerate: boolean; + userAction: string | null; + html?: string; + generating?: boolean; + } + const [visualPlan, setVisualPlan] = useState<VisualPlanItem[]>([]); + const [planningVisuals, setPlanningVisuals] = useState(false); + const [generatedVisuals, setGeneratedVisuals] = useState<{ type: string; html: string }[]>([]); + const [selectedVisualIndex, setSelectedVisualIndex] = useState<number | null>(null); + const [editorTab, setEditorTab] = useState<"write" | "visuals">("write"); + + // QT generation state for library editor + const [generatingEditorQT, setGeneratingEditorQT] = useState(false); + const [saveConfirm, setSaveConfirm] = useState(false); + const [refreshingMetrics, setRefreshingMetrics] = useState(false); + + const handleRefreshMetrics = async () => { + if (!postedUrl) return; + setRefreshingMetrics(true); + try { + const res = await fetch("/api/articles/scrape", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: postedUrl }), + }); + if (res.ok) { + const data = await res.json(); + if (data.impressions != null) setImpressions(data.impressions); + if (data.likes != null) setLikes(data.likes); + if (data.bookmarks != null) setBookmarks(data.bookmarks); + } + } finally { + setRefreshingMetrics(false); + } + }; + + // When status changes to "posted", auto-set date to today + if (status === "posted" && prevStatus !== "posted") { + setPrevStatus("posted"); + if (!postedDate) setPostedDate(todayStr); + } else if (status !== prevStatus) { + setPrevStatus(status); + } + + const handleSave = () => { + const updates: Partial<Article> & Record<string, any> = { + title, + body, + qtTweet: qtTweet || null, + qtUrl: qtUrl || null, + status, + track, + postedUrl: postedUrl || null, + heroImageUrl: heroImageUrl || null, + impressions: impressions !== "" ? Number(impressions) : null, + likes: likes !== "" ? Number(likes) : null, + bookmarks: bookmarks !== "" ? Number(bookmarks) : null, + }; + + if (status === "posted") { + updates.postedAt = postedDate ? new Date(postedDate).toISOString() : new Date().toISOString(); + updates.scheduledDate = postedDate || todayStr; + } + + onUpdate(updates); + setSaveConfirm(true); + setTimeout(() => setSaveConfirm(false), 2000); + }; + + const handlePlanVisuals = async () => { + setPlanningVisuals(true); + setVisualPlan([]); + try { + const res = await fetch("/api/articles/generate-visuals", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + articleId: article.id, + title, + articleBody: body, + track: track, + mode: "plan", + }), + }); + if (res.ok) { + const data = await res.json(); + setVisualPlan(data.plan || []); + } + } finally { + setPlanningVisuals(false); + } + }; + + const handleGeneratePlanItem = async (index: number) => { + const item = visualPlan[index]; + if (!item || !item.canGenerate) return; + + setVisualPlan((prev) => prev.map((p, i) => i === index ? { ...p, generating: true } : p)); + try { + const res = await fetch("/api/articles/generate-visuals", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + articleId: article.id, + title, + articleBody: body, + track: track, + visualType: item.type, + content: item.content, + }), + }); + if (res.ok) { + const data = await res.json(); + const html = data.visuals?.[0]?.html || ""; + setVisualPlan((prev) => + prev.map((p, i) => i === index ? { ...p, html, generating: false } : p) + ); + if (html) { + setGeneratedVisuals((prev) => [...prev, { type: item.type, html }]); + } + } + } catch { + setVisualPlan((prev) => prev.map((p, i) => i === index ? { ...p, generating: false } : p)); + } + }; + + const handleGenerateAllFromPlan = async () => { + const generatable = visualPlan + .map((item, i) => ({ item, i })) + .filter(({ item }) => item.canGenerate && !item.html); + for (const { i } of generatable) { + await handleGeneratePlanItem(i); + } + }; + + const handleUseAsHero = (html: string) => { + onUpdate({ heroImageHtml: html }); + }; + + const handleGenerateEditorQT = async () => { + setGeneratingEditorQT(true); + try { + const res = await fetch("/api/articles/generate-qt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + articleId: article.id, + title, + body, + track: article.track, + }), + }); + if (res.ok) { + const data = await res.json(); + setQtTweet(data.qtTweet); + } + } finally { + setGeneratingEditorQT(false); + } + }; + + const trackConfig = TRACK_CONFIG[track]; + + return ( + <div className="space-y-6"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <button onClick={onClose} className="text-[var(--text-3)] hover:text-[var(--text)] transition-colors"> + ← Back to Library + </button> + <select + value={track} + onChange={(e) => setTrack(e.target.value as Track)} + className={`text-xs px-2 py-1 rounded-full border-0 cursor-pointer focus:outline-none ${trackConfig.bgColor} ${trackConfig.color}`} + > + <option value="mega-viral">🔴 Mega Viral</option> + <option value="local-viral">🟡 Local Viral</option> + <option value="icp-viral">🟢 ICP Viral</option> + </select> + </div> + <div className="flex items-center gap-3"> + {/* Editor/Visuals tab toggle */} + <div className="flex gap-1 bg-[var(--surface-2)] rounded-[var(--r-md)] p-0.5"> + <button + onClick={() => setEditorTab("write")} + className={`px-3 py-1.5 rounded-md text-xs font-medium transition ${ + editorTab === "write" ? "bg-[var(--surface-3)] text-[var(--text)]" : "text-[var(--text-3)] hover:text-[var(--text)]" + }`} + > + Write + </button> + <button + onClick={() => setEditorTab("visuals")} + className={`px-3 py-1.5 rounded-md text-xs font-medium transition ${ + editorTab === "visuals" ? "bg-[var(--surface-3)] text-[var(--text)]" : "text-[var(--text-3)] hover:text-[var(--text)]" + }`} + > + Visuals + </button> + </div> + <button + onClick={onDelete} + className="px-3 py-1.5 text-[var(--down)] hover:bg-[color-mix(in_srgb,var(--down)_10%,transparent)] rounded-[var(--r-md)] text-sm" + > + Delete + </button> + </div> + </div> + + {editorTab === "write" && ( + <div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> + <div className="lg:col-span-3 space-y-4"> + <input + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-4 text-lg font-bold text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + <textarea + value={body} + onChange={(e) => setBody(e.target.value)} + className="w-full h-[400px] bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-4 text-sm leading-relaxed text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)] resize-none" + /> + <div className="text-xs text-[var(--text-3)]"> + <span className="num">{body.split(/\s+/).filter(Boolean).length}</span> words + </div> + + {/* QT Tweet */} + <div className="border-t border-[var(--line)] pt-4"> + <h4 className="text-sm font-semibold mb-2 text-[var(--text)]">QT Tweet</h4> + <textarea + value={qtTweet} + onChange={(e) => setQtTweet(e.target.value)} + placeholder="Write the hook tweet..." + className="w-full h-24 bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-4 text-sm text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)] resize-none" + /> + <button + onClick={handleGenerateEditorQT} + disabled={generatingEditorQT} + className="mt-2 text-xs text-[var(--text-3)] hover:text-[var(--text)] transition-colors disabled:opacity-50" + > + {generatingEditorQT ? "Generating..." : qtTweet ? "🔄 Regenerate QT" : "Generate QT Tweet"} + </button> + + {/* QT URL */} + <div className="mt-3 space-y-1"> + <label className="text-[10px] text-[var(--text-3)] uppercase">QT Tweet Link</label> + <input + type="url" + value={qtUrl} + onChange={(e) => setQtUrl(e.target.value)} + placeholder="https://x.com/yourhandle/status/... (the QT post)" + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-2 text-xs text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + {qtUrl && ( + <a href={qtUrl} target="_blank" rel="noreferrer" className="text-xs text-[var(--accent)] hover:opacity-80"> + View QT on X ↗ + </a> + )} + </div> + </div> + + <div className="flex items-center gap-3"> + <button + onClick={handleSave} + className={`px-4 py-2 text-sm ${ + saveConfirm + ? "rounded-full bg-[var(--up)] text-[#0a0b0d] font-semibold" + : "btn-primary" + }`} + > + {saveConfirm ? "Saved ✓" : "Save Changes"} + </button> + {saveConfirm && ( + <span className="text-xs text-[var(--up)] animate-pulse">Changes saved</span> + )} + </div> + </div> + + {/* Sidebar */} + <div className="space-y-4"> + <div className="panel p-4 space-y-3"> + <h4 className="eyebrow">Status</h4> + <select + value={status} + onChange={(e) => setStatus(e.target.value as Article["status"])} + className="w-full bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-md)] p-2 text-sm text-[var(--text)] focus:outline-none focus:border-[var(--line-strong)]" + > + <option value="idea">Idea</option> + <option value="draft">Draft</option> + <option value="ready">Ready</option> + <option value="posted">Posted</option> + </select> + </div> + + {/* Hero Image */} + <div className="panel p-4 space-y-3"> + <h4 className="eyebrow">Hero Image</h4> + {heroImageUrl ? ( + <div className="space-y-2"> + <div className="rounded-[var(--r-md)] overflow-hidden border border-[var(--line)]"> + <img + src={heroImageUrl} + alt="Hero preview" + className="w-full h-auto object-cover" + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} + /> + </div> + <button + onClick={() => setHeroImageUrl("")} + className="text-xs text-[var(--down)] hover:opacity-80" + > + Remove image + </button> + </div> + ) : ( + <label className="flex flex-col items-center gap-2 p-4 border border-dashed border-[var(--line)] rounded-[var(--r-md)] cursor-pointer hover:border-[var(--line-strong)] transition"> + <span className="text-[var(--text-3)] text-xs">Click to upload image</span> + <span className="text-[var(--text-4)] text-[10px]">PNG, JPG, WebP</span> + <input + type="file" + accept="image/*" + className="hidden" + onChange={(e) => { + const file = e.target.files?.[0]; + if (!file) return; + // Compress image to fit within API body limits + const img = new Image(); + const reader = new FileReader(); + reader.onload = () => { + img.onload = () => { + const canvas = document.createElement("canvas"); + const maxW = 1200; + const scale = Math.min(1, maxW / img.width); + canvas.width = img.width * scale; + canvas.height = img.height * scale; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + const compressed = canvas.toDataURL("image/jpeg", 0.8); + setHeroImageUrl(compressed); + }; + img.src = reader.result as string; + }; + reader.readAsDataURL(file); + }} + /> + </label> + )} + </div> + + {(status === "posted" || status === "ready") && ( + <div className="panel p-4 space-y-3"> + <h4 className="eyebrow">Article Link</h4> + <input + type="url" + value={postedUrl} + onChange={(e) => setPostedUrl(e.target.value)} + placeholder="https://x.com/yourhandle/status/..." + className="w-full bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-md)] p-2 text-xs text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + {postedUrl && ( + <a + href={postedUrl} + target="_blank" + rel="noreferrer" + className="text-xs text-[var(--accent)] hover:opacity-80 block" + > + View on X ↗ + </a> + )} + </div> + )} + + {status === "posted" && ( + <div className="panel p-4 space-y-3"> + <h4 className="eyebrow">Posted Date</h4> + <input + type="date" + value={postedDate} + onChange={(e) => setPostedDate(e.target.value)} + className="w-full bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-md)] p-2 text-xs text-[var(--text)] focus:outline-none focus:border-[var(--line-strong)] [color-scheme:dark]" + /> + </div> + )} + + {status === "posted" && ( + <div className="panel p-4 space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="eyebrow">Metrics</h4> + {postedUrl && ( + <button + onClick={handleRefreshMetrics} + disabled={refreshingMetrics} + className="text-[10px] text-[var(--text-3)] hover:text-[var(--accent)] disabled:opacity-50 transition" + > + {refreshingMetrics ? "Refreshing..." : "🔄 Refresh"} + </button> + )} + </div> + <div className="space-y-2"> + <div className="space-y-1"> + <label className="text-[10px] text-[var(--text-3)] uppercase">Impressions</label> + <input + type="number" + value={impressions} + onChange={(e) => setImpressions(e.target.value === "" ? "" : Number(e.target.value))} + placeholder="0" + className="w-full bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-md)] p-2 text-xs num text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + </div> + <div className="space-y-1"> + <label className="text-[10px] text-[var(--text-3)] uppercase">Likes</label> + <input + type="number" + value={likes} + onChange={(e) => setLikes(e.target.value === "" ? "" : Number(e.target.value))} + placeholder="0" + className="w-full bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-md)] p-2 text-xs num text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + </div> + <div className="space-y-1"> + <label className="text-[10px] text-[var(--text-3)] uppercase">Bookmarks</label> + <input + type="number" + value={bookmarks} + onChange={(e) => setBookmarks(e.target.value === "" ? "" : Number(e.target.value))} + placeholder="0" + className="w-full bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-md)] p-2 text-xs num text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)]" + /> + </div> + </div> + </div> + )} + + <div className="text-xs text-[var(--text-4)]"> + Created: {new Date(article.createdAt).toLocaleString()} + <br /> + Updated: {new Date(article.updatedAt).toLocaleString()} + </div> + </div> + </div> + )} + + {editorTab === "visuals" && ( + <div className="space-y-6"> + {/* Plan Button */} + <div className="flex items-center gap-3"> + <button + onClick={handlePlanVisuals} + disabled={planningVisuals} + className="btn-primary px-5 py-2.5 text-sm flex items-center gap-2 disabled:opacity-50" + > + {planningVisuals ? ( + <><span className="animate-spin">⏳</span> Analyzing article...</> + ) : ( + <>Analyze Article & Plan Visuals</> + )} + </button> + {visualPlan.length > 0 && ( + <button + onClick={handleGenerateAllFromPlan} + disabled={planningVisuals} + className="btn-ghost px-4 py-2 text-sm" + > + Generate All (<span className="num">{visualPlan.filter((p) => p.canGenerate && !p.html).length}</span>) + </button> + )} + </div> + + {/* Visual Plan */} + {visualPlan.length > 0 && ( + <div className="space-y-4"> + <h3 className="text-sm font-semibold text-[var(--text-2)]"> + Visual Plan (<span className="num">{visualPlan.length}</span> visuals recommended) + </h3> + <div className="space-y-3"> + {visualPlan.map((item, i) => ( + <div key={i} className={`bg-[var(--surface-1)] border rounded-[var(--r-lg)] overflow-hidden ${ + item.canGenerate ? "border-[var(--line)]" : "border-[color-mix(in_srgb,var(--warn)_25%,transparent)]" + }`}> + <div className="p-4 space-y-2"> + <div className="flex items-start justify-between gap-3"> + <div className="flex-1"> + <div className="flex items-center gap-2 mb-1"> + <span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded bg-[var(--surface-2)] text-[var(--text-2)]"> + {item.type} + </span> + <span className={`text-[10px] px-2 py-0.5 rounded ${ + item.canGenerate + ? "bg-[color-mix(in_srgb,var(--up)_12%,transparent)] text-[var(--up)]" + : "bg-[color-mix(in_srgb,var(--warn)_12%,transparent)] text-[var(--warn)]" + }`}> + {item.canGenerate ? "Can generate" : "User action needed"} + </span> + </div> + <p className="text-xs text-[var(--text-2)] font-medium">{item.description}</p> + <p className="text-[10px] text-[var(--text-3)] mt-1">{item.position}</p> + {item.content && ( + <p className="text-[10px] text-[var(--text-4)] mt-1 line-clamp-2">Data: {item.content}</p> + )} + {item.userAction && ( + <p className="text-xs text-[var(--warn)] mt-2">→ {item.userAction}</p> + )} + </div> + {item.canGenerate && ( + <button + onClick={() => handleGeneratePlanItem(i)} + disabled={!!item.generating || !!item.html} + className={`px-3 py-1.5 rounded-[var(--r-md)] text-xs transition flex-shrink-0 ${ + item.html + ? "bg-[color-mix(in_srgb,var(--up)_18%,transparent)] text-[var(--up)]" + : item.generating + ? "bg-[var(--surface-3)] text-[var(--text-3)]" + : "bg-[color-mix(in_srgb,var(--accent)_18%,transparent)] text-[var(--accent)] hover:bg-[color-mix(in_srgb,var(--accent)_28%,transparent)]" + }`} + > + {item.html ? "Generated ✓" : item.generating ? "Generating..." : "Generate"} + </button> + )} + </div> + </div> + {/* Preview generated visual */} + {item.html && ( + <div className="border-t border-[var(--line)]"> + <iframe + srcDoc={item.html} + className="w-full border-0" + style={{ height: "300px" }} + sandbox="allow-same-origin" + title={`${item.type} visual`} + /> + <div className="px-4 py-2 border-t border-[var(--line)] flex gap-2"> + <button + onClick={() => { handleUseAsHero(item.html!); }} + className="px-3 py-1 bg-[var(--surface-2)] text-[var(--text-2)] hover:text-[var(--text)] rounded-[var(--r-md)] text-xs transition" + > + Use as Hero + </button> + <button + onClick={() => { + const blob = new Blob([item.html!], { type: "text/html" }); + const u = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = u; a.download = `${article.id}-${item.type}.html`; + a.click(); URL.revokeObjectURL(u); + }} + className="px-3 py-1 bg-[var(--surface-2)] text-[var(--text-2)] hover:text-[var(--text)] rounded-[var(--r-md)] text-xs transition" + > + Download + </button> + <button + onClick={() => { navigator.clipboard.writeText(item.html!); }} + className="px-3 py-1 bg-[var(--surface-2)] text-[var(--text-2)] hover:text-[var(--text)] rounded-[var(--r-md)] text-xs transition" + > + Copy HTML + </button> + </div> + </div> + )} + </div> + ))} + </div> + </div> + )} + + {/* Existing Hero */} + {article.heroImageHtml && visualPlan.length === 0 && !planningVisuals && ( + <div className="space-y-3"> + <h3 className="text-sm font-semibold text-[var(--text-2)]">Current Hero Visual</h3> + <div className="panel overflow-hidden"> + <iframe + srcDoc={article.heroImageHtml} + className="w-full border-0" + style={{ height: "350px" }} + sandbox="allow-same-origin" + title="Current hero visual" + /> + </div> + </div> + )} + + {/* Empty state */} + {visualPlan.length === 0 && !article.heroImageHtml && !planningVisuals && ( + <div className="bg-[var(--surface-1)] border border-dashed border-[var(--line)] rounded-[var(--r-lg)] p-12 text-center"> + <p className="text-[var(--text-3)] text-sm">No visuals yet</p> + <p className="text-[var(--text-4)] text-xs mt-1"> + Click "Analyze Article & Plan Visuals" to get started + </p> + </div> + )} + </div> + )} + </div> + ); +} + +// ─── Calendar Tab ───────────────────────────────────────────────────────────── + +interface CalendarTabProps { + articles: Article[]; + weekDays: { date: string; label: string; dayName: string; isToday: boolean }[]; + weekOffset: number; + onPrevWeek: () => void; + onNextWeek: () => void; + onToday: () => void; + onUpdateArticle: (id: string, updates: Partial<Article>) => void; + onOpenArticle: (article: Article) => void; +} + +function CalendarTab({ + articles, + weekDays, + weekOffset, + onPrevWeek, + onNextWeek, + onToday, + onUpdateArticle, + onOpenArticle, +}: CalendarTabProps) { + const handleDrop = (date: string, articleId: string) => { + onUpdateArticle(articleId, { scheduledDate: date }); + }; + + return ( + <div className="space-y-4"> + {/* Navigation */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <button + onClick={onPrevWeek} + className="px-3 py-1.5 bg-[var(--surface-2)] hover:bg-[var(--surface-3)] rounded-[var(--r-md)] text-sm text-[var(--text)]" + > + ← Prev + </button> + <button + onClick={onToday} + className={`px-3 py-1.5 rounded-[var(--r-md)] text-sm ${ + weekOffset === 0 + ? "bg-[color-mix(in_srgb,var(--accent)_18%,transparent)] text-[var(--accent)]" + : "bg-[var(--surface-2)] hover:bg-[var(--surface-3)] text-[var(--text)]" + }`} + > + Today + </button> + <button + onClick={onNextWeek} + className="px-3 py-1.5 bg-[var(--surface-2)] hover:bg-[var(--surface-3)] rounded-[var(--r-md)] text-sm text-[var(--text)]" + > + Next → + </button> + </div> + <span className="text-sm text-[var(--text-2)]"> + {weekDays[0]?.label} - {weekDays[6]?.label} + </span> + </div> + + {/* Calendar Grid */} + <div className="grid grid-cols-7 gap-2"> + {weekDays.map((day) => { + const dayArticles = articles.filter((a) => a.scheduledDate === day.date); + + return ( + <div + key={day.date} + className={`min-h-[200px] p-3 rounded-[var(--r-lg)] border ${ + day.isToday + ? "border-[color-mix(in_srgb,var(--accent)_30%,transparent)] bg-[color-mix(in_srgb,var(--accent)_6%,transparent)]" + : "border-[var(--line)] bg-[var(--surface-1)]" + }`} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const articleId = e.dataTransfer.getData("text/plain"); + if (articleId) handleDrop(day.date, articleId); + }} + > + <div className="flex items-center gap-2 mb-2"> + <span + className={`text-xs font-medium ${ + day.isToday ? "text-[var(--accent)]" : "text-[var(--text-3)]" + }`} + > + {day.dayName} + </span> + <span + className={`text-xs num ${ + day.isToday ? "text-[var(--accent)]" : "text-[var(--text-4)]" + }`} + > + {day.label} + </span> + </div> + + <div className="space-y-2"> + {dayArticles.map((article) => { + const trackConfig = TRACK_CONFIG[article.track]; + return ( + <div + key={article.id} + draggable + onDragStart={(e) => { + e.dataTransfer.setData("text/plain", article.id); + }} + onClick={() => onOpenArticle(article)} + className={`p-2 rounded-lg border cursor-pointer hover:brightness-125 transition ${trackConfig.bgColor} ${trackConfig.borderColor}`} + > + {article.heroImageUrl && ( + <div + className="w-full h-12 rounded bg-cover bg-center mb-2" + style={{ backgroundImage: `url(${article.heroImageUrl})` }} + /> + )} + <p className="text-xs font-medium line-clamp-2"> + {article.title || "Untitled"} + </p> + <div className="flex items-center gap-1 mt-1"> + <span + className={`w-1.5 h-1.5 rounded-full ${trackConfig.color.replace( + "text-", + "bg-" + )}`} + /> + <span className="text-[10px] text-[var(--text-3)]"> + {trackConfig.label} + </span> + </div> + {article.status === "posted" && article.impressions != null && ( + <div className="text-[10px] text-[var(--text-3)] mt-1"> + 👀 {article.impressions.toLocaleString()} + {article.likes != null && ` · ❤️ ${article.likes.toLocaleString()}`} + </div> + )} + </div> + ); + })} + </div> + </div> + ); + })} + </div> + + {/* Unscheduled Articles */} + <div className="border-t border-[var(--line)] pt-4 mt-6"> + <h3 className="text-sm font-semibold mb-3 text-[var(--text-2)]"> + Unscheduled Articles (drag to calendar) + </h3> + <div className="flex flex-wrap gap-2"> + {articles + .filter((a) => !a.scheduledDate && a.status !== "posted") + .map((article) => { + const trackConfig = TRACK_CONFIG[article.track]; + return ( + <div + key={article.id} + draggable + onDragStart={(e) => { + e.dataTransfer.setData("text/plain", article.id); + }} + className={`p-2 rounded-lg border cursor-move max-w-[200px] ${trackConfig.bgColor} ${trackConfig.borderColor}`} + > + <p className="text-xs font-medium line-clamp-1"> + {article.title || "Untitled"} + </p> + <span className="text-[10px] text-[var(--text-3)]">{article.status}</span> + </div> + ); + })} + {articles.filter((a) => !a.scheduledDate && a.status !== "posted").length === 0 && ( + <p className="text-xs text-[var(--text-4)]">All articles are scheduled or posted</p> + )} + </div> + </div> + </div> + ); +} diff --git a/src/app/client-pulse/page.tsx b/src/app/client-pulse/page.tsx new file mode 100644 index 0000000..2fbf114 --- /dev/null +++ b/src/app/client-pulse/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Panel, SectionHeader, Eyebrow, Skeleton, EmptyState, Pill } from "@/components/ui/kit"; + +type PulseClient = { + clientId: string; + clientName: string; + productManagerName?: string; + services: string[]; + status?: string; + renewalDate?: string; + endDate?: string; + score: number; + category: "healthy" | "watch" | "needs_attention" | "urgent"; + sentimentScore: number; + responseScore: number; + cadenceScore: number; + renewalRiskScore: number; + reasons: string[]; + evidence: string[]; + recommendedAction?: string; + llmSummary?: string; + analysisSource: string; + analyzedAt: string; + chats: { telegramChatId: string; title: string; lastSeenAt?: string }[]; + lastClientMessage?: string; + lastInternalReply?: string; +}; + +type PulseData = { + average: number | null; + counts: { urgent: number; needsAttention: number; watch: number; healthy: number }; + clients: PulseClient[]; + unmappedChats: { telegramChatId: string; title: string; lastSeenAt?: string }[]; +}; + +const categoryLabel: Record<PulseClient["category"], string> = { + urgent: "Urgent", + needs_attention: "Needs attention", + watch: "Watch", + healthy: "Healthy", +}; + +const categoryTone: Record<PulseClient["category"], "down" | "warn" | "neutral" | "up"> = { + urgent: "down", + needs_attention: "warn", + watch: "neutral", + healthy: "up", +}; + +function scoreColor(score: number) { + if (score >= 85) return "var(--up)"; + if (score >= 70) return "var(--warn)"; + if (score >= 50) return "var(--accent)"; + return "var(--down)"; +} + +function dateLabel(value?: string) { + if (!value) return "—"; + return new Date(value).toLocaleDateString("en-GB", { timeZone: "Europe/Amsterdam" }); +} + +export default function ClientPulsePage() { + const [data, setData] = useState<PulseData | null>(null); + const [loading, setLoading] = useState(true); + const [category, setCategory] = useState("all"); + const [service, setService] = useState("all"); + const [owner, setOwner] = useState("all"); + + useEffect(() => { + fetch("/api/client-pulse") + .then((res) => res.json()) + .then((json) => setData(json)) + .finally(() => setLoading(false)); + }, []); + + const services = useMemo(() => Array.from(new Set((data?.clients || []).flatMap((client) => client.services))).sort(), [data]); + const owners = useMemo(() => Array.from(new Set((data?.clients || []).map((client) => client.productManagerName || "Unassigned"))).sort(), [data]); + const clients = useMemo(() => { + return (data?.clients || []).filter((client) => { + if (category !== "all" && client.category !== category) return false; + if (service !== "all" && !client.services.includes(service)) return false; + if (owner !== "all" && (client.productManagerName || "Unassigned") !== owner) return false; + return true; + }); + }, [data, category, service, owner]); + + const selectClass = "bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] px-3 py-2 text-sm text-[var(--text)]"; + + if (loading) { + return ( + <div className="relative z-10 p-8 space-y-8"> + <Skeleton className="h-16 w-72" /> + <div className="grid grid-cols-2 md:grid-cols-4 gap-3"> + {Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)} + </div> + <div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> + {Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-64" />)} + </div> + </div> + ); + } + + return ( + <div className="relative z-10 p-8 space-y-8 text-[var(--text)]"> + <header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> + <div> + <Eyebrow>Quality control</Eyebrow> + <h1 className="mt-1 text-[28px] font-semibold tracking-[-0.02em] text-[var(--text)]">Client Pulse</h1> + <p className="text-sm text-[var(--text-2)] mt-1">Telegram client sentiment, response SLAs, check-in cadence, and renewal risk.</p> + </div> + <div className="text-right"> + <Eyebrow>Average score</Eyebrow> + <p className="num text-[40px] font-semibold tracking-[-0.02em] leading-none mt-1" style={{ color: scoreColor(data?.average || 0) }}>{data?.average ?? "—"}</p> + </div> + </header> + + <section className="grid grid-cols-2 md:grid-cols-4 gap-3"> + <Panel className="p-4"> + <Eyebrow className="text-[var(--down)]">Urgent</Eyebrow> + <p className="num text-[28px] font-semibold tracking-[-0.02em] mt-1">{data?.counts.urgent || 0}</p> + </Panel> + <Panel className="p-4"> + <Eyebrow className="text-[var(--warn)]">Needs attention</Eyebrow> + <p className="num text-[28px] font-semibold tracking-[-0.02em] mt-1">{data?.counts.needsAttention || 0}</p> + </Panel> + <Panel className="p-4"> + <Eyebrow>Watch</Eyebrow> + <p className="num text-[28px] font-semibold tracking-[-0.02em] mt-1">{data?.counts.watch || 0}</p> + </Panel> + <Panel className="p-4"> + <Eyebrow className="text-[var(--up)]">Healthy</Eyebrow> + <p className="num text-[28px] font-semibold tracking-[-0.02em] mt-1">{data?.counts.healthy || 0}</p> + </Panel> + </section> + + <section className="flex flex-wrap gap-3"> + <select value={category} onChange={(event) => setCategory(event.target.value)} className={selectClass}> + <option value="all">All categories</option> + {Object.entries(categoryLabel).map(([value, label]) => <option key={value} value={value}>{label}</option>)} + </select> + <select value={service} onChange={(event) => setService(event.target.value)} className={selectClass}> + <option value="all">All services</option> + {services.map((item) => <option key={item} value={item}>{item}</option>)} + </select> + <select value={owner} onChange={(event) => setOwner(event.target.value)} className={selectClass}> + <option value="all">All PMs</option> + {owners.map((item) => <option key={item} value={item}>{item}</option>)} + </select> + </section> + + <section className="grid grid-cols-1 xl:grid-cols-2 gap-4"> + {clients.map((client) => ( + <Panel key={client.clientId} className="p-5 space-y-4"> + <div className="flex items-start justify-between gap-4"> + <div> + <div className="flex items-center gap-2 flex-wrap"> + <h2 className="text-lg font-semibold tracking-[-0.015em] text-[var(--text)]">{client.clientName}</h2> + <Pill tone={categoryTone[client.category]}>{categoryLabel[client.category]}</Pill> + </div> + <p className="text-sm text-[var(--text-3)] mt-1">{client.productManagerName || "Unassigned"} · {client.services.join(", ") || "No service"} · {client.status || "No status"}</p> + </div> + <div className="text-right"> + <p className="num text-[40px] font-semibold tracking-[-0.02em] leading-none" style={{ color: scoreColor(client.score) }}>{client.score}</p> + <p className="text-xs text-[var(--text-3)] mt-1">overall</p> + </div> + </div> + <div className="grid grid-cols-4 gap-2 text-center"> + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3"><p className="text-xs text-[var(--text-3)]">Sentiment</p><p className="num font-semibold text-[var(--text)] mt-0.5">{client.sentimentScore}</p></div> + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3"><p className="text-xs text-[var(--text-3)]">Response</p><p className="num font-semibold text-[var(--text)] mt-0.5">{client.responseScore}</p></div> + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3"><p className="text-xs text-[var(--text-3)]">Cadence</p><p className="num font-semibold text-[var(--text)] mt-0.5">{client.cadenceScore}</p></div> + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3"><p className="text-xs text-[var(--text-3)]">Renewal</p><p className="num font-semibold text-[var(--text)] mt-0.5">{client.renewalRiskScore}</p></div> + </div> + <div className="text-sm text-[var(--text-2)] space-y-2"> + <p><span className="text-[var(--text-3)]">Renewal/end:</span> {dateLabel(client.renewalDate || client.endDate)}</p> + {client.llmSummary && <p><span className="text-[var(--text-3)]">Summary:</span> {client.llmSummary}</p>} + {client.recommendedAction && <p><span className="text-[var(--text-3)]">Action:</span> {client.recommendedAction}</p>} + {client.lastClientMessage && <p><span className="text-[var(--text-3)]">Last client:</span> {client.lastClientMessage}</p>} + {client.lastInternalReply && <p><span className="text-[var(--text-3)]">Last internal:</span> {client.lastInternalReply}</p>} + </div> + <div className="space-y-1"> + {client.reasons.slice(0, 4).map((reason) => <p key={reason} className="text-xs text-[var(--text-3)]">• {reason}</p>)} + {client.evidence.slice(0, 2).map((evidence) => <p key={evidence} className="text-xs text-[var(--text-2)] border-l border-[var(--line)] pl-3">{evidence}</p>)} + </div> + </Panel> + ))} + </section> + + <Panel className="p-5"> + <SectionHeader title="Unmapped Telegram chats" /> + {!data?.unmappedChats.length && ( + <EmptyState title="No unmapped chats discovered yet." /> + )} + <div className="space-y-2"> + {data?.unmappedChats.map((chat) => ( + <div key={chat.telegramChatId} className="flex justify-between border border-[var(--line)] rounded-[var(--r-md)] p-3 text-sm text-[var(--text)]"> + <span>{chat.title}</span> + <code className="num text-[var(--text-3)]">{chat.telegramChatId}</code> + </div> + ))} + </div> + </Panel> + </div> + ); +} diff --git a/src/app/content-os/page.tsx b/src/app/content-os/page.tsx new file mode 100644 index 0000000..5bc97f7 --- /dev/null +++ b/src/app/content-os/page.tsx @@ -0,0 +1,519 @@ +"use client"; + +/* ─────────────────────────────────────────────────────────── + Content OS — X pipeline board for Hermy HQ + A growth-engine view over existing tweet drafts. + Read-first: cards link to /x-content for editing. + ─────────────────────────────────────────────────────────── */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Lightbulb, + CheckCircle2, + CalendarClock, + Send, + Trophy, + ArrowUpRight, + Flame, + RefreshCw, +} from "lucide-react"; +import { + Panel, + SectionHeader, + Pill, + EmptyState, + Skeleton, + Eyebrow, +} from "@/components/ui/kit"; + +// ── Types (mirrors the /api/x-content shape) ──────────────── +interface Draft { + id: string; + text: string; + title?: string | null; + model?: string; + type?: string; + status: "pending" | "approved" | "scheduled" | "posted" | "rejected" | string; + category?: string | null; + hookType?: string | null; + viralScore?: number | Record<string, unknown> | null; + scheduledDate?: string | null; + scheduledTime?: string | null; + createdAt?: string; + postedAt?: string | null; + tweetUrl?: string | null; + postedUrl?: string | null; + tweetId?: string | null; + views?: number | null; + metrics?: Record<string, unknown> | null; +} + +// ── Helpers ───────────────────────────────────────────────── +function fmt(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M"; + if (n >= 1_000) return Math.round(n / 1_000) + "K"; + return String(n); +} + +/** Highest view count across flat or checkpoint-shaped metrics. */ +function bestViews(d: Draft): number { + if (typeof d.views === "number") return d.views; + const m = d.metrics; + if (!m || typeof m !== "object") return 0; + if (typeof (m as Record<string, unknown>).views === "number") { + return (m as Record<string, number>).views; + } + let best = 0; + for (const v of Object.values(m)) { + if (v && typeof v === "object") { + const n = Number((v as Record<string, unknown>).views) || 0; + if (n > best) best = n; + } + } + return best; +} + +/** Normalize viralScore (number, {score}, {bookmarkScore}, or null) to 0–100. */ +function scoreOf(vs: Draft["viralScore"]): number | null { + if (vs == null) return null; + let raw: number | null = null; + if (typeof vs === "number") raw = vs; + else if (typeof vs === "object") { + const o = vs as Record<string, unknown>; + if (typeof o.score === "number") raw = o.score; + else if (typeof o.bookmarkScore === "number") raw = o.bookmarkScore; + } + if (raw == null || Number.isNaN(raw)) return null; + // Some scores are stored on a 0–10 scale, others 0–100. Lift small ones. + const norm = raw <= 10 ? raw * 10 : raw; + return Math.max(0, Math.min(100, Math.round(norm))); +} + +function scoreTone(score: number): "up" | "warn" | "neutral" { + if (score >= 70) return "up"; + if (score >= 40) return "warn"; + return "neutral"; +} + +function isTweet(d: Draft): boolean { + const t = (d.type || "tweet").toLowerCase(); + return t !== "idea" && t !== "content idea" && t !== "industry idea"; +} + +function withinDays(iso: string | null | undefined, days: number): boolean { + if (!iso) return false; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return false; + return Date.now() - t <= days * 86_400_000; +} + +// ── Column config ─────────────────────────────────────────── +type ColKey = "ideas" | "approved" | "scheduled" | "posted"; +const COLS: { + key: ColKey; + label: string; + icon: typeof Lightbulb; + accent: string; +}[] = [ + { key: "ideas", label: "Ideas", icon: Lightbulb, accent: "var(--warn)" }, + { key: "approved", label: "Approved", icon: CheckCircle2, accent: "var(--up)" }, + { key: "scheduled", label: "Scheduled", icon: CalendarClock, accent: "var(--accent)" }, + { key: "posted", label: "Posted", icon: Send, accent: "var(--accent)" }, +]; + +const CAP = 60; // max cards rendered per column (count in header shows the true total) + +// ── Viral score badge ─────────────────────────────────────── +function ScoreBadge({ score }: { score: number }) { + const tone = scoreTone(score); + return ( + <Pill tone={tone} className="num"> + {tone === "up" && <Flame className="w-3 h-3" strokeWidth={2.25} />} + {score} + </Pill> + ); +} + +// ── Pipeline card (stretched-link: whole card → /x-content) ── +function PipelineCard({ draft }: { draft: Draft }) { + const score = scoreOf(draft.viralScore); + const views = bestViews(draft); + const posted = draft.status === "posted"; + const tweetUrl = draft.tweetUrl || draft.postedUrl || null; + const preview = draft.text?.trim() || (posted ? "Posted directly on X" : "Untitled draft"); + + return ( + <div + className="panel panel-interactive relative p-3.5" + style={{ borderLeft: `2px solid color-mix(in srgb, ${ + posted ? "var(--accent)" : "var(--line-strong)" + } 55%, transparent)` }} + > + {/* stretched overlay link → composer */} + <a + href="/x-content" + aria-label="Open in composer" + className="absolute inset-0 z-0 rounded-[inherit]" + /> + + <div className="relative z-10 pointer-events-none space-y-2.5"> + {/* text preview */} + <p + className={`text-[13px] leading-snug text-[var(--text)] whitespace-pre-wrap ${ + draft.text?.trim() ? "" : "italic text-[var(--text-3)]" + }`} + style={{ + display: "-webkit-box", + WebkitLineClamp: 3, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }} + > + {preview} + </p> + + {/* chips row */} + <div className="flex flex-wrap items-center gap-1.5"> + {score != null && <ScoreBadge score={score} />} + {draft.category && ( + <span className="text-[10.5px] px-2 py-0.5 rounded-full font-medium text-[var(--text-3)] border border-[var(--line)]"> + {draft.category} + </span> + )} + {draft.hookType && ( + <span className="text-[10.5px] px-2 py-0.5 rounded-full font-medium text-[var(--text-3)] border border-[var(--line)]"> + {draft.hookType} + </span> + )} + </div> + + {/* scheduled slot */} + {draft.status === "scheduled" || (draft.status === "approved" && draft.scheduledDate) ? ( + draft.scheduledDate && ( + <div className="num text-[11px] flex items-center gap-1.5" style={{ color: "var(--accent)" }}> + <CalendarClock className="w-3.5 h-3.5" /> + {draft.scheduledDate} + {draft.scheduledTime ? ` · ${draft.scheduledTime}` : ""} + </div> + ) + ) : null} + + {/* posted metrics */} + {posted && ( + <div className="flex items-center justify-between gap-2 border-t border-[var(--line)] pt-2 mt-1"> + <span className="num text-[12px] font-semibold text-[var(--text-2)]"> + {fmt(views)} + <span className="text-[var(--text-3)] font-normal"> views</span> + </span> + {tweetUrl && ( + <a + href={tweetUrl} + target="_blank" + rel="noopener noreferrer" + className="pointer-events-auto num text-[11px] font-medium inline-flex items-center gap-0.5 text-[var(--accent)] hover:brightness-110" + > + View on X + <ArrowUpRight className="w-3 h-3" strokeWidth={2.25} /> + </a> + )} + </div> + )} + </div> + </div> + ); +} + +// ── Pipeline column ───────────────────────────────────────── +function Column({ + col, + drafts, +}: { + col: (typeof COLS)[number]; + drafts: Draft[]; +}) { + const Icon = col.icon; + const shown = drafts.slice(0, CAP); + return ( + <div className="flex flex-col min-w-0"> + <div className="flex items-center gap-2 px-1 pb-3"> + <Icon className="w-4 h-4" style={{ color: col.accent }} strokeWidth={2} /> + <span className="text-[13px] font-semibold text-[var(--text)]">{col.label}</span> + <span className="num text-[11px] text-[var(--text-3)] ml-auto">{drafts.length}</span> + </div> + <div + className="flex flex-col gap-2.5 overflow-y-auto pr-1 -mr-1" + style={{ maxHeight: "min(70vh, 720px)" }} + > + {shown.length === 0 ? ( + <div className="rounded-[var(--r-md)] border border-dashed border-[var(--line)] py-10 text-center text-[12px] text-[var(--text-3)]"> + Nothing here yet + </div> + ) : ( + shown.map((d) => <PipelineCard key={d.id} draft={d} />) + )} + {drafts.length > CAP && ( + <a + href="/x-content" + className="text-center text-[11px] text-[var(--text-3)] hover:text-[var(--text-2)] py-2" + > + + {drafts.length - CAP} more in composer → + </a> + )} + </div> + </div> + ); +} + +// ── Summary stat chip ─────────────────────────────────────── +function StatChip({ label, value, tone = "neutral" }: { label: string; value: string; tone?: string }) { + const color = + tone === "up" ? "var(--up)" : tone === "accent" ? "var(--accent)" : tone === "warn" ? "var(--warn)" : "var(--text-2)"; + return ( + <div className="rounded-[var(--r-md)] border border-[var(--line)] bg-[var(--surface-1)] px-4 py-2.5"> + <div className="num text-[20px] font-semibold leading-none" style={{ color }}> + {value} + </div> + <div className="eyebrow mt-1.5">{label}</div> + </div> + ); +} + +// ── Top performers ────────────────────────────────────────── +function TopPerformers({ posted }: { posted: Draft[] }) { + const ranked = useMemo( + () => + [...posted] + .map((d) => ({ d, views: bestViews(d), score: scoreOf(d.viralScore) })) + .filter((r) => r.views > 0) + .sort((a, b) => b.views - a.views) + .slice(0, 6), + [posted] + ); + + return ( + <> + <SectionHeader + label="What worked" + title="Top performers" + action={<span className="num text-[11px] text-[var(--text-3)]">by views</span>} + /> + {ranked.length === 0 ? ( + <Panel className="p-2"> + <EmptyState + icon={<Trophy className="w-6 h-6" />} + title="No view data yet" + hint="Posted tweets with metrics will rank here — the feedback loop for what to make more of." + /> + </Panel> + ) : ( + <Panel className="p-2"> + <div className="divide-y divide-[var(--line)]"> + {ranked.map((r, i) => { + const tweetUrl = r.d.tweetUrl || r.d.postedUrl || null; + const preview = r.d.text?.trim() || "Posted directly on X"; + return ( + <div key={r.d.id} className="flex items-start gap-3.5 px-3.5 py-3"> + <span + className="num text-[15px] font-semibold w-6 shrink-0 text-center" + style={{ color: i === 0 ? "var(--accent)" : "var(--text-3)" }} + > + {i + 1} + </span> + <div className="min-w-0 flex-1"> + <p + className={`text-[13px] leading-snug ${r.d.text?.trim() ? "text-[var(--text)]" : "italic text-[var(--text-3)]"}`} + style={{ + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }} + > + {preview} + </p> + <div className="flex items-center gap-3 mt-1.5"> + <span className="num text-[12px] font-semibold text-[var(--text-2)]"> + {fmt(r.views)} + <span className="text-[var(--text-3)] font-normal"> views</span> + </span> + {r.score != null && <ScoreBadge score={r.score} />} + {tweetUrl && ( + <a + href={tweetUrl} + target="_blank" + rel="noopener noreferrer" + className="num text-[11px] font-medium inline-flex items-center gap-0.5 text-[var(--accent)] hover:brightness-110 ml-auto" + > + View + <ArrowUpRight className="w-3 h-3" strokeWidth={2.25} /> + </a> + )} + </div> + </div> + </div> + ); + })} + </div> + </Panel> + )} + </> + ); +} + +// ── Page ──────────────────────────────────────────────────── +export default function ContentOSPage() { + const [drafts, setDrafts] = useState<Draft[]>([]); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState<string | null>(null); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + try { + // NOTE: the API treats ?status=all as a literal filter (matches nothing but + // injected posted tweets), so we fetch unfiltered to populate every column. + const res = await fetch("/api/x-content", { cache: "no-store" }); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json(); + setDrafts(Array.isArray(data) ? data : (data?.drafts ?? [])); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load"); + } finally { + setLoaded(true); + } + }, []); + + useEffect(() => { + load(); + const iv = setInterval(load, 15000); + return () => clearInterval(iv); + }, [load]); + + const manualRefresh = async () => { + setRefreshing(true); + await load(); + setRefreshing(false); + }; + + // ── Derive pipeline buckets ── + const { ideas, approved, scheduled, posted, stats } = useMemo(() => { + const tweets = drafts.filter(isTweet); + const ideas = tweets + .filter((d) => d.status === "pending") + .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "")); + const approved = tweets + .filter((d) => d.status === "approved" && !d.scheduledDate) + .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "")); + const scheduled = tweets + .filter((d) => d.status === "scheduled" || (d.status === "approved" && d.scheduledDate)) + .sort((a, b) => (a.scheduledDate || "").localeCompare(b.scheduledDate || "")); + const posted = tweets + .filter((d) => d.status === "posted") + .sort((a, b) => bestViews(b) - bestViews(a)); + + const postedThisWeek = posted.filter((d) => withinDays(d.postedAt || d.createdAt, 7)); + const viewsThisWeek = postedThisWeek.reduce((sum, d) => sum + bestViews(d), 0); + + return { + ideas, + approved, + scheduled, + posted, + stats: { + pending: ideas.length, + scheduled: scheduled.length, + postedThisWeek: postedThisWeek.length, + viewsThisWeek, + }, + }; + }, [drafts]); + + const byCol: Record<ColKey, Draft[]> = { ideas, approved, scheduled, posted }; + + return ( + <> + <div className="relative z-10 w-full mx-auto pb-16"> + {/* Header */} + <div className="hq-rise pt-4 pb-8 flex items-end justify-between gap-4 flex-wrap"> + <div> + <Eyebrow>Content OS</Eyebrow> + <h1 className="mt-2.5 text-[40px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]"> + X Pipeline + </h1> + <p className="mt-3 text-[14px] text-[var(--text-2)]"> + Every tweet from idea to impact. + </p> + </div> + <button + type="button" + onClick={manualRefresh} + aria-label="Refresh" + className="btn-ghost inline-flex items-center justify-center w-9 h-9" + > + <RefreshCw className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`} /> + </button> + </div> + + {/* Summary chips */} + <div className="hq-rise flex flex-wrap gap-3"> + <StatChip label="In ideas" value={loaded ? String(stats.pending) : "—"} tone="warn" /> + <StatChip label="Scheduled" value={loaded ? String(stats.scheduled) : "—"} tone="accent" /> + <StatChip label="Posted this week" value={loaded ? String(stats.postedThisWeek) : "—"} tone="up" /> + <StatChip label="Views this week" value={loaded ? fmt(stats.viewsThisWeek) : "—"} tone="accent" /> + </div> + + {/* Error */} + {error && loaded && drafts.length === 0 && ( + <div className="mt-8"> + <Panel className="p-2"> + <EmptyState + title="Couldn't load the pipeline" + hint={error === "401" ? "Session expired — sign back in." : `Error ${error}. It will retry automatically.`} + action={ + <button onClick={manualRefresh} className="btn-ghost px-4 py-2 text-[13px]"> + Retry + </button> + } + /> + </Panel> + </div> + )} + + {/* Pipeline board */} + <section className="mt-12"> + <SectionHeader label="Pipeline" title="The board" /> + {!loaded ? ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5"> + {COLS.map((c) => ( + <div key={c.key} className="flex flex-col gap-2.5"> + <Skeleton className="h-5 w-24" /> + <Skeleton className="h-28" /> + <Skeleton className="h-28" /> + <Skeleton className="h-28" /> + </div> + ))} + </div> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5"> + {COLS.map((c) => ( + <Column key={c.key} col={c} drafts={byCol[c.key]} /> + ))} + </div> + )} + </section> + + {/* Top performers */} + <section className="mt-14"> + {!loaded ? ( + <> + <SectionHeader label="What worked" title="Top performers" /> + <Skeleton className="h-64" /> + </> + ) : ( + <TopPerformers posted={posted} /> + )} + </section> + </div> + </> + ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/garden/page.tsx b/src/app/garden/page.tsx new file mode 100644 index 0000000..4dc2f67 --- /dev/null +++ b/src/app/garden/page.tsx @@ -0,0 +1,258 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import { Plus, X, Droplets } from 'lucide-react'; +import { Button, Skeleton, EmptyState, rise } from '@/components/ui/kit'; + +interface Plant { + id: string; + name: string; + emoji: string; + location: 'indoor' | 'outdoor'; + waterSchedule: string; + waterDays: number[]; + img?: string; + tip?: string; + addedBy?: string; + addedAt?: string; +} + +interface GardenBlob { + version: number; + lastUpdated: string; + plants: Plant[]; +} + +const EMPTY_FORM: Omit<Plant, 'id'> = { + name: '', emoji: '🌿', location: 'indoor', waterSchedule: '', waterDays: [], img: '', tip: '', addedBy: '', +}; + +const inputCls = + 'bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-sm)] px-3 py-2 text-[14px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)] transition-colors'; + +export default function GardenPage() { + const [blob, setBlob] = useState<GardenBlob | null>(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [error, setError] = useState<string | null>(null); + const lastUpdatedRef = useRef<string | null>(null); + + async function fetchGarden(silent = false) { + if (!silent) setLoading(true); + try { + const res = await fetch('/api/garden'); + const data: GardenBlob = await res.json(); + if (data.lastUpdated !== lastUpdatedRef.current) { + lastUpdatedRef.current = data.lastUpdated; + setBlob(data); + } + } catch { setError('Failed to load garden'); } + if (!silent) setLoading(false); + } + + useEffect(() => { + fetchGarden(); + const interval = setInterval(() => fetchGarden(true), 30000); + return () => clearInterval(interval); + }, []); + + async function removePlant(id: string) { + if (!blob) return; + const updated: GardenBlob = { + ...blob, + lastUpdated: new Date().toISOString(), + plants: blob.plants.filter(p => p.id !== id), + }; + setSaving(true); + await fetch('/api/garden', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) }); + setBlob(updated); + lastUpdatedRef.current = updated.lastUpdated; + setSaving(false); + } + + async function addPlant() { + if (!blob || !form.name.trim()) return; + const newPlant: Plant = { + ...form, + id: `plant-${Date.now()}`, + addedAt: new Date().toISOString().split('T')[0], + }; + const updated: GardenBlob = { + ...blob, + lastUpdated: new Date().toISOString(), + plants: [...blob.plants, newPlant], + }; + setSaving(true); + await fetch('/api/garden', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) }); + setBlob(updated); + lastUpdatedRef.current = updated.lastUpdated; + setForm({ ...EMPTY_FORM }); + setShowForm(false); + setSaving(false); + } + + if (loading) { + return ( + <div className="w-full mx-auto p-6"> + <div className="flex items-center justify-between mb-8"> + <div className="space-y-2"> + <Skeleton className="h-3 w-24" /> + <Skeleton className="h-8 w-56" /> + </div> + <Skeleton className="h-9 w-28 !rounded-full" /> + </div> + <div className="grid grid-cols-7 gap-2 mb-8"> + {[...Array(7)].map((_, i) => <Skeleton key={i} className="h-24" />)} + </div> + <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3.5"> + {[...Array(4)].map((_, i) => <Skeleton key={i} className="h-40" />)} + </div> + </div> + ); + } + + if (error || !blob || !Array.isArray(blob.plants)) { + return ( + <div className="w-full mx-auto p-6"> + <EmptyState title={error ?? 'Unexpected data format — please reload.'} /> + </div> + ); + } + + const indoor = blob.plants.filter(p => p.location === 'indoor'); + const outdoor = blob.plants.filter(p => p.location === 'outdoor'); + + // Watering calendar — next 7 days + const today = new Date(); + const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const weekDays = Array.from({ length: 7 }, (_, i) => { + const d = new Date(today); + d.setDate(today.getDate() + i); + const dayIdx = d.getDay(); + const plants = blob.plants.filter(p => p.waterDays.includes(dayIdx)); + return { date: d, dayIdx, label: i === 0 ? 'Today' : DAY_NAMES[dayIdx], dateNum: d.getDate(), plants }; + }); + + return ( + <div className="w-full mx-auto p-6 pb-16"> + {/* Header */} + <div className="hq-rise flex items-end justify-between gap-4 pt-2 pb-8" style={rise(0)}> + <div> + <div className="eyebrow mb-2.5">🌿 Shared Garden</div> + <h1 className="text-[32px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]">Our Garden</h1> + <p className="num text-[var(--text-4)] text-[12px] mt-3"> + Syncs with Marwa every 30s · {blob.plants.length} plants + </p> + </div> + <Button variant="primary" onClick={() => setShowForm(true)}> + <Plus className="w-4 h-4" /> + Add plant + </Button> + </div> + + {/* Add form */} + {showForm && ( + <div className="hq-rise panel p-6 mb-8" style={rise(1)}> + <span className="eyebrow">New plant</span> + <div className="grid grid-cols-1 sm:grid-cols-[1fr_80px_1fr] gap-3 mt-4 mb-3"> + <input placeholder="Plant name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} className={inputCls} /> + <input placeholder="🌿" value={form.emoji} onChange={e => setForm(f => ({ ...f, emoji: e.target.value }))} className={`${inputCls} text-center text-[20px]`} /> + <select value={form.location} onChange={e => setForm(f => ({ ...f, location: e.target.value as 'indoor' | 'outdoor' }))} className={inputCls}> + <option value="indoor">Indoor</option> + <option value="outdoor">Outdoor</option> + </select> + </div> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3"> + <input placeholder="Water schedule (e.g. Sunday)" value={form.waterSchedule} onChange={e => setForm(f => ({ ...f, waterSchedule: e.target.value }))} className={inputCls} /> + <input placeholder="Image URL (optional)" value={form.img} onChange={e => setForm(f => ({ ...f, img: e.target.value }))} className={inputCls} /> + </div> + <input placeholder="Care tip (optional)" value={form.tip} onChange={e => setForm(f => ({ ...f, tip: e.target.value }))} className={`${inputCls} w-full mb-4`} /> + <div className="flex gap-2"> + <Button variant="primary" onClick={addPlant} disabled={saving || !form.name.trim()}> + {saving ? 'Saving...' : 'Add'} + </Button> + <Button variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button> + </div> + </div> + )} + + {/* Watering Calendar */} + <div className="mb-10"> + <div className="eyebrow flex items-center gap-1.5 mb-4"> + <Droplets className="w-3.5 h-3.5" style={{ color: 'var(--up)' }} /> + Watering This Week + </div> + <div className="grid grid-cols-7 gap-2"> + {weekDays.map(({ label, dateNum, plants }, i) => { + const isToday = i === 0; + return ( + <div + key={i} + className="rounded-[var(--r-md)] p-2.5 min-h-[90px] border transition-colors" + style={{ + background: isToday ? 'color-mix(in srgb, var(--up) 8%, var(--surface-1))' : 'var(--surface-1)', + borderColor: isToday + ? 'color-mix(in srgb, var(--up) 32%, transparent)' + : plants.length > 0 ? 'var(--line-strong)' : 'var(--line)', + }} + > + <p className="eyebrow !text-[9.5px]" style={isToday ? { color: 'var(--up)' } : undefined}>{label}</p> + <p className="num text-[18px] font-semibold text-[var(--text-2)] mt-0.5 mb-2">{dateNum}</p> + {plants.length === 0 + ? <p className="text-[var(--text-4)] text-[11px]">—</p> + : plants.map(p => ( + <div key={p.id} className="flex items-center gap-1 mb-1"> + <span className="text-[13px]">{p.emoji}</span> + <span className="text-[var(--text-2)] text-[11px] leading-tight">{p.name.split(' ')[0]}</span> + </div> + )) + } + </div> + ); + })} + </div> + </div> + + {/* Plant grid helper */} + {[{ title: 'Indoor', emoji: '🪴', plants: indoor }, { title: 'Outdoor', emoji: '🌳', plants: outdoor }].map(group => ( + group.plants.length === 0 ? null : + <div key={group.title} className="mb-9"> + <div className="eyebrow mb-4">{group.emoji} {group.title}</div> + <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3.5"> + {group.plants.map(plant => ( + <div key={plant.id} className="panel overflow-hidden"> + {plant.img && ( + <div className="h-[120px] overflow-hidden"> + {/* eslint-disable-next-line @next/next/no-img-element */} + <img src={plant.img} alt={plant.name} className="w-full h-full object-cover" /> + </div> + )} + <div className="px-3 pt-3 pb-3.5"> + <div className="flex justify-between items-start"> + <div> + <span className="text-[20px]">{plant.emoji}</span> + <p className="font-semibold text-[14px] text-[var(--text)] mt-1 mb-0.5">{plant.name}</p> + </div> + <button + onClick={() => removePlant(plant.id)} + className="text-[var(--text-4)] hover:text-[var(--text-2)] transition-colors p-0.5 leading-none" + aria-label="Remove plant" + > + <X className="w-4 h-4" /> + </button> + </div> + <p className="text-[12px] mt-1 mb-0.5 flex items-center gap-1" style={{ color: 'var(--up)' }}> + <Droplets className="w-3 h-3" /> {plant.waterSchedule || 'No schedule'} + </p> + {plant.tip && <p className="text-[var(--text-3)] text-[11px] mt-1 leading-relaxed">{plant.tip}</p>} + {plant.addedBy && <p className="text-[var(--text-4)] text-[10px] num mt-2">Added by {plant.addedBy}</p>} + </div> + </div> + ))} + </div> + </div> + ))} + </div> + ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..9faffe9 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,368 @@ +@import "tailwindcss"; + +/* ── Agent Office Animations ─────────────────────────────── */ + +/* Vertical bob — simulates walking when combined with horizontal drift */ +@keyframes agent-bob { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-3px); } +} + +/* Horizontal wander for idle agents — different durations set per-agent inline */ +@keyframes agent-wander { + 0% { transform: translateX(0px) scaleX(1); } + 15% { transform: translateX(10px) scaleX(1); } + 20% { transform: translateX(10px) scaleX(-1); } + 40% { transform: translateX(-8px) scaleX(-1); } + 45% { transform: translateX(-8px) scaleX(1); } + 65% { transform: translateX(6px) scaleX(1); } + 70% { transform: translateX(6px) scaleX(-1); } + 85% { transform: translateX(-4px) scaleX(-1); } + 90% { transform: translateX(-4px) scaleX(1); } + 100% { transform: translateX(0px) scaleX(1); } +} + +/* Working — lean toward monitor, fast typing bob */ +@keyframes agent-type { + 0%, 100% { transform: translateX(4px) translateY(0px) rotate(0deg); } + 25% { transform: translateX(4px) translateY(-2px) rotate(-2deg); } + 75% { transform: translateX(4px) translateY(-2px) rotate(2deg); } +} + +/* Speech bubble fade-cycle — shows for a while then hides */ +@keyframes bubble-cycle { + 0%, 10% { opacity: 0; transform: translateY(4px) scale(0.9); } + 15%, 70% { opacity: 1; transform: translateY(0px) scale(1); } + 80%, 100%{ opacity: 0; transform: translateY(-4px) scale(0.9); } +} + +/* Monitor screen flicker when working */ +@keyframes screen-flicker { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +/* Pulse ring for working agents */ +@keyframes status-ring { + 0% { box-shadow: 0 0 0 0 rgba(56,189,248,0.5); } + 70% { box-shadow: 0 0 0 6px rgba(56,189,248,0); } + 100% { box-shadow: 0 0 0 0 rgba(56,189,248,0); } +} + +:root { + --background: #0a0b0d; + --foreground: #e7e8ea; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, monospace; +} + +* { + box-sizing: border-box; +} + +html { + color-scheme: dark; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-feature-settings: "cv01", "ss03", "calt", "kern"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Glass morphism (legacy compat) ────────────────────── */ +.glass { + background: #171717; + border: 1px solid #262626; + transition: all 0.2s ease; +} + +.glass:hover { + border-color: #333; +} + +.glass-card { + background: #171717; + border: 1px solid #262626; + transition: all 0.2s ease; +} + +.glass-card:hover { + border-color: #333; +} + +/* ── War Room grid shift ─────────────────────────────────── */ +@keyframes grid-shift { + 0% { transform: translate(0, 0); } + 100% { transform: translate(50px, 50px); } +} + +/* ── Page transitions ────────────────────────────────────── */ +@keyframes page-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.page-enter { + animation: page-enter 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* ── Shimmer loading skeleton ────────────────────────────── */ +@keyframes shimmer { + from { background-position: -200% 0; } + to { background-position: 200% 0; } +} + +.skeleton { + background: linear-gradient(90deg, #1a1a1a 25%, #242424 50%, #1a1a1a 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 6px; +} + +/* ── Custom scrollbar ────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.08); + border-radius: 99px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.14); +} + +/* Selection */ +::selection { + background: rgba(255, 255, 255, 0.15); +} + +/* Force LTR everywhere */ +*, *::before, *::after { + direction: ltr !important; + unicode-bidi: plaintext; +} +textarea, input { + direction: ltr !important; + text-align: left !important; +} + +/* ═══════════════════════════════════════════════════════════ + PREMIUM DESIGN SYSTEM · Hermy HQ · "Calm Luxury" (Linear × Stripe) + Restraint + space. Monochrome surface ladder, hairline borders, + tabular/mono numerals, ONE desaturated accent, semantic-only + status color, sub-300ms ease-out motion. Elevation by light, + never by shadow (overlays are the only exception). + ═══════════════════════════════════════════════════════════ */ +:root { + /* surface ladder — depth via lighter tiers, not shadows */ + --bg: #0a0b0d; /* near-black, never #000 (avoids halation) */ + --surface-1: #101113; /* cards */ + --surface-2: #16181b; /* raised / hover */ + --surface-3: #1e2024; /* modals, highest tier */ + + /* hairlines carry structure */ + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.13); + + /* text ramp (contrast-checked against surfaces) */ + --text: #e7e8ea; /* primary ~15:1 */ + --text-2: #a0a3a8; /* secondary ~7:1 */ + --text-3: #6b6e74; /* tertiary — labels/decorative */ + --text-4: #4b4e54; /* ghost — hairline captions */ + + /* ONE brand accent (desaturated for dark) + semantic-only status */ + --accent: #6ea8fe; + --up: #5fd0a0; + --down: #f28b82; + --warn: #f5c451; + + /* radius — calm & precise, never toy-soft */ + --r-sm: 8px; + --r-md: 12px; + --r-lg: 16px; + + /* motion */ + --ease: cubic-bezier(0.16, 1, 0.3, 1); /* strong ease-out */ + --ease-inout: cubic-bezier(0.65, 0, 0.35, 1); + + /* legacy aliases — keep older components rendering during migration */ + --hq-bg: var(--bg); + --hq-text: var(--text); + --hq-text-dim: var(--text-2); + --hq-text-faint: var(--text-3); + --hq-text-ghost: var(--text-4); + --hq-hairline: var(--line); + --hq-hairline-strong: var(--line-strong); + --hq-up: var(--up); + --hq-down: var(--down); + --hq-warn: var(--warn); + --hq-elev-1: rgba(255, 255, 255, 0.02); + --hq-elev-2: rgba(255, 255, 255, 0.04); +} + +/* tabular/mono numerals — everywhere numbers compare or align */ +.num { + font-family: var(--font-mono), ui-monospace, monospace; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1, "ss01" 1; + letter-spacing: -0.01em; +} + +/* section label / eyebrow */ +.eyebrow { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-3); +} + +/* Ambient backdrop — a living "place": a slow-drifting aurora under a + very faint grid. Pinned to the viewport so it never cuts off on scroll. */ +@keyframes aurora-drift { + 0% { transform: translate3d(-6%, -4%, 0) scale(1.05); } + 50% { transform: translate3d(6%, 3%, 0) scale(1.16); } + 100% { transform: translate3d(-6%, -4%, 0) scale(1.05); } +} +.hq-ambient { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; + background: var(--bg); +} +.hq-ambient::before { + content: ""; + position: absolute; + inset: -20%; + background: + radial-gradient(72% 62% at 8% 6%, rgba(110, 168, 254, 0.14), transparent 70%), + radial-gradient(64% 62% at 92% 22%, rgba(95, 208, 160, 0.09), transparent 72%), + radial-gradient(70% 62% at 44% 80%, rgba(110, 168, 254, 0.07), transparent 74%), + radial-gradient(62% 60% at 98% 98%, rgba(95, 208, 160, 0.06), transparent 74%); + filter: blur(48px); + animation: aurora-drift 44s ease-in-out infinite; + will-change: transform; +} +.hq-ambient::after { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.012) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.012) 1px, transparent 1px); + background-size: 60px 60px; + /* fade top→bottom uniformly across the full width — no horizontal seam */ + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 80%); + -webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 80%); +} + +/* Card material — calm luxury: surface-1 + hairline + faint inner + top light. No glow, no drop shadow on data cards. */ +.panel { + position: relative; + border-radius: var(--r-lg); + background: var(--surface-1); + border: 1px solid var(--line); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); + transition: + border-color 0.3s var(--ease), + background 0.3s var(--ease), + transform 0.25s var(--ease), + box-shadow 0.3s var(--ease); +} +.panel-interactive { cursor: pointer; } +.panel-interactive:hover { + border-color: var(--line-strong); + background: var(--surface-2); + transform: translateY(-2px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 12px 32px -14px rgba(0, 0, 0, 0.6); +} +.panel-interactive:active { + transform: translateY(-1px) scale(0.995); +} + +/* overlays (menus / modals) — the ONLY place a real shadow lives */ +.elevated { + background: var(--surface-2); + border: 1px solid var(--line-strong); + border-radius: var(--r-md); + box-shadow: 0 12px 40px -8px rgba(0, 0, 0, 0.6); +} + +/* buttons — one filled primary in the whole system, rest are ghost */ +.btn-primary { + background: var(--text); + color: #0a0b0d; + font-weight: 600; + border-radius: 9999px; + transition: opacity 0.12s var(--ease), transform 0.12s var(--ease); +} +.btn-primary:hover { opacity: 0.92; } +.btn-primary:active { transform: scale(0.97); } + +.btn-ghost { + color: var(--text-2); + border: 1px solid var(--line); + border-radius: 9999px; + transition: color 0.15s var(--ease), border-color 0.15s var(--ease), background 0.15s var(--ease), transform 0.12s var(--ease); +} +.btn-ghost:hover { color: var(--text); border-color: var(--line-strong); background: var(--surface-2); } +.btn-ghost:active { transform: scale(0.97); } + +/* designed focus ring (not the UA default) */ +:where(a, button, [role="button"], input, textarea, select):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 6px; +} + +/* layout-mirroring skeleton */ +.sk { + background: linear-gradient(90deg, var(--surface-1) 25%, var(--surface-2) 50%, var(--surface-1) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--r-sm); +} + +/* staggered entrance — first paint only, reduced-motion aware */ +@keyframes hq-rise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +.hq-rise { + opacity: 0; + animation: hq-rise 0.5s var(--ease) forwards; +} + +/* hairline divider utility */ +.rule { + height: 1px; + background: var(--line); +} + +@media (prefers-reduced-motion: reduce) { + .hq-rise { animation: none; opacity: 1; } + .hq-ambient::before { animation: none; } + .panel-interactive:hover { transform: none; } + .panel-interactive:active, + .btn-primary:active, + .btn-ghost:active { transform: none; } +} diff --git a/src/app/hermes/page.tsx b/src/app/hermes/page.tsx new file mode 100644 index 0000000..c4f8d7d --- /dev/null +++ b/src/app/hermes/page.tsx @@ -0,0 +1,910 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Send, + RefreshCw, + Check, + X, + Pencil, + Inbox, + Clock, + Zap, + Activity as ActivityIcon, + LayoutGrid, + Pause, + Play, +} from "lucide-react"; +import { + Panel, + SectionHeader, + Button, + Pill, + EmptyState, + Skeleton, + Eyebrow, +} from "@/components/ui/kit"; +import { HermesDispatches } from "@/components/hermes-dispatches"; +import { HermesRuns } from "@/components/hermes-runs"; + +// ── Types ───────────────────────────────────────────────── +type ReqStatus = + | "queued" + | "awaiting_approval" + | "approved" + | "running" + | "done" + | "failed" + | "rejected"; + +interface Req { + id: string; + origin: string; + kind: string; + title: string; + prompt: string | null; + sideEffecting: boolean; + status: ReqStatus; + result: string | null; + error: string | null; + createdAt: string; + startedAt: string | null; + finishedAt: string | null; +} + +type EvLevel = "info" | "up" | "warn" | "down"; +interface Ev { + id: string; + kind: string; + title: string; + detail: string | null; + agent: string | null; + level: EvLevel; + createdAt: string; +} + +interface Task { + id: string; + board: string; + title: string; + assignee: string | null; + status: string; + priority: number | null; + result: string | null; + syncedAt: string; +} + +interface Health { + online: boolean; + gateway: string | null; + detail: string | null; + lastSeen: string | null; +} + +// ── Helpers ─────────────────────────────────────────────── +function timeAgo(d: string | null): string { + if (!d) return "—"; + const diff = Date.now() - new Date(d).getTime(); + if (Number.isNaN(diff)) return "—"; + const s = Math.floor(diff / 1000); + if (s < 45) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const days = Math.floor(h / 24); + return `${days}d ago`; +} + +async function getJSON<T>(url: string): Promise<T | null> { + try { + const r = await fetch(url); + if (!r.ok) return null; + return (await r.json()) as T; + } catch { + return null; + } +} + +// ── Task board column order ─────────────────────────────── +const COLUMN_ORDER = [ + "triage", + "todo", + "ready", + "running", + "review", + "blocked", + "done", +] as const; + +function normStatus(s: string): string { + return s.toLowerCase().replace(/[\s_-]+/g, ""); +} +function columnFor(status: string): string { + const k = normStatus(status); + for (const c of COLUMN_ORDER) if (k.includes(c)) return c; + if (k.includes("progress") || k.includes("doing")) return "running"; + if (k.includes("complete")) return "done"; + return "triage"; +} +function columnTone(col: string): "neutral" | "up" | "down" | "warn" | "accent" { + if (col === "done") return "up"; + if (col === "running") return "accent"; + if (col === "blocked") return "down"; + if (col === "review") return "warn"; + return "neutral"; +} +const COLUMN_LABEL: Record<string, string> = { + triage: "Triage", + todo: "To do", + ready: "Ready", + running: "Running", + review: "Review", + blocked: "Blocked", + done: "Done", +}; + +function levelColor(l: EvLevel): string { + if (l === "up") return "var(--up)"; + if (l === "down") return "var(--down)"; + if (l === "warn") return "var(--warn)"; + return "var(--text-3)"; +} + +// ── Health chip ─────────────────────────────────────────── +function HealthChip({ health }: { health: Health | null }) { + const online = !!health?.online; + const color = online ? "var(--up)" : "var(--warn)"; + return ( + <div + className="flex items-center gap-2 rounded-full border px-3 py-1.5" + style={{ + color, + borderColor: `color-mix(in srgb, ${color} 22%, transparent)`, + background: `color-mix(in srgb, ${color} 8%, transparent)`, + }} + > + <span className="relative flex w-1.5 h-1.5"> + {online && ( + <span + className="absolute inline-flex h-full w-full rounded-full animate-ping" + style={{ background: "color-mix(in srgb, var(--up) 60%, transparent)" }} + /> + )} + <span + className="relative inline-flex w-1.5 h-1.5 rounded-full" + style={{ background: color }} + /> + </span> + <span className="text-[12px] font-semibold"> + {online ? "Online" : "Offline · bridge idle"} + </span> + {health?.lastSeen && ( + <span className="num text-[10.5px] text-[var(--text-3)]"> + {timeAgo(health.lastSeen)} + </span> + )} + </div> + ); +} + +// ── Dispatch bar ────────────────────────────────────────── +function DispatchBar({ onDone }: { onDone: () => void }) { + const [text, setText] = useState(""); + const [side, setSide] = useState(false); + const [busy, setBusy] = useState(false); + const [toast, setToast] = useState<string | null>(null); + const timer = useRef<ReturnType<typeof setTimeout> | null>(null); + + const flash = (msg: string) => { + setToast(msg); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setToast(null), 4000); + }; + useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); + + const submit = async () => { + const title = text.trim(); + if (!title || busy) return; + setBusy(true); + try { + const r = await fetch("/api/hermes/dispatch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "oneshot", title, sideEffecting: side }), + }); + if (r.ok) { + setText(""); + flash( + side + ? "Sent to approval inbox — awaiting your go-ahead." + : "Queued for Hermes." + ); + onDone(); + } else { + flash("Dispatch failed. Try again."); + } + } catch { + flash("Dispatch failed. Try again."); + } finally { + setBusy(false); + } + }; + + return ( + <Panel className="p-5"> + <div className="flex flex-col sm:flex-row sm:items-center gap-3"> + <input + value={text} + onChange={(e) => setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); submit(); } + }} + placeholder="Ask or tell Hermes to do something…" + className="flex-1 min-w-0 bg-transparent text-[14px] text-[var(--text)] placeholder:text-[var(--text-3)] px-3.5 py-2.5 rounded-[10px] border border-[var(--line)] focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] outline-none transition-colors" + /> + <div className="flex items-center gap-3 shrink-0"> + <button + type="button" + onClick={() => setSide((s) => !s)} + aria-pressed={side} + className="flex items-center gap-2 select-none" + > + <span + className="relative inline-flex h-[18px] w-[32px] rounded-full transition-colors" + style={{ + background: side + ? "color-mix(in srgb, var(--warn) 55%, transparent)" + : "var(--surface-2)", + border: "1px solid var(--line)", + }} + > + <span + className="absolute top-[1px] h-[14px] w-[14px] rounded-full bg-[var(--text)] transition-all" + style={{ left: side ? "15px" : "1px" }} + /> + </span> + <span className="text-[12px] font-medium text-[var(--text-2)]"> + side-effecting? + </span> + </button> + <Button variant="primary" onClick={submit} disabled={busy || !text.trim()}> + <Send className="w-3.5 h-3.5" /> + Dispatch + </Button> + </div> + </div> + {toast && ( + <p className="mt-3 text-[12.5px] text-[var(--text-2)] flex items-center gap-1.5"> + <Check className="w-3.5 h-3.5" style={{ color: "var(--up)" }} /> + {toast} + </p> + )} + </Panel> + ); +} + +// ── Approval inbox card ─────────────────────────────────── +function InboxCard({ req, onAction }: { req: Req; onAction: () => void }) { + const [busy, setBusy] = useState(false); + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(req.title); + const [draftPrompt, setDraftPrompt] = useState(req.prompt ?? ""); + + const patch = async (body: Record<string, unknown>) => { + setBusy(true); + try { + await fetch(`/api/hermes/requests/${req.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + onAction(); + } catch { + /* leave card in place on failure */ + } finally { + setBusy(false); + setEditing(false); + } + }; + + return ( + <Panel className={`p-5 ${busy ? "opacity-50 pointer-events-none" : ""}`}> + <div className="flex items-start justify-between gap-3 mb-2.5"> + <div className="flex items-center gap-2 flex-wrap"> + <Pill tone="neutral">{req.kind}</Pill> + {req.sideEffecting && <Pill tone="warn">side-effecting</Pill>} + </div> + <span className="num text-[10.5px] text-[var(--text-3)] shrink-0 mt-1"> + {timeAgo(req.createdAt)} + </span> + </div> + + {editing ? ( + <div className="space-y-2.5"> + <input + value={draftTitle} + onChange={(e) => setDraftTitle(e.target.value)} + className="w-full bg-transparent text-[14px] font-medium text-[var(--text)] px-3 py-2 rounded-[8px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)]" + /> + <textarea + value={draftPrompt} + onChange={(e) => setDraftPrompt(e.target.value)} + rows={3} + className="w-full bg-transparent text-[13px] text-[var(--text-2)] px-3 py-2 rounded-[8px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] resize-y" + /> + </div> + ) : ( + <> + <h3 className="text-[15px] font-medium text-[var(--text)] leading-snug"> + {req.title} + </h3> + {req.prompt && ( + <p className="mt-1.5 text-[13px] text-[var(--text-2)] leading-snug line-clamp-3"> + {req.prompt} + </p> + )} + </> + )} + + <div className="flex items-center gap-2 mt-4"> + {editing ? ( + <> + <button + type="button" + onClick={() => + patch({ action: "edit", title: draftTitle.trim(), prompt: draftPrompt }) + } + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-semibold transition-colors" + style={{ + color: "var(--accent)", + border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)", + background: "color-mix(in srgb, var(--accent) 10%, transparent)", + }} + > + <Check className="w-3.5 h-3.5" /> + Save + </button> + <button + type="button" + onClick={() => { + setEditing(false); + setDraftTitle(req.title); + setDraftPrompt(req.prompt ?? ""); + }} + className="btn-ghost inline-flex items-center gap-1.5 px-3.5 py-1.5 text-[12px] font-medium" + > + Cancel + </button> + </> + ) : ( + <> + <button + type="button" + onClick={() => patch({ action: "approve" })} + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-semibold transition-colors" + style={{ + color: "var(--up)", + border: "1px solid color-mix(in srgb, var(--up) 30%, transparent)", + background: "color-mix(in srgb, var(--up) 10%, transparent)", + }} + > + <Check className="w-3.5 h-3.5" /> + Approve + </button> + <button + type="button" + onClick={() => patch({ action: "reject" })} + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-medium transition-colors text-[var(--text-2)] hover:text-[var(--down)]" + style={{ border: "1px solid var(--line)" }} + > + <X className="w-3.5 h-3.5" /> + Reject + </button> + <button + type="button" + onClick={() => setEditing(true)} + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-medium transition-colors text-[var(--text-2)] hover:text-[var(--text)]" + style={{ border: "1px solid var(--line)" }} + > + <Pencil className="w-3.5 h-3.5" /> + Edit + </button> + </> + )} + </div> + </Panel> + ); +} + +// ── Task board ──────────────────────────────────────────── +function TaskBoard({ + tasks, + total, + lastSync, +}: { + tasks: Task[]; + total: number; + lastSync: string | null; +}) { + const groups: Record<string, Task[]> = {}; + for (const t of tasks) { + const col = columnFor(t.status); + (groups[col] ||= []).push(t); + } + const cols = COLUMN_ORDER.filter((c) => groups[c]?.length); + + return ( + <> + <SectionHeader + label="Task board" + title="Hermes kanban" + action={ + <div className="flex items-center gap-3"> + <span className="num text-[12px] text-[var(--text-2)]">{total} total</span> + <span className="num text-[11px] text-[var(--text-3)]"> + synced {timeAgo(lastSync)} + </span> + </div> + } + /> + {tasks.length === 0 ? ( + <Panel className="p-2"> + <EmptyState + icon={<LayoutGrid className="w-6 h-6" />} + title="No tasks on the board" + hint="Dispatched work and synced kanban cards will show up here." + /> + </Panel> + ) : ( + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> + {cols.map((col) => { + const tone = columnTone(col); + return ( + <div key={col} className="flex flex-col gap-2.5"> + <div className="flex items-center justify-between px-1"> + <Eyebrow>{COLUMN_LABEL[col]}</Eyebrow> + <span className="num text-[11px] text-[var(--text-3)]"> + {groups[col].length} + </span> + </div> + <div className="flex flex-col gap-2.5"> + {groups[col] + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + .map((t) => ( + <div + key={t.id} + className="panel p-3.5" + style={{ + borderLeft: `2px solid color-mix(in srgb, ${ + tone === "neutral" ? "var(--text-3)" : `var(--${tone})` + } 55%, transparent)`, + }} + > + <p className="text-[13px] text-[var(--text)] leading-snug line-clamp-2"> + {t.title} + </p> + <div className="flex items-center gap-2 mt-2.5"> + {t.assignee && ( + <span className="num text-[10.5px] text-[var(--text-3)]"> + {t.assignee} + </span> + )} + {t.priority != null && t.priority > 0 && ( + <span className="num text-[10.5px] text-[var(--text-3)] ml-auto"> + P{t.priority} + </span> + )} + </div> + </div> + ))} + </div> + </div> + ); + })} + </div> + )} + </> + ); +} + +// ── Cron / schedules ────────────────────────────────────── +type CronJob = { + id: string; status: string; name: string; schedule: string; + nextRun: string | null; lastRun: string | null; lastResult: string | null; + deliver: string | null; skills: string | null; script: string | null; mode: string | null; +}; +function CronPanel({ jobs, syncedAt, onDone }: { jobs: CronJob[]; syncedAt: string | null; onDone: () => void }) { + const [schedule, setSchedule] = useState(""); + const [prompt, setPrompt] = useState(""); + const [runName, setRunName] = useState(""); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState<string | null>(null); + + const post = async (body: Record<string, unknown>, ok: string) => { + setBusy(true); + try { + const r = await fetch("/api/hermes/crons", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setNote(r.ok ? ok : "Request failed."); + if (r.ok) onDone(); + } catch { + setNote("Request failed."); + } finally { + setBusy(false); + } + }; + + const create = () => { + if (!schedule.trim() || !prompt.trim()) return; + post( + { op: "create", schedule: schedule.trim(), prompt: prompt.trim() }, + "Schedule sent to Hermes." + ).then(() => { + setSchedule(""); + setPrompt(""); + }); + }; + const runNow = () => { + if (!runName.trim()) return; + post({ op: "run", name: runName.trim() }, "Run-now sent to Hermes.").then(() => + setRunName("") + ); + }; + + return ( + <> + <SectionHeader + label="Cron · schedules" + title="Recurring jobs" + action={ + <span className="num text-[11px] text-[var(--text-3)]"> + synced {timeAgo(syncedAt)} + </span> + } + /> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> + {/* Schedules */} + <Panel className="p-5"> + <div className="flex items-center justify-between mb-3"> + <Eyebrow>schedules</Eyebrow> + <span className="num text-[10.5px] text-[var(--text-3)]"> + {jobs.length} job{jobs.length === 1 ? "" : "s"} + </span> + </div> + {jobs.length === 0 ? ( + <p className="text-[13px] text-[var(--text-3)] py-6 text-center"> + No schedules yet. + </p> + ) : ( + <div className="flex flex-col gap-2 max-h-[440px] overflow-auto -mx-1 px-1"> + {jobs.map((j) => { + const active = j.status === "active"; + return ( + <div key={j.id} className="rounded-[10px] border border-[var(--line)] bg-[var(--surface-2)] p-3"> + <div className="flex items-start gap-2.5"> + <span className="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" style={{ background: active ? "var(--up)" : "var(--text-3)" }} title={j.status} /> + <div className="flex-1 min-w-0"> + <p className="text-[13px] font-medium text-[var(--text)] truncate">{j.name || j.id}</p> + <div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 mt-1 num text-[11px] text-[var(--text-3)]"> + <span className="text-[var(--text-2)]">{j.schedule}</span> + {j.nextRun && <span>next {timeAgo(j.nextRun)}</span>} + {j.deliver && <span>→ {j.deliver.split(":")[0]}</span>} + {j.skills && <span>{j.skills}</span>} + </div> + </div> + <div className="flex items-center gap-1 shrink-0"> + <button title="Run now" disabled={busy} onClick={() => post({ op: "run", id: j.id, name: j.name }, "Run-now sent.")} className="p-1.5 rounded-md text-[var(--text-3)] hover:text-[var(--accent)] hover:bg-[var(--surface-1)] transition-colors"> + <Zap className="w-3.5 h-3.5" /> + </button> + {active ? ( + <button title="Pause" disabled={busy} onClick={() => post({ op: "pause", id: j.id, name: j.name }, "Pause sent.")} className="p-1.5 rounded-md text-[var(--text-3)] hover:text-[var(--warn)] hover:bg-[var(--surface-1)] transition-colors"> + <Pause className="w-3.5 h-3.5" /> + </button> + ) : ( + <button title="Resume" disabled={busy} onClick={() => post({ op: "resume", id: j.id, name: j.name }, "Resume sent.")} className="p-1.5 rounded-md text-[var(--text-3)] hover:text-[var(--up)] hover:bg-[var(--surface-1)] transition-colors"> + <Play className="w-3.5 h-3.5" /> + </button> + )} + </div> + </div> + </div> + ); + })} + </div> + )} + </Panel> + + {/* Controls */} + <Panel className="p-5"> + <div className="space-y-4"> + <div> + <Eyebrow className="!mb-2 block">New schedule</Eyebrow> + <div className="space-y-2.5"> + <input + value={schedule} + onChange={(e) => setSchedule(e.target.value)} + placeholder="Schedule (e.g. 0 9 * * 1)" + className="w-full bg-transparent num text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] px-3 py-2 rounded-[8px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)]" + /> + <textarea + value={prompt} + onChange={(e) => setPrompt(e.target.value)} + rows={2} + placeholder="Prompt — what should Hermes do on this cadence?" + className="w-full bg-transparent text-[13px] text-[var(--text-2)] placeholder:text-[var(--text-3)] px-3 py-2 rounded-[8px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] resize-y" + /> + <Button + variant="ghost" + size="sm" + onClick={create} + disabled={busy || !schedule.trim() || !prompt.trim()} + > + <Clock className="w-3.5 h-3.5" /> + Create schedule + </Button> + </div> + </div> + + <div className="rule" /> + + <div> + <Eyebrow className="!mb-2 block">Run now</Eyebrow> + <div className="flex items-center gap-2.5"> + <input + value={runName} + onChange={(e) => setRunName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); runNow(); } }} + placeholder="Job name" + className="flex-1 min-w-0 bg-transparent text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] px-3 py-2 rounded-[8px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)]" + /> + <Button variant="ghost" size="sm" onClick={runNow} disabled={busy || !runName.trim()}> + <Zap className="w-3.5 h-3.5" /> + Run now + </Button> + </div> + </div> + + {note && ( + <p className="text-[12px] text-[var(--text-2)] flex items-center gap-1.5"> + <Check className="w-3.5 h-3.5" style={{ color: "var(--up)" }} /> + {note} + </p> + )} + </div> + </Panel> + </div> + </> + ); +} + +// ── Activity feed ───────────────────────────────────────── +function ActivityFeed({ events }: { events: Ev[] }) { + return ( + <> + <SectionHeader label="Activity" title="Recent events" /> + {events.length === 0 ? ( + <Panel className="p-2"> + <EmptyState + icon={<ActivityIcon className="w-6 h-6" />} + title="No recent activity" + hint="Events from Hermes and its agents will stream in here." + /> + </Panel> + ) : ( + <Panel className="p-2"> + <div className="divide-y divide-[var(--line)]"> + {events.map((e) => ( + <div key={e.id} className="flex items-start gap-3 px-3.5 py-3"> + <span + className="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" + style={{ background: levelColor(e.level) }} + /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <p className="text-[13px] font-medium text-[var(--text)] leading-snug truncate"> + {e.title} + </p> + <span className="num text-[10.5px] text-[var(--text-3)] shrink-0 ml-auto"> + {timeAgo(e.createdAt)} + </span> + </div> + {e.detail && ( + <p className="mt-0.5 text-[12.5px] text-[var(--text-2)] leading-snug line-clamp-2"> + {e.detail} + </p> + )} + {e.agent && ( + <span className="num text-[10.5px] text-[var(--text-3)] mt-1 inline-block"> + {e.agent} + </span> + )} + </div> + </div> + ))} + </div> + </Panel> + )} + </> + ); +} + +// ── Main ────────────────────────────────────────────────── +export default function HermesPage() { + const [health, setHealth] = useState<Health | null>(null); + const [inbox, setInbox] = useState<Req[]>([]); + const [pending, setPending] = useState(0); + const [events, setEvents] = useState<Ev[]>([]); + const [tasks, setTasks] = useState<Task[]>([]); + const [taskTotal, setTaskTotal] = useState(0); + const [taskSync, setTaskSync] = useState<string | null>(null); + const [jobs, setJobs] = useState<CronJob[]>([]); + const [cronSync, setCronSync] = useState<string | null>(null); + const [loaded, setLoaded] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const [h, reqs, act, tk, cr] = await Promise.all([ + getJSON<Health>("/api/hermes/health"), + getJSON<{ requests: Req[]; pending: number }>( + "/api/hermes/requests?status=awaiting_approval&take=50" + ), + getJSON<{ events: Ev[] }>("/api/hermes/activity?take=40"), + getJSON<{ tasks: Task[]; counts: Record<string, number>; total: number; lastSync: string }>( + "/api/hermes/tasks" + ), + getJSON<{ jobs: CronJob[]; syncedAt: string }>("/api/hermes/crons"), + ]); + if (h) setHealth(h); + if (reqs) { + setInbox(reqs.requests ?? []); + setPending(reqs.pending ?? reqs.requests?.length ?? 0); + } + if (act) setEvents(act.events ?? []); + if (tk) { + setTasks(tk.tasks ?? []); + setTaskTotal(tk.total ?? tk.tasks?.length ?? 0); + setTaskSync(tk.lastSync ?? null); + } + if (cr) { + setJobs(cr.jobs ?? []); + setCronSync(cr.syncedAt ?? null); + } + setLoaded(true); + }, []); + + useEffect(() => { + load(); + const iv = setInterval(load, 8000); + return () => clearInterval(iv); + }, [load]); + + const manualRefresh = async () => { + setRefreshing(true); + await load(); + setRefreshing(false); + }; + + return ( + <> + <div className="relative z-10 w-full mx-auto pb-16"> + {/* Header */} + <div className="hq-rise pt-4 pb-8 flex items-end justify-between gap-4"> + <div> + <Eyebrow>Agent runtime</Eyebrow> + <h1 className="mt-2.5 text-[40px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]"> + Hermes + </h1> + </div> + <div className="flex items-center gap-2.5"> + <HealthChip health={health} /> + <button + type="button" + onClick={manualRefresh} + aria-label="Refresh" + className="btn-ghost inline-flex items-center justify-center w-9 h-9" + > + <RefreshCw className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`} /> + </button> + </div> + </div> + + {/* Dispatch */} + <div className="hq-rise"> + <DispatchBar onDone={load} /> + </div> + + {/* Dispatches — what you've sent Hermes + live status/results */} + <section className="mt-12"> + <HermesDispatches /> + </section> + + {/* Approval inbox */} + <section className="mt-12"> + <SectionHeader + label="Approval inbox" + title="Awaiting approval" + action={ + pending > 0 ? ( + <Pill tone="warn">{pending} pending</Pill> + ) : ( + <span className="num text-[11px] text-[var(--text-3)]">clear</span> + ) + } + /> + {!loaded ? ( + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> + <Skeleton className="h-40" /> + <Skeleton className="h-40" /> + </div> + ) : inbox.length === 0 ? ( + <Panel className="p-2"> + <EmptyState + icon={<Inbox className="w-6 h-6" />} + title="Nothing awaiting approval." + hint="Side-effecting dispatches land here for a one-tap approve." + /> + </Panel> + ) : ( + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> + {inbox.map((req) => ( + <InboxCard key={req.id} req={req} onAction={load} /> + ))} + </div> + )} + </section> + + {/* Task board */} + <section className="mt-12"> + {!loaded ? ( + <> + <SectionHeader label="Task board" title="Hermes kanban" /> + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> + <Skeleton className="h-48" /> + <Skeleton className="h-48" /> + <Skeleton className="h-48" /> + </div> + </> + ) : ( + <TaskBoard tasks={tasks} total={taskTotal} lastSync={taskSync} /> + )} + </section> + + {/* Cron / schedules */} + <section className="mt-12"> + {!loaded ? ( + <> + <SectionHeader label="Cron · schedules" title="Recurring jobs" /> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> + <Skeleton className="h-56" /> + <Skeleton className="h-56" /> + </div> + </> + ) : ( + <CronPanel jobs={jobs} syncedAt={cronSync} onDone={load} /> + )} + </section> + + {/* Activity feed */} + <section className="mt-12"> + {!loaded ? ( + <> + <SectionHeader label="Activity" title="Recent events" /> + <Skeleton className="h-64" /> + </> + ) : ( + <ActivityFeed events={events} /> + )} + </section> + + {/* Observability — runs & usage */} + <section className="mt-12"> + <HermesRuns /> + </section> + </div> + </> + ); +} diff --git a/src/app/ideas/page.tsx b/src/app/ideas/page.tsx new file mode 100644 index 0000000..6d06812 --- /dev/null +++ b/src/app/ideas/page.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Plus, Clock, Lightbulb, Check, X } from "lucide-react"; +import { Panel, Pill, Button, Skeleton, EmptyState, rise } from "@/components/ui/kit"; + +interface Idea { + id: string; + title: string; + description: string; + source: string; + category: string; + estimatedTime: string; + status: string; + createdAt?: string; + timestamp?: string; + rejectionReason?: string; +} + +type Tone = "neutral" | "up" | "down" | "warn" | "accent"; + +const STATUS_CONFIG: Record<string, { label: string; tone: Tone }> = { + new: { label: "New", tone: "accent" }, + considering: { label: "Considering", tone: "warn" }, + approved: { label: "Approved", tone: "up" }, + "in-progress": { label: "In Progress", tone: "accent" }, + done: { label: "Done", tone: "up" }, + rejected: { label: "Rejected", tone: "down" }, +}; + +const CATEGORY_CONFIG: Record<string, { label: string }> = { + build: { label: "Build" }, + content: { label: "Content" }, + feature: { label: "Feature" }, + thread: { label: "Thread" }, + experiment: { label: "Experiment" }, +}; + +const inputCls = + "w-full bg-[var(--surface-2)] border border-[var(--line)] rounded-[var(--r-sm)] px-4 py-3 text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:border-[var(--line-strong)] transition-colors"; + +function formatDate(dateStr?: string) { + if (!dateStr) return ""; + try { + const d = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + const days = Math.floor(diff / 86400000); + if (days === 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}d ago`; + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } catch { return ""; } +} + +function IdeaCard({ idea, onUpdate }: { idea: Idea; onUpdate: () => void }) { + const [isRejecting, setIsRejecting] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + + const status = idea.status || "new"; + const statusConf = STATUS_CONFIG[status] || STATUS_CONFIG.new; + const catConf = CATEGORY_CONFIG[idea.category] || { label: idea.category || "other" }; + const date = idea.createdAt || idea.timestamp || ""; + const isDead = status === "rejected" || status === "done"; + const isApproved = status === "approved"; + + const updateIdea = async (updates: Partial<Idea>) => { + await fetch("/api/ideas", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: idea.id, ...updates }), + }); + onUpdate(); + }; + + const handleReject = async () => { + if (!rejectReason.trim()) return; + await updateIdea({ status: "rejected", rejectionReason: rejectReason.trim() }); + setIsRejecting(false); + setRejectReason(""); + }; + + return ( + <Panel + className={`p-5 ${isDead ? "opacity-55 hover:opacity-80 transition-opacity" : ""}`} + style={isApproved ? { borderColor: "color-mix(in srgb, var(--up) 28%, transparent)" } : undefined} + > + {/* Top row */} + <div className="flex items-start justify-between gap-3 mb-3"> + <div className="flex items-center gap-2 flex-wrap"> + <Pill tone={statusConf.tone}> + <span className="w-1.5 h-1.5 rounded-full" style={{ background: "currentColor" }} /> + {statusConf.label} + </Pill> + <Pill tone="neutral">{catConf.label}</Pill> + </div> + <div className="flex items-center gap-3 text-[11px] num text-[var(--text-4)] shrink-0"> + {date && <span>{formatDate(date)}</span>} + {idea.estimatedTime && ( + <span className="flex items-center gap-1"> + <Clock className="w-3 h-3" /> + {idea.estimatedTime} + </span> + )} + </div> + </div> + + {/* Title + description */} + <h3 className="text-[14px] font-semibold text-[var(--text)] mb-1.5 leading-snug">{idea.title}</h3> + <p className="text-[var(--text-2)] text-[13px] leading-relaxed mb-4">{idea.description}</p> + + {/* Rejection reason */} + {status === "rejected" && idea.rejectionReason && ( + <div + className="rounded-[var(--r-sm)] px-3 py-2 mb-4" + style={{ + background: "color-mix(in srgb, var(--down) 8%, transparent)", + border: "1px solid color-mix(in srgb, var(--down) 22%, transparent)", + }} + > + <p className="text-[12px]" style={{ color: "var(--down)" }}> + <span className="font-medium">Rejected:</span> {idea.rejectionReason} + </p> + </div> + )} + + {/* Source */} + {idea.source && idea.source !== "manual" && ( + <p className="text-[var(--text-4)] text-[11px] num mb-3">via {idea.source}</p> + )} + + {/* Actions */} + {!isDead && !isApproved && !isRejecting && ( + <div className="flex gap-2"> + <button + onClick={() => updateIdea({ status: "approved" })} + className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors" + style={{ color: "var(--up)", borderColor: "color-mix(in srgb, var(--up) 24%, transparent)" }} + > + <Check className="w-3 h-3" /> + Approve + </button> + <button + onClick={() => setIsRejecting(true)} + className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors" + style={{ color: "var(--down)", borderColor: "color-mix(in srgb, var(--down) 24%, transparent)" }} + > + <X className="w-3 h-3" /> + Reject + </button> + </div> + )} + + {isApproved && ( + <div className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--up)" }}> + <Check className="w-3 h-3" /> + <span>Approved</span> + </div> + )} + + {/* Reject input */} + {isRejecting && ( + <div className="flex gap-2"> + <input + type="text" + value={rejectReason} + onChange={(e) => setRejectReason(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleReject()} + placeholder="Why reject? (helps Sage learn)" + className="flex-1 bg-[var(--surface-2)] border rounded-full px-3 py-2 text-[13px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none transition-colors" + style={{ borderColor: "color-mix(in srgb, var(--down) 28%, transparent)" }} + autoFocus + /> + <button + onClick={handleReject} + disabled={!rejectReason.trim()} + className="px-3 py-2 rounded-full text-[12px] font-medium border transition-colors disabled:opacity-40 disabled:cursor-not-allowed" + style={{ color: "var(--down)", borderColor: "color-mix(in srgb, var(--down) 28%, transparent)" }} + > + Reject + </button> + <button + onClick={() => { setIsRejecting(false); setRejectReason(""); }} + className="px-3 py-2 rounded-full text-[12px] text-[var(--text-3)] hover:text-[var(--text)] transition-colors" + > + Cancel + </button> + </div> + )} + </Panel> + ); +} + +export default function IdeasPage() { + const [ideas, setIdeas] = useState<Idea[]>([]); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState<string>("active"); + const [categoryFilter, setCategoryFilter] = useState<string>("all"); + const [showForm, setShowForm] = useState(false); + const [newIdea, setNewIdea] = useState({ title: "", description: "", source: "manual", category: "build", estimatedTime: "1 hour", status: "new" }); + const [submitting, setSubmitting] = useState(false); + + const fetchIdeas = useCallback(async () => { + try { + const res = await fetch("/api/ideas"); + const data = await res.json(); + setIdeas(data); + } catch { /* noop */ } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchIdeas(); }, [fetchIdeas]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newIdea.title.trim() || !newIdea.description.trim()) return; + setSubmitting(true); + try { + await fetch("/api/ideas", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...newIdea, createdAt: new Date().toISOString() }), + }); + await fetchIdeas(); + setNewIdea({ title: "", description: "", source: "manual", category: "build", estimatedTime: "1 hour", status: "new" }); + setShowForm(false); + } catch { /* noop */ } finally { setSubmitting(false); } + }; + + const sorted = [...ideas].sort((a, b) => { + const da = a.createdAt || a.timestamp || ""; + const db = b.createdAt || b.timestamp || ""; + return db.localeCompare(da); + }); + + const filtered = sorted.filter((idea) => { + const s = idea.status || "new"; + if (statusFilter === "active" && (s === "rejected" || s === "done")) return false; + if (statusFilter !== "active" && statusFilter !== "all" && s !== statusFilter) return false; + if (categoryFilter !== "all" && (idea.category || "") !== categoryFilter) return false; + return true; + }); + + const uniqueCategories = [...new Set(ideas.map(i => i.category).filter(Boolean))]; + + const statusTabs = [ + { key: "active", label: "Active", count: ideas.filter(i => !["rejected","done"].includes(i.status)).length }, + { key: "all", label: "All", count: ideas.length }, + { key: "approved", label: "Approved", count: ideas.filter(i => i.status === "approved").length }, + { key: "done", label: "Done", count: ideas.filter(i => i.status === "done").length }, + { key: "rejected", label: "Rejected", count: ideas.filter(i => i.status === "rejected").length }, + ]; + + if (loading) { + return ( + <div className="w-full mx-auto p-6"> + <div className="flex items-center justify-between mb-8"> + <div className="space-y-2"> + <Skeleton className="h-3 w-16" /> + <Skeleton className="h-8 w-32" /> + </div> + <Skeleton className="h-9 w-28 !rounded-full" /> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {[...Array(6)].map((_, i) => ( + <div key={i} className="panel p-5 space-y-3"> + <div className="flex gap-2"><Skeleton className="h-5 w-16 !rounded-full" /><Skeleton className="h-5 w-14 !rounded-full" /></div> + <Skeleton className="h-4 w-3/4" /> + <Skeleton className="h-10 w-full" /> + </div> + ))} + </div> + </div> + ); + } + + return ( + <div className="w-full mx-auto p-6 pb-16"> + {/* Header */} + <div className="hq-rise flex items-end justify-between gap-4 pt-2 pb-8" style={rise(0)}> + <div> + <div className="eyebrow mb-2.5 flex items-center gap-1.5"> + <Lightbulb className="w-3.5 h-3.5" /> + Ideas + </div> + <h1 className="text-[32px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]">Idea Board</h1> + <p className="num text-[var(--text-4)] text-[12px] mt-3">{filtered.length} showing · {ideas.length} total</p> + </div> + <Button + variant={showForm ? "ghost" : "primary"} + onClick={() => setShowForm(!showForm)} + > + {showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />} + {showForm ? "Cancel" : "Add Idea"} + </Button> + </div> + + {/* Add Idea Form */} + {showForm && ( + <div className="hq-rise panel p-6 mb-8" style={rise(1)}> + <span className="eyebrow">New Idea</span> + <form onSubmit={handleSubmit} className="space-y-4 mt-4"> + <input + type="text" + value={newIdea.title} + onChange={(e) => setNewIdea({ ...newIdea, title: e.target.value })} + className={inputCls} + placeholder="Idea title *" + required + /> + <textarea + value={newIdea.description} + onChange={(e) => setNewIdea({ ...newIdea, description: e.target.value })} + className={`${inputCls} resize-none`} + placeholder="Describe the idea *" + rows={3} + required + /> + <div className="flex gap-3"> + <select + value={newIdea.category} + onChange={(e) => setNewIdea({ ...newIdea, category: e.target.value })} + className="flex-1 bg-[var(--surface-2)] border border-[var(--line)] text-[var(--text-2)] px-3 py-2.5 rounded-[var(--r-sm)] text-[13px] focus:outline-none focus:border-[var(--line-strong)]" + > + <option value="build">Build</option> + <option value="content">Content</option> + <option value="feature">Feature</option> + <option value="thread">Thread</option> + <option value="experiment">Experiment</option> + </select> + <select + value={newIdea.estimatedTime} + onChange={(e) => setNewIdea({ ...newIdea, estimatedTime: e.target.value })} + className="flex-1 bg-[var(--surface-2)] border border-[var(--line)] text-[var(--text-2)] px-3 py-2.5 rounded-[var(--r-sm)] text-[13px] focus:outline-none focus:border-[var(--line-strong)]" + > + <option value="30 minutes">30 min</option> + <option value="1 hour">1 hour</option> + <option value="2 hours">2 hours</option> + <option value="3 hours">3 hours</option> + <option value="Half day">Half day</option> + <option value="Full day">Full day</option> + </select> + </div> + <div className="flex gap-2 pt-1"> + <Button + type="submit" + variant="primary" + disabled={submitting || !newIdea.title.trim() || !newIdea.description.trim()} + > + {submitting ? "Adding..." : "Add Idea"} + </Button> + <Button variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button> + </div> + </form> + </div> + )} + + {/* Filters */} + <div className="flex items-center gap-2 flex-wrap mb-6"> + {statusTabs.map(tab => ( + <button + key={tab.key} + onClick={() => setStatusFilter(tab.key)} + className={`px-3 py-1.5 rounded-full text-[12px] font-medium transition-colors flex items-center gap-1.5 border ${ + statusFilter === tab.key + ? "bg-[var(--surface-2)] text-[var(--text)] border-[var(--line-strong)]" + : "text-[var(--text-3)] hover:text-[var(--text)] border-[var(--line)] hover:border-[var(--line-strong)]" + }`} + > + {tab.label} + <span className="num text-[10px] text-[var(--text-4)]">{tab.count}</span> + </button> + ))} + {uniqueCategories.length > 0 && ( + <> + <div className="w-px h-4 bg-[var(--line)] mx-1" /> + <select + value={categoryFilter} + onChange={(e) => setCategoryFilter(e.target.value)} + className="bg-transparent border border-[var(--line)] text-[var(--text-2)] px-3 py-1.5 rounded-full text-[12px] focus:outline-none focus:border-[var(--line-strong)]" + > + <option value="all">All Categories</option> + {uniqueCategories.map(c => ( + <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option> + ))} + </select> + </> + )} + </div> + + {/* Ideas grid */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {filtered.map((idea) => ( + <IdeaCard key={idea.id} idea={idea} onUpdate={fetchIdeas} /> + ))} + </div> + + {/* Empty state */} + {filtered.length === 0 && ( + <div className="panel"> + <EmptyState + icon={<Lightbulb className="w-8 h-8" />} + title="No ideas found" + hint={statusFilter !== "all" ? "Try adjusting your filters" : "Add your first idea to get started"} + /> + </div> + )} + </div> + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..4479766 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata, Viewport } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { ConditionalLayout } from "@/components/conditional-layout"; + +const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" }); +const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" }); + +export const metadata: Metadata = { + title: "Hermy HQ", + description: "Your command center", +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <html lang="en" dir="ltr" className="dark"> + <body className={`${geist.variable} ${geistMono.variable} ${geist.className} bg-[#0a0a0a] text-white min-h-screen`}> + <ConditionalLayout>{children}</ConditionalLayout> + </body> + </html> + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..071c2d7 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,31 @@ +"use client"; +import { signIn } from "next-auth/react"; + +export default function LoginPage() { + return ( + <div className="min-h-screen flex items-center justify-center px-6" style={{ background: "var(--bg)" }}> + <div className="hq-ambient" aria-hidden /> + <div className="relative z-10 panel hq-rise w-full max-w-sm p-8"> + <div className="flex flex-col items-center text-center"> + <div className="w-11 h-11 rounded-[var(--r-md)] bg-white flex items-center justify-center text-[19px] font-bold text-[#0a0b0d]"> + M + </div> + <h1 className="mt-5 text-[20px] font-semibold tracking-[-0.015em] text-[var(--text)]"> + Hermy HQ + </h1> + <p className="eyebrow mt-2">Sign in to continue</p> + </div> + + <div className="rule my-7" /> + + <button + onClick={() => signIn("google", { callbackUrl: "/" })} + className="btn-primary w-full py-3 text-[13px] flex items-center justify-center gap-2" + > + <svg width="18" height="18" viewBox="0 0 18 18"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/><path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 6.29C4.672 4.163 6.656 3.58 9 3.58z"/></svg> + Sign in with Google + </button> + </div> + </div> + ); +} diff --git a/src/app/longform/page.tsx b/src/app/longform/page.tsx new file mode 100644 index 0000000..770c31c --- /dev/null +++ b/src/app/longform/page.tsx @@ -0,0 +1,969 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { EmptyState } from "@/components/ui/kit"; + +interface LongformScript { + id: string; + title: string; + status: "draft" | "approved" | "tofilm" | "filmed" | "posted" | "rejected"; + platforms: string[]; + targetLength: string; + hook: string; + outline: string; + fullScript: string; + factCheck: { status: string; verified: string[]; issues: string[] }; + createdAt: string; + notes: string; + rejectedReason?: string; + type?: "article" | "video"; + youtubeUrl?: string; + thumbnailUrl?: string; + tweetUrl?: string; + youtubeViews?: number; + youtubeLikes?: number; + youtubeComments?: number; + tweetViews?: number; + tweetLikes?: number; + tweetBookmarks?: number; +} + +/* ── presentational tone helpers (Calm Luxury) ── */ +const BTN_BASE = "text-[12px] px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors"; +const TONE: Record<string, string> = { + up: "bg-[color-mix(in_srgb,var(--up)_12%,transparent)] text-[var(--up)] hover:bg-[color-mix(in_srgb,var(--up)_20%,transparent)]", + down: "bg-[color-mix(in_srgb,var(--down)_12%,transparent)] text-[var(--down)] hover:bg-[color-mix(in_srgb,var(--down)_20%,transparent)]", + warn: "bg-[color-mix(in_srgb,var(--warn)_12%,transparent)] text-[var(--warn)] hover:bg-[color-mix(in_srgb,var(--warn)_20%,transparent)]", + accent: "bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] text-[var(--accent)] hover:bg-[color-mix(in_srgb,var(--accent)_20%,transparent)]", + ghost: "bg-[var(--surface-2)] text-[var(--text-2)] hover:bg-[var(--surface-3)]", +}; +const INPUT = "bg-[var(--surface-1)] border border-[var(--line)] text-[var(--text)] placeholder-[var(--text-3)] rounded-[var(--r-md)] focus:outline-none focus:border-[var(--accent)]"; + +export default function LongFormPage() { + const [scripts, setScripts] = useState<LongformScript[]>([]); + const [activeView, setActiveView] = useState<"ideas" | "scripts" | "tofilm" | "filmed" | "posted">("ideas"); + const [generating, setGenerating] = useState(false); + const [topicInput, setTopicInput] = useState(""); + const [typeFilter, setTypeFilter] = useState<"all" | "article" | "video">("all"); + const [newType, setNewType] = useState<"article" | "video">("article"); + const [expandedScript, setExpandedScript] = useState<string | null>(null); + const [expandedOutline, setExpandedOutline] = useState<string | null>(null); + const [rejectModal, setRejectModal] = useState<{ id: string; title: string } | null>(null); + const [postModal, setPostModal] = useState<{ id: string; title: string } | null>(null); + const [postForm, setPostForm] = useState({ youtubeUrl: "", finalTitle: "", finalScript: "", thumbnailUrl: "" }); + const rejectRef = React.useRef<HTMLTextAreaElement>(null); + + const fetchScripts = useCallback(async () => { + try { setScripts(await (await fetch("/api/longform", { cache: "no-store" })).json()); } catch { /* empty */ } + }, []); + + useEffect(() => { fetchScripts(); }, [fetchScripts]); + + async function updateScript(id: string, updates: Partial<LongformScript>) { + try { + const res = await fetch("/api/longform", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, ...updates }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + console.error("[longform] PATCH failed", res.status, err); + alert(`Save failed (${res.status}): ${err?.error || "unknown error"}`); + return; + } + await fetchScripts(); + } catch (e) { + console.error("[longform] updateScript error", e); + } + } + + // deleteScript removed — use reject instead to preserve data + + async function generateScript() { + if (!topicInput.trim()) return; + setGenerating(true); + try { + const res = await fetch("/api/longform/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic: topicInput, type: newType }), + }); + if (res.ok) { + const data = await res.json(); + await fetch("/api/longform", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + setTopicInput(""); + fetchScripts(); + } + } catch { /* empty */ } + setGenerating(false); + } + + const [generatingId, setGeneratingId] = useState<string | null>(null); + + async function approveAndGenerate(id: string, title: string, hook: string) { + setGeneratingId(id); + try { + // First update status + await updateScript(id, { status: "approved" }); + // Then generate script + const res = await fetch("/api/longform/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic: title, hook, id }), + }); + if (res.ok) { + const data = await res.json(); + // Update the existing script with generated content + await fetch("/api/longform", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id, + status: "approved", + title: data.title || title, + hook: data.hook || hook, + outline: data.outline || "", + fullScript: data.fullScript || "", + targetLength: data.targetLength || "10-15 min", + factCheck: data.factCheck || { status: "pending", verified: [], issues: [] }, + }), + }); + fetchScripts(); + } + } catch { /* empty */ } + setGeneratingId(null); + } + + const filteredScripts = typeFilter === "all" ? scripts : scripts.filter(s => (s.type || "article") === typeFilter); + const draftScripts = filteredScripts.filter(s => s.status === "draft"); + const approvedScripts = filteredScripts.filter(s => s.status === "approved"); + const tofilmScripts = filteredScripts.filter(s => s.status === "tofilm"); + const filmedScripts = filteredScripts.filter(s => s.status === "filmed"); + const postedScripts = filteredScripts.filter(s => s.status === "posted").sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const rejectedScripts = filteredScripts.filter(s => s.status === "rejected"); + + function copySpokenText(script: LongformScript) { + if (!script.fullScript) return; + // Extract only spoken text: lines in quotes, remove headers/stage directions + const lines = script.fullScript.split("\n"); + const spoken = lines + .filter(l => !l.startsWith("#") && !l.startsWith("*[") && !l.startsWith("**Target") && !l.startsWith("**Format") && !l.startsWith("**Tone") && !l.startsWith("---") && l.trim() !== "") + .map(l => l.replace(/^\*\[.*?\]\*\s*/, "").replace(/^[">]\s*/, "").replace(/^- /, "• ")) + .filter(l => l.trim() !== "") + .join("\n"); + navigator.clipboard.writeText(spoken); + } + + function RejectModal() { + if (!rejectModal) return null; + return ( + <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setRejectModal(null)}> + <div dir="ltr" className="panel p-5 w-full max-w-md" onClick={e => e.stopPropagation()}> + <h3 className="text-sm font-semibold text-[var(--text)] mb-1">Reject: {rejectModal.title}</h3> + <p className="text-xs text-[var(--text-3)] mb-3">Why? This helps improve future scripts.</p> + <textarea + ref={rejectRef} + suppressHydrationWarning + defaultValue="" + placeholder="e.g. hook is weak, topic isn't right, needs more research..." + className={`w-full ${INPUT} p-3 text-sm resize-none h-20 focus:border-[var(--down)]`} + /> + <div className="flex gap-2 mt-3"> + <button + onClick={() => { + const reason = rejectRef.current?.value || ""; + updateScript(rejectModal.id, { status: "rejected", rejectedReason: reason }); + setRejectModal(null); + }} + className={`flex-1 text-sm py-2 rounded-[var(--r-md)] ${TONE.down} transition-colors font-medium`} + >Reject</button> + <button onClick={() => setRejectModal(null)} className={`flex-1 text-sm py-2 rounded-[var(--r-md)] ${TONE.ghost} transition-colors`}>Cancel</button> + </div> + </div> + </div> + ); + } + + function PostModal() { + const [scraping, setScraping] = useState(false); + const [scraped, setScraped] = useState(false); + const [scrapeError, setScrapeError] = useState(""); + + async function scrapeYoutube(url: string) { + if (!url.includes("youtube.com") && !url.includes("youtu.be")) return; + setScraping(true); + setScrapeError(""); + setScraped(false); + try { + const res = await fetch("/api/youtube-scrape", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + if (!res.ok) throw new Error("Scrape failed"); + const data = await res.json(); + if (data.error) throw new Error(data.error); + setPostForm({ + youtubeUrl: url, + finalTitle: data.title || "", + thumbnailUrl: data.thumbnail || "", + finalScript: data.transcript || "", + }); + setScraped(true); + } catch (e: any) { + setScrapeError(e.message || "Could not scrape video — fill in manually"); + } finally { + setScraping(false); + } + } + + function handleUrlChange(url: string) { + setPostForm(f => ({ ...f, youtubeUrl: url })); + // Auto-scrape when a full YouTube URL is pasted + if ((url.includes("youtube.com/watch") || url.includes("youtu.be/")) && url.length > 20) { + scrapeYoutube(url); + } + } + + if (!postModal) return null; + return ( + <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setPostModal(null)}> + <div dir="ltr" className="panel p-5 w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}> + <h3 className="text-sm font-semibold text-[var(--text)] mb-1">Mark as Posted</h3> + <p className="text-xs text-[var(--text-3)] mb-4">{postModal.title}</p> + + <div className="space-y-3"> + <div> + <label className="eyebrow mb-1 block">YouTube URL *</label> + <div className="flex gap-2"> + <input + type="url" + value={postForm.youtubeUrl} + onChange={e => handleUrlChange(e.target.value)} + onPaste={e => { setTimeout(() => handleUrlChange((e.target as HTMLInputElement).value), 50); }} + placeholder="Paste YouTube URL — auto-fills everything" + className={`flex-1 ${INPUT} p-3 text-sm`} + /> + <button + onClick={() => scrapeYoutube(postForm.youtubeUrl)} + disabled={scraping || !postForm.youtubeUrl} + className={`px-3 py-2 rounded-[var(--r-md)] ${TONE.accent} text-xs font-medium disabled:opacity-40 transition-colors whitespace-nowrap`} + >{scraping ? "Scraping..." : "Fetch"}</button> + </div> + {scrapeError && <p className="text-xs text-[var(--down)] mt-1">{scrapeError}</p>} + {scraped && <p className="text-xs text-[var(--up)] mt-1">Auto-filled from YouTube</p>} + </div> + + <div> + <label className="eyebrow mb-1 block">Video Title</label> + <input + value={postForm.finalTitle} + onChange={e => setPostForm(f => ({ ...f, finalTitle: e.target.value }))} + placeholder="Auto-filled from YouTube" + className={`w-full ${INPUT} p-3 text-sm`} + /> + </div> + + {postForm.thumbnailUrl && ( + <div> + <label className="eyebrow mb-1 block">Thumbnail</label> + <img src={postForm.thumbnailUrl} alt="Thumbnail" className="w-full rounded-[var(--r-md)] border border-[var(--line)]" /> + </div> + )} + + <div> + <label className="eyebrow mb-1 block">Transcript / Script</label> + <textarea + value={postForm.finalScript} + onChange={e => setPostForm(f => ({ ...f, finalScript: e.target.value }))} + placeholder="Auto-filled from YouTube captions..." + className={`w-full ${INPUT} p-3 text-sm resize-none h-32`} + /> + </div> + </div> + + <div className="flex gap-2 mt-4"> + <button + onClick={() => { + if (!postForm.youtubeUrl) return; + updateScript(postModal.id, { + status: "posted", + youtubeUrl: postForm.youtubeUrl, + thumbnailUrl: postForm.thumbnailUrl, + title: postForm.finalTitle || postModal.title, + fullScript: postForm.finalScript, + } as any); + setPostModal(null); + }} + disabled={!postForm.youtubeUrl} + className={`flex-1 text-sm py-2 rounded-[var(--r-md)] ${TONE.accent} transition-colors font-medium disabled:opacity-40`} + >Post</button> + <button onClick={() => setPostModal(null)} className={`flex-1 text-sm py-2 rounded-[var(--r-md)] ${TONE.ghost} transition-colors`}>Cancel</button> + </div> + </div> + </div> + ); + } + + function ScriptCard({ script, compact }: { script: LongformScript; compact?: boolean }) { + const isExpanded = expandedScript === script.id; + const isOutlineExpanded = expandedOutline === script.id; + const [notesValue, setNotesValue] = useState(script.notes || ""); + const [editingTitle, setEditingTitle] = useState(false); + const [titleValue, setTitleValue] = useState(script.title); + const [tweakOpen, setTweakOpen] = useState(false); + const [tweakMsg, setTweakMsg] = useState(""); + const [tweaking, setTweaking] = useState(false); + + return ( + <div className="panel panel-interactive overflow-hidden"> + <div className="p-4"> + {/* Title + meta */} + <div className="flex items-center justify-between mb-2"> + {editingTitle ? ( + <input + autoFocus + value={titleValue} + onChange={e => setTitleValue(e.target.value)} + onBlur={() => { if (titleValue !== script.title) updateScript(script.id, { title: titleValue }); setEditingTitle(false); }} + onKeyDown={e => { if (e.key === "Enter") { if (titleValue !== script.title) updateScript(script.id, { title: titleValue }); setEditingTitle(false); } if (e.key === "Escape") { setTitleValue(script.title); setEditingTitle(false); } }} + className={`text-sm font-semibold flex-1 pr-2 ${INPUT} px-2 py-1`} + /> + ) : ( + <span onClick={() => { if (script.status !== "posted") setEditingTitle(true); }} className={`text-sm font-semibold text-[var(--text)] flex-1 pr-2 ${script.status !== "posted" ? "cursor-pointer hover:text-[var(--accent)]" : ""}`} title={script.status !== "posted" ? "Click to edit title" : ""}>{script.title}</span> + )} + <div className="flex items-center gap-2 shrink-0"> + <span className="text-[10px] px-2 py-0.5 rounded-full border border-[var(--line)] text-[var(--text-2)]">{(script.type || "article") === "video" ? "🎬 Video" : "📝 Article"}</span> + <span className="text-[10px] px-2 py-0.5 rounded-full border border-[var(--line)] text-[var(--text-3)] num">{script.targetLength}</span> + </div> + </div> + + {/* Platform badges */} + <div className="flex items-center gap-1.5 mb-3"> + {script.platforms?.map(p => ( + <span key={p} className="text-[9px] px-2 py-0.5 rounded-full border border-[var(--line)] text-[var(--text-2)]"> + {p === "youtube" ? "▶ YouTube" : "𝕏 Twitter"} + </span> + ))} + </div> + + {/* Fact check */} + {script.factCheck && ( + <div + className="rounded-[var(--r-md)] p-2 mb-3 border" + style={{ + background: `color-mix(in srgb, ${script.factCheck.status === "✅" ? "var(--up)" : "var(--warn)"} 8%, transparent)`, + borderColor: `color-mix(in srgb, ${script.factCheck.status === "✅" ? "var(--up)" : "var(--warn)"} 22%, transparent)`, + }} + > + <p className="text-[11px] text-[var(--text-2)]"> + <span className="font-medium text-[var(--text)]">{script.factCheck.status} Fact Check</span> + {script.factCheck.verified?.length > 0 && <span className="text-[var(--up)] ml-1 num">— {script.factCheck.verified.length} verified</span>} + </p> + {script.factCheck.issues?.length > 0 && ( + <div className="mt-1 space-y-0.5"> + {script.factCheck.issues.map((issue, i) => ( + <p key={i} className="text-[10px] text-[var(--warn)]">• {issue}</p> + ))} + </div> + )} + </div> + )} + + {script.rejectedReason && ( + <div className="rounded-[var(--r-md)] p-2 mb-3 border border-[color-mix(in_srgb,var(--down)_22%,transparent)] bg-[color-mix(in_srgb,var(--down)_8%,transparent)]"> + <p className="text-[10px] text-[var(--down)]">💬 {script.rejectedReason}</p> + </div> + )} + + {/* Hook */} + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3 border border-[var(--line)] mb-3"> + <p className="eyebrow mb-1">Hook</p> + <p className="text-[13px] text-[var(--text)] whitespace-pre-line leading-relaxed">{script.hook}</p> + </div> + + {/* Outline (collapsible) */} + <div className="mb-3"> + <button + onClick={() => setExpandedOutline(isOutlineExpanded ? null : script.id)} + className="flex items-center gap-2 text-xs text-[var(--text-3)] hover:text-[var(--text-2)] transition-colors" + > + <span>{isOutlineExpanded ? "▴" : "▾"}</span> + <span className="font-medium uppercase tracking-wider">Outline</span> + </button> + {isOutlineExpanded && ( + <div className="mt-2 bg-[var(--surface-2)] rounded-[var(--r-md)] p-3"> + <p className="text-xs text-[var(--text-2)] leading-relaxed">{script.outline}</p> + </div> + )} + </div> + + {/* Full Script (collapsible) */} + <div className="mb-3"> + <button + onClick={() => setExpandedScript(isExpanded ? null : script.id)} + className="flex items-center gap-2 text-xs text-[var(--text-3)] hover:text-[var(--text-2)] transition-colors" + > + <span>{isExpanded ? "▴" : "▾"}</span> + <span className="font-medium uppercase tracking-wider">Full Script</span> + </button> + {isExpanded && script.fullScript && ( + <div className="mt-2 bg-[var(--surface-2)] rounded-[var(--r-md)] p-5 border border-[var(--line)] max-h-[70vh] overflow-y-auto"> + <div className="max-w-none"> + {script.fullScript.split("\n").map((line, i) => { + if (line.startsWith("# ")) return <h1 key={i} className="text-lg font-bold text-[var(--text)] mt-4 mb-2">{line.slice(2)}</h1>; + if (line.startsWith("## ")) return <h2 key={i} className="text-base font-bold text-[var(--text)] mt-6 mb-2 border-b border-[var(--line)] pb-1">{line.slice(3)}</h2>; + if (line.startsWith("### ")) return <h3 key={i} className="text-sm font-bold text-[var(--text-2)] mt-4 mb-1">{line.slice(4)}</h3>; + if (line.startsWith("**") && line.endsWith("**")) return <p key={i} className="text-sm font-bold text-[var(--text)] mt-3 mb-1">{line.slice(2, -2)}</p>; + if (line.startsWith("- ")) return <p key={i} className="text-xs text-[var(--text-2)] leading-relaxed ml-4">• {line.slice(2)}</p>; + if (line.startsWith("*[") || line.startsWith("*[")) return <p key={i} className="text-xs text-[var(--text-3)] italic leading-relaxed">{line.replace(/\*/g, "")}</p>; + if (line.startsWith(">") || line.startsWith('"')) return <blockquote key={i} className="text-sm text-[var(--text)] border-l-2 border-[var(--accent)] pl-3 my-2 italic">{line.replace(/^>?\s*"?/, "").replace(/"$/, "")}</blockquote>; + if (line.startsWith("---")) return <hr key={i} className="border-[var(--line)] my-4" />; + if (line.trim() === "") return <div key={i} className="h-2" />; + return <p key={i} className="text-xs text-[var(--text-2)] leading-relaxed">{line}</p>; + })} + </div> + </div> + )} + </div> + + {/* Notes */} + <div className="mb-3"> + <p className="eyebrow mb-1">Notes</p> + <textarea + suppressHydrationWarning + value={notesValue} + onChange={e => setNotesValue(e.target.value)} + onBlur={() => { if (notesValue !== script.notes) updateScript(script.id, { notes: notesValue }); }} + placeholder="Filming notes..." + className={`w-full ${INPUT} p-2 text-xs text-[var(--text-2)] resize-none h-16`} + /> + </div> + + {/* Actions */} + <div className="flex items-center gap-2 flex-wrap"> + <button + onClick={() => copySpokenText(script)} + className={`${BTN_BASE} ${TONE.ghost}`} + >Copy Full Script</button> + + {script.status === "draft" && !script.fullScript && ( + <> + <button + onClick={() => approveAndGenerate(script.id, script.title, script.hook)} + disabled={generatingId === script.id} + className={`${BTN_BASE} ${TONE.up} disabled:opacity-50`} + >{generatingId === script.id ? "Generating..." : "Approve & Generate Script"}</button> + <button + onClick={() => setTweakOpen(!tweakOpen)} + className={`${BTN_BASE} ${TONE.warn}`} + >Tweak</button> + <button + onClick={() => setRejectModal({ id: script.id, title: script.title })} + className={`${BTN_BASE} ${TONE.down}`} + >Reject</button> + </> + )} + {script.status === "draft" && script.fullScript && ( + <> + <button + onClick={() => updateScript(script.id, { status: "approved" })} + className={`${BTN_BASE} ${TONE.up}`} + >Approve Script</button> + <button + onClick={() => setRejectModal({ id: script.id, title: script.title })} + className={`${BTN_BASE} ${TONE.down}`} + >Reject</button> + </> + )} + + {script.status === "approved" && ( + <> + <button + onClick={() => updateScript(script.id, { status: "tofilm" })} + className={`${BTN_BASE} ${TONE.accent}`} + >Ready to Film</button> + <button + onClick={() => setTweakOpen(!tweakOpen)} + className={`${BTN_BASE} ${TONE.warn}`} + >Tweak</button> + <button + onClick={() => setRejectModal({ id: script.id, title: script.title })} + className={`${BTN_BASE} ${TONE.down}`} + >Reject</button> + <button + onClick={() => updateScript(script.id, { status: "draft" })} + className={`${BTN_BASE} ${TONE.ghost}`} + >↩ Back to Ideas</button> + </> + )} + + {script.status === "tofilm" && ( + <> + <button + onClick={() => updateScript(script.id, { status: "filmed" })} + className={`${BTN_BASE} ${TONE.accent}`} + >Filmed</button> + <button + onClick={() => setRejectModal({ id: script.id, title: script.title })} + className={`${BTN_BASE} ${TONE.down}`} + >Reject</button> + <button + onClick={() => updateScript(script.id, { status: "approved" })} + className={`${BTN_BASE} ${TONE.ghost}`} + >↩ Back to Scripts</button> + </> + )} + + {script.status === "filmed" && ( + <> + <button + onClick={() => { setPostModal({ id: script.id, title: script.title }); setPostForm({ youtubeUrl: "", finalTitle: script.title, finalScript: script.fullScript || "", thumbnailUrl: "" }); }} + className={`${BTN_BASE} ${TONE.accent}`} + >Mark as Posted</button> + <button + onClick={() => updateScript(script.id, { status: "tofilm" })} + className={`${BTN_BASE} ${TONE.ghost}`} + >↩ Back to To Film</button> + </> + )} + + {script.status === "posted" && ( + <button + onClick={() => updateScript(script.id, { status: "filmed" })} + className={`${BTN_BASE} ${TONE.ghost}`} + >↩ Back to Filmed</button> + )} + + {script.status === "rejected" && ( + <button + onClick={() => updateScript(script.id, { status: "draft" })} + className={`${BTN_BASE} ${TONE.ghost}`} + >↩ Restore to Draft</button> + )} + </div> + + {/* Tweak request panel */} + {tweakOpen && ( + <div className="mt-3 bg-[color-mix(in_srgb,var(--warn)_6%,transparent)] border border-[color-mix(in_srgb,var(--warn)_22%,transparent)] rounded-[var(--r-md)] p-3"> + <p className="eyebrow mb-2" style={{ color: "var(--warn)" }}>Send tweak to Nova</p> + <div className="flex gap-2"> + <input + type="text" + value={tweakMsg} + onChange={e => setTweakMsg(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter" && tweakMsg.trim()) { + setTweaking(true); + fetch("/api/longform/tweak", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: script.id, title: script.title, hook: script.hook, outline: script.outline, message: tweakMsg.trim() }), + }).then(() => { setTweaking(false); setTweakOpen(false); setTweakMsg(""); }).catch(() => setTweaking(false)); + } + }} + placeholder="e.g. 'make the hook more outcome-based' or 'change title to focus on results'" + className={`flex-1 ${INPUT} px-3 py-2 text-sm focus:border-[var(--warn)]`} + autoFocus + /> + <button + onClick={() => { + if (!tweakMsg.trim()) return; + setTweaking(true); + fetch("/api/longform/tweak", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: script.id, title: script.title, hook: script.hook, outline: script.outline, message: tweakMsg.trim() }), + }).then(() => { setTweaking(false); setTweakOpen(false); setTweakMsg(""); fetchScripts(); }).catch(() => setTweaking(false)); + }} + disabled={!tweakMsg.trim() || tweaking} + className={`px-4 py-2 rounded-[var(--r-md)] text-xs font-medium transition-colors ${tweakMsg.trim() && !tweaking ? TONE.warn : "bg-[var(--surface-2)] text-[var(--text-4)] cursor-not-allowed"}`} + >{tweaking ? "Sending..." : "Send"}</button> + <button onClick={() => { setTweakOpen(false); setTweakMsg(""); }} className="px-3 py-2 rounded-[var(--r-md)] text-xs text-[var(--text-2)] hover:bg-[var(--surface-2)] transition-colors">Cancel</button> + </div> + </div> + )} + </div> + </div> + ); + } + + function PostedCard({ script }: { script: LongformScript }) { + const [ytUrl, setYtUrl] = useState(script.youtubeUrl || ""); + const [twUrl, setTwUrl] = useState(script.tweetUrl || ""); + const [editingYt, setEditingYt] = useState(false); + const [editingTw, setEditingTw] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + async function scrapeAndUpdate(ytUrl?: string, twUrl?: string) { + const yt = ytUrl || script.youtubeUrl; + const tw = twUrl || script.tweetUrl; + if (!yt && !tw) return; + setRefreshing(true); + try { + const res = await fetch("/api/scrape-metrics", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ youtubeUrl: yt, tweetUrl: tw, scriptId: script.id }), + }); + // Always refresh — server saves directly to DB + await fetchScripts(); + } catch { /* */ } finally { + setRefreshing(false); + } + } + + function saveYt() { + if (ytUrl !== script.youtubeUrl) { + updateScript(script.id, { youtubeUrl: ytUrl } as any); + scrapeAndUpdate(ytUrl, undefined); + } + setEditingYt(false); + } + function saveTw() { + if (twUrl !== script.tweetUrl) updateScript(script.id, { tweetUrl: twUrl } as any); + setEditingTw(false); + } + + return ( + <div className="panel panel-interactive overflow-hidden"> + <div className="p-4"> + <div className="flex items-center justify-between mb-3"> + <span className="text-sm font-semibold text-[var(--text)] flex-1 pr-2">{script.title}</span> + <span className="text-[10px] px-2 py-0.5 rounded-full border border-[color-mix(in_srgb,var(--accent)_22%,transparent)] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] text-[var(--accent)]">Posted</span> + </div> + + {/* Hook preview */} + {script.hook && ( + <p className="text-xs text-[var(--text-2)] mb-3 line-clamp-2">{script.hook}</p> + )} + + {/* URLs */} + <div className="space-y-2 mb-3"> + <div className="flex items-center gap-2"> + <span className="text-[var(--text-2)] text-xs w-6">▶</span> + {editingYt ? ( + <> + <input + type="text" + value={ytUrl} + onChange={e => setYtUrl(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") saveYt(); if (e.key === "Escape") { setYtUrl(script.youtubeUrl || ""); setEditingYt(false); } }} + autoFocus + placeholder="YouTube URL..." + className={`flex-1 ${INPUT} px-3 py-1.5 text-xs`} + /> + <button onClick={saveYt} className="text-xs text-[var(--up)] hover:opacity-80">✓</button> + <button onClick={() => { setYtUrl(script.youtubeUrl || ""); setEditingYt(false); }} className="text-xs text-[var(--text-3)] hover:text-[var(--text-2)]">✕</button> + </> + ) : ( + <> + {ytUrl ? ( + <a href={ytUrl} target="_blank" rel="noopener noreferrer" className="flex-1 text-xs text-[var(--accent)] hover:opacity-80 truncate">{ytUrl}</a> + ) : ( + <span className="flex-1 text-xs text-[var(--text-4)]">No YouTube URL</span> + )} + <button onClick={() => setEditingYt(true)} className="text-[10px] px-2 py-0.5 rounded bg-[var(--surface-2)] text-[var(--text-3)] hover:text-[var(--text)] transition-colors">Edit</button> + </> + )} + </div> + <div className="flex items-center gap-2"> + <span className="text-[var(--text-2)] text-xs w-6">𝕏</span> + {editingTw ? ( + <> + <input + type="text" + value={twUrl} + onChange={e => setTwUrl(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") saveTw(); if (e.key === "Escape") { setTwUrl(script.tweetUrl || ""); setEditingTw(false); } }} + autoFocus + placeholder="Tweet URL..." + className={`flex-1 ${INPUT} px-3 py-1.5 text-xs`} + /> + <button onClick={saveTw} className="text-xs text-[var(--up)] hover:opacity-80">✓</button> + <button onClick={() => { setTwUrl(script.tweetUrl || ""); setEditingTw(false); }} className="text-xs text-[var(--text-3)] hover:text-[var(--text-2)]">✕</button> + </> + ) : ( + <> + {twUrl ? ( + <a href={twUrl} target="_blank" rel="noopener noreferrer" className="flex-1 text-xs text-[var(--accent)] hover:opacity-80 truncate">{twUrl}</a> + ) : ( + <span className="flex-1 text-xs text-[var(--text-4)]">No Tweet URL</span> + )} + <button onClick={() => setEditingTw(true)} className="text-[10px] px-2 py-0.5 rounded bg-[var(--surface-2)] text-[var(--text-3)] hover:text-[var(--text)] transition-colors">Edit</button> + </> + )} + </div> + </div> + + {/* Engagement stats */} + <div className="grid grid-cols-2 gap-2 mb-3"> + {/* YouTube stats */} + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3 border border-[var(--line)]"> + <p className="eyebrow mb-2">▶ YouTube</p> + <div className="space-y-1"> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Views</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{script.youtubeViews?.toLocaleString() ?? "—"}</span> + </div> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Likes</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{script.youtubeLikes?.toLocaleString() ?? "—"}</span> + </div> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Comments</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{script.youtubeComments?.toLocaleString() ?? "—"}</span> + </div> + </div> + </div> + {/* X stats */} + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3 border border-[var(--line)]"> + <p className="eyebrow mb-2">𝕏 Twitter</p> + <div className="space-y-1"> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Views</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{(script as any).tweetViews?.toLocaleString() ?? "—"}</span> + </div> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Likes</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{script.tweetLikes?.toLocaleString() ?? "—"}</span> + </div> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Bookmarks</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{script.tweetBookmarks?.toLocaleString() ?? "—"}</span> + </div> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Retweets</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{(script as any).tweetRetweets?.toLocaleString() ?? "—"}</span> + </div> + <div className="flex justify-between"> + <span className="text-[11px] text-[var(--text-3)]">Replies</span> + <span className="text-[11px] text-[var(--text)] font-medium num">{(script as any).tweetReplies?.toLocaleString() ?? "—"}</span> + </div> + </div> + </div> + </div> + + {/* Actions */} + <div className="flex items-center gap-2"> + {script.youtubeUrl && ( + <button + onClick={() => scrapeAndUpdate()} + disabled={refreshing} + className={`${BTN_BASE} ${TONE.accent} disabled:opacity-40`} + >{refreshing ? "Fetching..." : "Refresh Metrics"}</button> + )} + <button + onClick={() => updateScript(script.id, { status: "filmed" } as any)} + className={`${BTN_BASE} ${TONE.ghost}`} + >↩ Back to Filmed</button> + </div> + </div> + </div> + ); + } + + return ( + <div dir="ltr" className="space-y-6"> + <RejectModal /> + <PostModal /> + + {/* Header */} + <div className="flex items-center gap-3"> + <span className="text-2xl">📹</span> + <div> + <h1 className="text-xl font-semibold text-[var(--text)]">Long Form</h1> + <div className="flex items-center gap-1.5 mt-0.5"> + <span className="text-[9px] px-2 py-0.5 rounded-full border border-[var(--line)] text-[var(--text-2)]">▶ YouTube</span> + <span className="text-[9px] px-2 py-0.5 rounded-full border border-[var(--line)] text-[var(--text-2)]">𝕏 Twitter</span> + </div> + </div> + <span className="text-xs text-[var(--text-3)] ml-auto num">5-15 min · Teleprompter</span> + </div> + + {/* Type Filter */} + <div className="flex items-center gap-2 mb-2"> + <span className="text-xs text-[var(--text-3)]">Filter:</span> + {(["all", "article", "video"] as const).map(t => ( + <button + key={t} + onClick={() => setTypeFilter(t)} + className={`text-xs px-3 py-1 rounded-full transition-colors ${typeFilter === t ? "border border-[color-mix(in_srgb,var(--accent)_30%,transparent)] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] text-[var(--accent)]" : "text-[var(--text-3)] hover:text-[var(--text-2)] border border-[var(--line)]"}`} + > + {t === "all" ? "All" : t === "article" ? "📝 Articles" : "🎬 Videos"} + </button> + ))} + </div> + + {/* Main Tabs */} + <div className="flex items-center gap-0 border-b border-[var(--line)] overflow-x-auto"> + {([ + { key: "ideas" as const, label: "💡 Ideas", count: draftScripts.length }, + { key: "scripts" as const, label: "📜 Scripts", count: approvedScripts.length }, + { key: "tofilm" as const, label: "🎬 To Film", count: tofilmScripts.length }, + { key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length }, + { key: "posted" as const, label: "🚀 Posted", count: postedScripts.length }, + ]).map(tab => ( + <button + key={tab.key} + onClick={() => setActiveView(tab.key)} + className={`text-sm px-4 py-2.5 font-medium transition-colors relative whitespace-nowrap ${activeView === tab.key ? "text-[var(--accent)]" : "text-[var(--text-3)] hover:text-[var(--text-2)]"}`} + > + {tab.label} (<span className="num">{tab.count}</span>) + {activeView === tab.key && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--accent)] rounded-full" />} + </button> + ))} + </div> + + {/* IDEAS VIEW */} + {activeView === "ideas" && ( + <div className="space-y-4"> + {/* Generate new */} + <div className="panel p-4"> + <div className="flex items-center gap-2 mb-2"> + <p className="text-xs text-[var(--text-3)]">Generate a new long-form script</p> + <div className="flex gap-1 ml-auto"> + {(["article", "video"] as const).map(t => ( + <button key={t} onClick={() => setNewType(t)} + className={`text-xs px-2 py-0.5 rounded transition-colors ${newType === t ? "bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] text-[var(--accent)]" : "text-[var(--text-4)] hover:text-[var(--text-2)]"}`} + >{t === "article" ? "📝 Article" : "🎬 Video"}</button> + ))} + </div> + </div> + <div className="flex gap-2"> + <input + suppressHydrationWarning + type="text" + value={topicInput} + onChange={e => setTopicInput(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") generateScript(); }} + placeholder="e.g. How I built an AI content system from scratch..." + className={`flex-1 ${INPUT} px-3 py-2 text-sm`} + /> + <button + suppressHydrationWarning + onClick={generateScript} + disabled={generating || !topicInput.trim()} + className="btn-primary text-xs px-4 py-2 font-medium disabled:opacity-50 whitespace-nowrap" + >{generating ? "Generating..." : "Generate Script"}</button> + </div> + </div> + + {/* Draft scripts */} + <div className="space-y-3"> + {draftScripts.length === 0 && rejectedScripts.length === 0 ? ( + <div className="panel"> + <EmptyState icon={<span className="text-3xl">📹</span>} title="No draft scripts yet" hint="Generate one above or scripts will appear here" /> + </div> + ) : ( + <> + {draftScripts.length > 0 && ( + <> + <p className="text-xs text-[var(--text-3)]">Pending review (<span className="num">{draftScripts.length}</span>)</p> + {draftScripts.map(s => <ScriptCard key={s.id} script={s} />)} + </> + )} + {rejectedScripts.length > 0 && ( + <details className="mt-4"> + <summary className="text-xs text-[var(--text-3)] cursor-pointer hover:text-[var(--text-2)] transition-colors"> + Rejected (<span className="num">{rejectedScripts.length}</span>) › + </summary> + <div className="mt-2"> + {rejectedScripts.map(s => <ScriptCard key={s.id} script={s} compact />)} + </div> + </details> + )} + </> + )} + </div> + </div> + )} + + {/* SCRIPTS VIEW */} + {activeView === "scripts" && ( + <div className="space-y-3"> + {approvedScripts.length === 0 ? ( + <div className="panel"> + <EmptyState title="No approved scripts" /> + </div> + ) : ( + approvedScripts.map(s => <ScriptCard key={s.id} script={s} />) + )} + </div> + )} + + {/* TO FILM VIEW */} + {activeView === "tofilm" && ( + <div className="space-y-3"> + {tofilmScripts.length === 0 ? ( + <div className="panel"> + <EmptyState icon={<span className="text-3xl">🎬</span>} title="No scripts queued for filming" hint="Approve scripts to move them here" /> + </div> + ) : ( + <> + <div className="flex items-center gap-2"> + <button + onClick={() => { + const text = tofilmScripts.map((s, i) => { + const lines = (s.fullScript || "").split("\n") + .filter(l => !l.startsWith("#") && !l.startsWith("*[") && !l.startsWith("---") && l.trim() !== "") + .join("\n"); + return `Video ${i + 1}: ${s.title}\n${lines}`; + }).join("\n\n---\n\n"); + navigator.clipboard.writeText(text); + }} + className={`text-xs px-4 py-2 rounded-[var(--r-md)] ${TONE.accent} transition-colors font-medium`} + >Copy All Scripts (<span className="num">{tofilmScripts.length}</span>)</button> + <button + onClick={async () => { + for (const s of tofilmScripts) { + await fetch("/api/longform", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: s.id, status: "filmed" }), + }); + } + setScripts(prev => prev.map(s => s.status === "tofilm" ? { ...s, status: "filmed" } : s)); + }} + className={`text-xs px-4 py-2 rounded-[var(--r-md)] ${TONE.up} transition-colors font-medium`} + >Mark All as Filmed (<span className="num">{tofilmScripts.length}</span>)</button> + </div> + {tofilmScripts.map(s => <ScriptCard key={s.id} script={s} />)} + </> + )} + </div> + )} + + {/* FILMED VIEW */} + {activeView === "filmed" && ( + <div className="space-y-3"> + {filmedScripts.length === 0 ? ( + <div className="panel"> + <EmptyState icon={<span className="text-3xl">✅</span>} title="No filmed scripts yet" /> + </div> + ) : ( + filmedScripts.map(s => <ScriptCard key={s.id} script={s} compact />) + )} + </div> + )} + + {/* POSTED VIEW */} + {activeView === "posted" && ( + <div className="space-y-3"> + {postedScripts.length === 0 ? ( + <div className="panel"> + <EmptyState icon={<span className="text-3xl">🚀</span>} title="No posted videos yet" hint="Mark filmed scripts as posted to track engagement" /> + </div> + ) : ( + postedScripts.map(s => <PostedCard key={s.id} script={s} />) + )} + </div> + )} + </div> + ); +} diff --git a/src/app/memory-wiki/page.tsx b/src/app/memory-wiki/page.tsx new file mode 100644 index 0000000..ec6663b --- /dev/null +++ b/src/app/memory-wiki/page.tsx @@ -0,0 +1,886 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + Fragment, +} from "react"; +import { + BookOpen, + Search, + Plus, + X, + Pencil, + Check, + ChevronDown, + Link2, + ShieldCheck, + CalendarClock, +} from "lucide-react"; +import { + Panel, + Button, + Pill, + EmptyState, + Skeleton, + Eyebrow, +} from "@/components/ui/kit"; + +// ── Types ───────────────────────────────────────────────── +type MemType = + | "fact" + | "preference" + | "decision" + | "event" + | "project" + | "contact" + | "lesson" + | "metric" + | "note"; +type MemStatus = "active" | "superseded" | "archived"; + +interface Entry { + id: string; + path: string; + type: MemType; + title: string; + status: MemStatus; + confidence: number | null; + provenance: string | null; + tags: string[]; + links: string[]; + body: string; + validFrom: string | null; + validTo: string | null; + updatedAt: string; + syncedAt: string; +} + +interface MemoryResponse { + entries: Entry[]; + typeCounts: Record<string, number>; + total: number; + lastSync: string | null; +} + +const TYPES: MemType[] = [ + "fact", + "preference", + "decision", + "event", + "project", + "contact", + "lesson", + "metric", + "note", +]; + +type Tone = "neutral" | "up" | "down" | "warn" | "accent"; +function typeTone(t: string): Tone { + if (t === "decision") return "accent"; + if (t === "lesson") return "warn"; + if (t === "project") return "up"; + return "neutral"; +} + +// ── Helpers ─────────────────────────────────────────────── +function timeAgo(d: string | null): string { + if (!d) return "—"; + const diff = Date.now() - new Date(d).getTime(); + if (Number.isNaN(diff)) return "—"; + const s = Math.floor(diff / 1000); + if (s < 45) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const days = Math.floor(h / 24); + if (days < 30) return `${days}d ago`; + const mo = Math.floor(days / 30); + return `${mo}mo ago`; +} + +function fmtDate(d: string | null): string { + if (!d) return "—"; + const dt = new Date(d); + if (Number.isNaN(dt.getTime())) return "—"; + return dt.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +async function getJSON<T>(url: string): Promise<T | null> { + try { + const r = await fetch(url); + if (!r.ok) return null; + return (await r.json()) as T; + } catch { + return null; + } +} + +// ── Confidence dot ──────────────────────────────────────── +function confidenceMeta(c: number): { label: string; color: string } { + // Accept 0–1 or 0–100 scales. + const v = c > 1 ? c / 100 : c; + if (v >= 0.75) return { label: "high confidence", color: "var(--up)" }; + if (v >= 0.4) return { label: "medium confidence", color: "var(--warn)" }; + return { label: "low confidence", color: "var(--down)" }; +} + +function ConfidenceDot({ value }: { value: number }) { + const { label, color } = confidenceMeta(value); + return ( + <span + className="inline-flex items-center gap-1.5 num text-[10.5px] text-[var(--text-3)]" + title={label} + > + <span + className="w-1.5 h-1.5 rounded-full shrink-0" + style={{ background: color }} + /> + {label} + </span> + ); +} + +// ── Tiny, safe markdown renderer (builds React elements) ── +// Handles: # / ## headings, - / * bullets, - [ ] / - [x] checkboxes, +// blank-line spacing, paragraphs, and inline `code`. +function renderInline(text: string, keyBase: string): React.ReactNode[] { + const parts = text.split(/(`[^`]+`)/g); + return parts.map((p, i) => { + if (p.startsWith("`") && p.endsWith("`") && p.length > 1) { + return ( + <code + key={`${keyBase}-c${i}`} + className="num text-[12.5px] px-1.5 py-0.5 rounded-[5px] bg-[var(--surface-2)] border border-[var(--line)] text-[var(--text)]" + > + {p.slice(1, -1)} + </code> + ); + } + return <Fragment key={`${keyBase}-t${i}`}>{p}</Fragment>; + }); +} + +function Markdown({ body }: { body: string }) { + const lines = (body || "").replace(/\r\n/g, "\n").split("\n"); + const blocks: React.ReactNode[] = []; + let bullets: { text: string; check: boolean | null }[] = []; + let key = 0; + + const flushBullets = () => { + if (!bullets.length) return; + const items = bullets; + bullets = []; + blocks.push( + <ul key={`ul-${key++}`} className="my-1.5 space-y-1"> + {items.map((it, i) => ( + <li + key={i} + className="flex items-start gap-2 text-[13.5px] leading-relaxed text-[var(--text-2)]" + > + {it.check === null ? ( + <span className="mt-[9px] w-1 h-1 rounded-full bg-[var(--text-3)] shrink-0" /> + ) : ( + <span + className="mt-[2px] w-3.5 h-3.5 rounded-[4px] border shrink-0 flex items-center justify-center" + style={{ + borderColor: it.check ? "var(--up)" : "var(--line-strong)", + background: it.check + ? "color-mix(in srgb, var(--up) 16%, transparent)" + : "transparent", + }} + > + {it.check && ( + <Check className="w-2.5 h-2.5" style={{ color: "var(--up)" }} /> + )} + </span> + )} + <span className={it.check ? "opacity-70" : ""}> + {renderInline(it.text, `li-${key}-${i}`)} + </span> + </li> + ))} + </ul> + ); + }; + + for (const raw of lines) { + const line = raw.trimEnd(); + const checkM = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/); + const bulletM = line.match(/^\s*[-*]\s+(.*)$/); + + if (checkM) { + bullets.push({ text: checkM[2], check: checkM[1].toLowerCase() === "x" }); + continue; + } + if (bulletM) { + bullets.push({ text: bulletM[1], check: null }); + continue; + } + flushBullets(); + + if (line.startsWith("## ")) { + blocks.push( + <h4 + key={`h-${key++}`} + className="mt-3 mb-1 text-[13px] font-semibold tracking-[-0.01em] text-[var(--text)]" + > + {renderInline(line.slice(3), `h2-${key}`)} + </h4> + ); + } else if (line.startsWith("# ")) { + blocks.push( + <h3 + key={`h-${key++}`} + className="mt-3 mb-1 text-[15px] font-semibold tracking-[-0.015em] text-[var(--text)]" + > + {renderInline(line.slice(2), `h1-${key}`)} + </h3> + ); + } else if (line.trim() === "") { + blocks.push(<div key={`sp-${key++}`} className="h-2" />); + } else { + blocks.push( + <p + key={`p-${key++}`} + className="text-[13.5px] leading-relaxed text-[var(--text-2)]" + > + {renderInline(line, `p-${key}`)} + </p> + ); + } + } + flushBullets(); + + if (!blocks.length) { + return ( + <p className="text-[13px] text-[var(--text-3)] italic">No content.</p> + ); + } + return <div className="space-y-0.5">{blocks}</div>; +} + +// ── Entry card ──────────────────────────────────────────── +function EntryCard({ + entry, + expanded, + onToggle, + onEdit, +}: { + entry: Entry; + expanded: boolean; + onToggle: () => void; + onEdit: () => void; +}) { + return ( + <Panel className="p-0 overflow-hidden"> + <button + type="button" + onClick={onToggle} + aria-expanded={expanded} + className="w-full text-left px-5 py-4 flex items-start gap-3" + > + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2 flex-wrap mb-1.5"> + <Pill tone={typeTone(entry.type)}>{entry.type}</Pill> + {entry.status !== "active" && ( + <Pill tone="neutral">{entry.status}</Pill> + )} + {entry.confidence != null && ( + <ConfidenceDot value={entry.confidence} /> + )} + </div> + <h3 className="text-[15px] font-medium text-[var(--text)] leading-snug truncate"> + {entry.title} + </h3> + {entry.tags.length > 0 && ( + <div className="flex items-center gap-1.5 flex-wrap mt-2"> + {entry.tags.slice(0, 6).map((t) => ( + <span + key={t} + className="num text-[10.5px] text-[var(--text-3)] rounded-full px-2 py-0.5 bg-[var(--surface-2)] border border-[var(--line)]" + > + #{t} + </span> + ))} + </div> + )} + </div> + <div className="flex flex-col items-end gap-2 shrink-0"> + <span className="num text-[10.5px] text-[var(--text-3)]"> + {timeAgo(entry.updatedAt)} + </span> + <ChevronDown + className={`w-4 h-4 text-[var(--text-3)] transition-transform ${ + expanded ? "rotate-180" : "" + }`} + /> + </div> + </button> + + {expanded && ( + <div className="px-5 pb-5 pt-1"> + <div className="rule mb-4" /> + <Markdown body={entry.body} /> + + {/* Metadata */} + <div className="mt-5 flex flex-col gap-2.5"> + {entry.provenance && ( + <div className="flex items-center gap-2 text-[11.5px] text-[var(--text-3)]"> + <ShieldCheck className="w-3.5 h-3.5 shrink-0" /> + <span> + source{" "} + <span className="text-[var(--text-2)]">{entry.provenance}</span> + </span> + </div> + )} + {(entry.validFrom || entry.validTo) && ( + <div className="flex items-center gap-2 num text-[11.5px] text-[var(--text-3)]"> + <CalendarClock className="w-3.5 h-3.5 shrink-0" /> + <span> + valid {fmtDate(entry.validFrom)} + {" → "} + {entry.validTo ? fmtDate(entry.validTo) : "present"} + </span> + </div> + )} + {entry.links.length > 0 && ( + <div className="flex items-start gap-2 text-[11.5px] text-[var(--text-3)]"> + <Link2 className="w-3.5 h-3.5 shrink-0 mt-1" /> + <div className="flex items-center gap-1.5 flex-wrap"> + {entry.links.map((l) => ( + <span + key={l} + className="num text-[10.5px] text-[var(--text-2)] rounded-full px-2 py-0.5 bg-[var(--surface-2)] border border-[var(--line)]" + > + {l} + </span> + ))} + </div> + </div> + )} + <div className="flex items-center gap-2 num text-[11px] text-[var(--text-4)]"> + <span>{entry.path}</span> + </div> + </div> + + <div className="mt-5"> + <Button variant="ghost" size="sm" onClick={onEdit}> + <Pencil className="w-3.5 h-3.5" /> + Edit + </Button> + </div> + </div> + )} + </Panel> + ); +} + +// ── Edit / New slide-over ───────────────────────────────── +interface Draft { + id?: string; + path?: string; + title: string; + type: MemType; + tags: string; + status: MemStatus; + confidence: string; + body: string; +} + +function emptyDraft(): Draft { + return { + title: "", + type: "note", + tags: "", + status: "active", + confidence: "", + body: "", + }; +} + +function draftFrom(e: Entry): Draft { + return { + id: e.id, + path: e.path, + title: e.title, + type: e.type, + tags: e.tags.join(", "), + status: e.status, + confidence: e.confidence != null ? String(e.confidence) : "", + body: e.body, + }; +} + +function EntryEditor({ + draft, + isNew, + onClose, + onSaved, +}: { + draft: Draft; + isNew: boolean; + onClose: () => void; + onSaved: () => void; +}) { + const [d, setD] = useState<Draft>(draft); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + + const set = <K extends keyof Draft>(k: K, v: Draft[K]) => + setD((prev) => ({ ...prev, [k]: v })); + + const save = async () => { + if (!d.title.trim() || busy) return; + setBusy(true); + const tags = d.tags + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + const confNum = d.confidence.trim() ? Number(d.confidence) : null; + const body: Record<string, unknown> = { + id: d.id, + path: d.path, + type: d.type, + title: d.title.trim(), + body: d.body, + tags, + status: d.status, + confidence: Number.isFinite(confNum as number) ? confNum : null, + }; + try { + const r = await fetch("/api/hermes/memory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.ok) { + setSaved(true); + setTimeout(() => { + onSaved(); + }, 2000); + } else { + setBusy(false); + } + } catch { + setBusy(false); + } + }; + + const inputCls = + "w-full bg-transparent text-[14px] text-[var(--text)] placeholder:text-[var(--text-3)] px-3.5 py-2.5 rounded-[10px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] transition-colors"; + const labelCls = + "block eyebrow !mb-2"; + + return ( + <div className="fixed inset-0 z-50 flex justify-end"> + <button + type="button" + aria-label="Close editor" + onClick={onClose} + className="absolute inset-0 bg-black/50" + style={{ backdropFilter: "blur(2px)" }} + /> + <div className="elevated relative w-full max-w-md h-full overflow-y-auto p-6 animate-[hq-rise_0.3s_ease]"> + <div className="flex items-start justify-between gap-4 mb-6"> + <div> + <Eyebrow>{isNew ? "New memory" : "Edit memory"}</Eyebrow> + <h2 className="mt-1.5 text-[22px] font-semibold tracking-[-0.02em] text-[var(--text)]"> + {isNew ? "Add entry" : "Correct entry"} + </h2> + </div> + <button + type="button" + onClick={onClose} + aria-label="Close" + className="btn-ghost inline-flex items-center justify-center w-9 h-9 shrink-0" + > + <X className="w-4 h-4" /> + </button> + </div> + + {saved ? ( + <div className="flex flex-col items-center justify-center text-center py-16"> + <div + className="mb-4 w-12 h-12 rounded-full flex items-center justify-center" + style={{ + background: "color-mix(in srgb, var(--up) 14%, transparent)", + border: "1px solid color-mix(in srgb, var(--up) 30%, transparent)", + }} + > + <Check className="w-6 h-6" style={{ color: "var(--up)" }} /> + </div> + <p className="text-[15px] font-medium text-[var(--text)]"> + Saved — Hermes will write it to memory + </p> + <p className="mt-1.5 text-[12.5px] text-[var(--text-3)] max-w-xs"> + The bridge is committing this to the wiki. It will reappear here + once mirrored. + </p> + </div> + ) : ( + <div className="space-y-4"> + <div> + <label className={labelCls}>Title</label> + <input + value={d.title} + onChange={(e) => set("title", e.target.value)} + placeholder="What should Hermes remember?" + className={inputCls} + autoFocus + /> + </div> + + <div className="grid grid-cols-2 gap-3"> + <div> + <label className={labelCls}>Type</label> + <select + value={d.type} + onChange={(e) => set("type", e.target.value as MemType)} + className={inputCls} + > + {TYPES.map((t) => ( + <option key={t} value={t}> + {t} + </option> + ))} + </select> + </div> + <div> + <label className={labelCls}>Status</label> + <select + value={d.status} + onChange={(e) => set("status", e.target.value as MemStatus)} + className={inputCls} + > + <option value="active">active</option> + <option value="superseded">superseded</option> + <option value="archived">archived</option> + </select> + </div> + </div> + + <div> + <label className={labelCls}>Tags</label> + <input + value={d.tags} + onChange={(e) => set("tags", e.target.value)} + placeholder="comma, separated, tags" + className={inputCls} + /> + </div> + + <div> + <label className={labelCls}>Confidence (optional)</label> + <input + value={d.confidence} + onChange={(e) => set("confidence", e.target.value)} + placeholder="0–1 (e.g. 0.9)" + inputMode="decimal" + className={`${inputCls} num`} + /> + </div> + + <div> + <label className={labelCls}>Body · markdown</label> + <textarea + value={d.body} + onChange={(e) => set("body", e.target.value)} + rows={10} + placeholder={"# Heading\n- bullet\n- [ ] task\n\nInline `code` supported."} + className={`${inputCls} resize-y leading-relaxed`} + /> + </div> + + <div className="flex items-center gap-2 pt-1"> + <Button + variant="primary" + onClick={save} + disabled={busy || !d.title.trim()} + > + <Check className="w-3.5 h-3.5" /> + Save to memory + </Button> + <Button variant="ghost" onClick={onClose}> + Cancel + </Button> + </div> + </div> + )} + </div> + </div> + ); +} + +// ── Main ────────────────────────────────────────────────── +export default function MemoryWikiPage() { + const [q, setQ] = useState(""); + const [qInput, setQInput] = useState(""); + const [typeFilter, setTypeFilter] = useState<string>("all"); + const [statusAll, setStatusAll] = useState(false); + + const [entries, setEntries] = useState<Entry[]>([]); + const [typeCounts, setTypeCounts] = useState<Record<string, number>>({}); + const [total, setTotal] = useState(0); + const [lastSync, setLastSync] = useState<string | null>(null); + const [loaded, setLoaded] = useState(false); + + const [expandedId, setExpandedId] = useState<string | null>(null); + const [editor, setEditor] = useState<{ draft: Draft; isNew: boolean } | null>( + null + ); + + // Debounce search input → q + useEffect(() => { + const t = setTimeout(() => setQ(qInput.trim()), 350); + return () => clearTimeout(t); + }, [qInput]); + + const load = useCallback(async () => { + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (typeFilter !== "all") params.set("type", typeFilter); + params.set("status", statusAll ? "all" : "active"); + const data = await getJSON<MemoryResponse>( + `/api/hermes/memory?${params.toString()}` + ); + if (data) { + setEntries(data.entries ?? []); + setTypeCounts(data.typeCounts ?? {}); + setTotal(data.total ?? 0); + setLastSync(data.lastSync ?? null); + } + setLoaded(true); + }, [q, typeFilter, statusAll]); + + // Reload on filter change + poll every 10s + useEffect(() => { + load(); + const iv = setInterval(load, 10000); + return () => clearInterval(iv); + }, [load]); + + const chips = useMemo(() => { + return TYPES.filter((t) => (typeCounts[t] ?? 0) > 0).map((t) => ({ + type: t, + count: typeCounts[t] ?? 0, + })); + }, [typeCounts]); + + const openNew = () => + setEditor({ draft: emptyDraft(), isNew: true }); + const openEdit = (e: Entry) => + setEditor({ draft: draftFrom(e), isNew: false }); + + return ( + <> + <div className="relative z-10 w-full mx-auto pb-16"> + {/* Header */} + <div className="hq-rise pt-4 pb-8 flex flex-col sm:flex-row sm:items-end justify-between gap-5"> + <div> + <Eyebrow>Hermes memory</Eyebrow> + <h1 className="mt-2.5 text-[40px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]"> + Memory Wiki + </h1> + <p className="mt-3.5 text-[14px] text-[var(--text-2)] leading-relaxed max-w-lg"> + Hermes' long-term brain — everything it remembers, that you + can browse, search, and correct. + </p> + <p className="num text-[11.5px] text-[var(--text-3)] mt-3"> + synced {timeAgo(lastSync)} + </p> + </div> + <div className="shrink-0"> + <Button variant="primary" onClick={openNew}> + <Plus className="w-3.5 h-3.5" /> + New entry + </Button> + </div> + </div> + + {/* Toolbar */} + <div className="hq-rise flex flex-col gap-3"> + <div className="flex items-center gap-3"> + <div className="relative flex-1 min-w-0"> + <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-3)] pointer-events-none" /> + <input + value={qInput} + onChange={(e) => setQInput(e.target.value)} + placeholder="Search memory — titles, bodies, tags…" + className="w-full bg-transparent text-[14px] text-[var(--text)] placeholder:text-[var(--text-3)] pl-10 pr-3.5 py-2.5 rounded-[10px] border border-[var(--line)] focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] outline-none transition-colors" + /> + </div> + {/* Status toggle */} + <div className="flex items-center rounded-full border border-[var(--line)] p-0.5 shrink-0"> + <button + type="button" + onClick={() => setStatusAll(false)} + className={`px-3 py-1.5 rounded-full text-[12px] font-medium transition-colors ${ + !statusAll + ? "bg-[var(--surface-2)] text-[var(--text)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)]" + }`} + > + Active + </button> + <button + type="button" + onClick={() => setStatusAll(true)} + className={`px-3 py-1.5 rounded-full text-[12px] font-medium transition-colors ${ + statusAll + ? "bg-[var(--surface-2)] text-[var(--text)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)]" + }`} + > + All + </button> + </div> + </div> + + {/* Type filter chips */} + <div className="flex items-center gap-2 flex-wrap"> + <TypeChip + label="All" + count={total} + active={typeFilter === "all"} + onClick={() => setTypeFilter("all")} + /> + {chips.map((c) => ( + <TypeChip + key={c.type} + label={c.type} + count={c.count} + tone={typeTone(c.type)} + active={typeFilter === c.type} + onClick={() => + setTypeFilter(typeFilter === c.type ? "all" : c.type) + } + /> + ))} + </div> + </div> + + {/* List */} + <div className="mt-8 space-y-3"> + {!loaded ? ( + <> + <Skeleton className="h-24" /> + <Skeleton className="h-24" /> + <Skeleton className="h-24" /> + </> + ) : entries.length === 0 ? ( + <Panel className="p-2"> + <EmptyState + icon={<BookOpen className="w-6 h-6" />} + title={ + q || typeFilter !== "all" + ? "No matching entries" + : "No memory synced yet" + } + hint={ + q || typeFilter !== "all" + ? "Try a different search or clear the filters." + : "Once the Hermes bridge is running with a ~/.hermes/wiki, entries appear here." + } + action={ + q || typeFilter !== "all" ? ( + <Button + variant="ghost" + size="sm" + onClick={() => { + setQInput(""); + setQ(""); + setTypeFilter("all"); + }} + > + Clear filters + </Button> + ) : undefined + } + /> + </Panel> + ) : ( + entries.map((e) => ( + <EntryCard + key={e.id} + entry={e} + expanded={expandedId === e.id} + onToggle={() => + setExpandedId((cur) => (cur === e.id ? null : e.id)) + } + onEdit={() => openEdit(e)} + /> + )) + )} + </div> + </div> + + {editor && ( + <EntryEditor + draft={editor.draft} + isNew={editor.isNew} + onClose={() => setEditor(null)} + onSaved={() => { + setEditor(null); + load(); + }} + /> + )} + </> + ); +} + +// ── Type filter chip ────────────────────────────────────── +function TypeChip({ + label, + count, + tone = "neutral", + active, + onClick, +}: { + label: string; + count: number; + tone?: Tone; + active: boolean; + onClick: () => void; +}) { + const map: Record<Tone, string> = { + neutral: "var(--text-3)", + up: "var(--up)", + down: "var(--down)", + warn: "var(--warn)", + accent: "var(--accent)", + }; + const c = map[tone]; + return ( + <button + type="button" + onClick={onClick} + className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors" + style={ + active + ? { + color: c, + background: `color-mix(in srgb, ${c} 14%, transparent)`, + border: `1px solid color-mix(in srgb, ${c} 30%, transparent)`, + } + : { + color: "var(--text-2)", + background: "transparent", + border: "1px solid var(--line)", + } + } + > + <span className="capitalize">{label}</span> + <span className="num text-[11px] opacity-70">{count}</span> + </button> + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..f4488a6 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,577 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { Twitter, Youtube, ArrowUpRight, ArrowDownRight, ChevronRight } from "lucide-react"; +import { MetricCard } from "@/components/ui/metric-card"; +import { Sparkline } from "@/components/sparkline"; +import { HermesBriefing } from "@/components/hermes-briefing"; +import { ApprovalInbox } from "@/components/approval-inbox"; + +// ── Types ───────────────────────────────────────────────── +interface HLPosition { + asset: string; direction: string; unrealizedPnl: number; + unrealizedPnlPct: number; leverage: number; stopLoss?: number; takeProfit?: number; +} +interface Tweet { id: string; text: string; views: number; engRate: number; postedAt: string | null; tweetUrl: string | null } +interface Video { title: string; thumbnail: string; url: string; publishedAt: string } +interface Draft { id: string; text: string } +interface YTIdea { title: string; hook: string } +interface BuildIdea { title: string; description: string; effort: string } +interface Process { name: string; status: string; uptime: string } +interface KanbanTask { id: string; title: string; assignee: string; status: string; priority: number } +interface HermesKanban { board: string; slug: string; total: number; counts: Record<string, number>; tasks: KanbanTask[] } +interface ScoreComponent { score: number; weight?: number; label: string; detail?: string } +interface ScoreData { score: number; grade: string; label: string; color: string; period?: string; components: Record<string, ScoreComponent> } + +interface HomeData { + xFollowers: number; xGoal: number; xHandle: string; + topTweets: Tweet[]; topTweet: Tweet | null; xViewsThisWeek: number; + totalTweets: number; daysSincePost: number; + bestPostingDay: string; bestPostingHourStr: string; + topSageDrafts: Draft[]; + topYoutubeIdeas: YTIdea[]; + topBuildIdeas: BuildIdea[]; + topVideo: Video | null; latestVideo: Video | null; + ytSubscribers: number; ytGoal: number; + polyBalance: number; polyWinRate: number; polyTodayPnl: number; polyAllTimePnl: number; + hlBalance: number; hlPosition: HLPosition | null; hlTodayPnl: number; hlAllTimePnl: number; + allTimePnl: number; todayPnl: number; + processes: Process[]; + hermesKanban: HermesKanban; + xViewsTrend: number[]; + snapshots: { d: string; xf: number; yt: number; pnl: number }[]; +} + +const EMPTY: HomeData = { + xFollowers: 0, xGoal: 100000, xHandle: "yourhandle", + topTweets: [], topTweet: null, xViewsThisWeek: 0, totalTweets: 0, + daysSincePost: 999, bestPostingDay: "—", bestPostingHourStr: "—", + topSageDrafts: [], topYoutubeIdeas: [], topBuildIdeas: [], + topVideo: null, latestVideo: null, ytSubscribers: 0, ytGoal: 20000, + polyBalance: 0, polyWinRate: 0, polyTodayPnl: 0, polyAllTimePnl: 0, + hlBalance: 0, hlPosition: null, hlTodayPnl: 0, hlAllTimePnl: 0, + allTimePnl: 0, todayPnl: 0, processes: [], + hermesKanban: { board: "Hermes 24/7 Assistant", slug: "hermes-24-7-assistant", total: 0, counts: {}, tasks: [] }, + xViewsTrend: [], snapshots: [], +}; + +// ── Animated counter ────────────────────────────────────── +function useCountUp(target: number, duration = 1400, enabled = true) { + const [val, setVal] = useState(0); + const raf = useRef<number | null>(null); + useEffect(() => { + const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (!enabled || target === 0 || reduce) { setVal(target); return; } + const start = Date.now(); + const tick = () => { + const t = Math.min((Date.now() - start) / duration, 1); + const ease = 1 - Math.pow(1 - t, 4); + setVal(Math.round(target * ease)); + if (t < 1) raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => { if (raf.current) cancelAnimationFrame(raf.current); }; + }, [target, duration, enabled]); + return val; +} + +// ── Helpers ─────────────────────────────────────────────── +function fmt(n: number) { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; + if (n >= 1_000) { + const k = Math.round(n / 1_000); + if (k >= 1000) return (n / 1_000_000).toFixed(1) + "M"; + return k + "K"; + } + return n.toString(); +} +function fmtExact(n: number) { return n.toLocaleString("en-US"); } +function fmtUsd(n: number, alwaysSign = false) { + const abs = Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const sign = n >= 0 ? (alwaysSign ? "+" : "") : "-"; + return `${sign}$${abs}`; +} +function timeAgo(d: string) { + const diff = Date.now() - new Date(d).getTime(); + const days = Math.floor(diff / 86400000); + const hrs = Math.floor(diff / 3600000); + if (days > 0) return `${days}d ago`; + if (hrs > 0) return `${hrs}h ago`; + return "just now"; +} +function greeting() { + const h = new Date().getHours(); + if (h < 12) return "Good morning"; + if (h < 17) return "Good afternoon"; + if (h < 21) return "Good evening"; + return "Still up"; +} + +// Local-dev preview only — never runs in production builds. Lets the full +// card structure (delta + sparkline) show before real snapshot history exists. +const DEV_PREVIEW = process.env.NODE_ENV !== "production"; +function sampleSeries(current: number, n: number) { + return Array.from({ length: n }, (_, i) => { + const ramp = 0.82 + (0.18 * i) / (n - 1); + const wobble = 1 + Math.sin(i * 1.3) * 0.03; + return Math.max(0, Math.round(current * ramp * wobble)); + }); +} +type Snap = { d: string; xf: number; yt: number; pnl: number }; +function snapDelta(snaps: Snap[], key: "xf" | "yt" | "pnl") { + const series = snaps.map(s => s[key]); + if (snaps.length < 2) return { delta: null as number | null, deltaPct: null as number | null, label: undefined as string | undefined, series }; + const first = snaps[0][key]; + const last = snaps[snaps.length - 1][key]; + const delta = last - first; + const deltaPct = first !== 0 ? (delta / Math.abs(first)) * 100 : 0; + return { delta, deltaPct, label: `${snaps.length - 1}d`, series }; +} +function withDevPreview(d: { delta: number | null; deltaPct: number | null; label?: string; series: number[] }, current: number) { + if (!DEV_PREVIEW || d.series.length >= 2) return d; + const series = sampleSeries(current, 12); + const first = series[0], last = series[series.length - 1]; + return { delta: last - first, deltaPct: first ? ((last - first) / first) * 100 : 0, label: "sample", series }; +} + +// ── Section header ──────────────────────────────────────── +function SectionLabel({ children, right }: { children: React.ReactNode; right?: React.ReactNode }) { + return ( + <div className="flex items-center gap-3 mb-3"> + <span className="eyebrow">{children}</span> + <span className="h-px flex-1 bg-[var(--hq-hairline)]" /> + {right} + </div> + ); +} + +// ── Ideas section ───────────────────────────────────────── +type IdeaTab = "x" | "youtube" | "builds"; +function IdeasPanel({ sageDrafts, ytIdeas, buildIdeas }: { + sageDrafts: Draft[]; ytIdeas: YTIdea[]; buildIdeas: BuildIdea[]; +}) { + const [tab, setTab] = useState<IdeaTab>("x"); + const tabs: { key: IdeaTab; label: string; count: number }[] = [ + { key: "x", label: "X", count: sageDrafts.length }, + { key: "youtube", label: "YouTube", count: ytIdeas.length }, + { key: "builds", label: "Builds", count: buildIdeas.length }, + ]; + + return ( + <div className="panel flex flex-col p-6 h-full"> + <div className="flex items-center justify-between mb-4"> + <span className="eyebrow">Top Ideas</span> + <div className="flex gap-1 rounded-lg border border-[var(--hq-hairline)] p-0.5"> + {tabs.map(t => ( + <button + key={t.key} + onClick={() => setTab(t.key)} + className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-colors ${ + tab === t.key ? "bg-white/[0.08] text-[var(--hq-text)]" : "text-[var(--hq-text-ghost)] hover:text-[var(--hq-text-dim)]" + }`} + > + {t.label} + {t.count > 0 && <span className="ml-1 num text-[var(--hq-text-ghost)]">{t.count}</span>} + </button> + ))} + </div> + </div> + + <div className="space-y-1 min-h-[172px]"> + {tab === "x" && (sageDrafts.length > 0 ? sageDrafts.map((d, i) => ( + <a key={d.id} href="/x-content" className="group flex gap-3 items-center py-2 border-b border-[var(--hq-hairline)] last:border-0 hover:opacity-100 transition-opacity"> + <span className="num text-[11px] text-[var(--hq-text-ghost)] w-5 shrink-0">{String(i + 1).padStart(2, "0")}</span> + <p className="text-[var(--hq-text-dim)] text-[13px] leading-snug line-clamp-1 flex-1 group-hover:text-[var(--hq-text)] transition-colors">{d.text}</p> + <ChevronRight className="w-3.5 h-3.5 text-[var(--hq-text-ghost)] group-hover:text-[var(--hq-text-dim)] shrink-0 transition-all group-hover:translate-x-0.5" /> + </a> + )) : <Empty>No pending drafts.</Empty>)} + + {tab === "youtube" && (ytIdeas.length > 0 ? ytIdeas.map((it, idx) => ( + <a key={idx} href="/youtube" className="group flex gap-3 items-center py-2 border-b border-[var(--hq-hairline)] last:border-0"> + <span className="num text-[11px] text-[var(--hq-text-ghost)] w-5 shrink-0">{String(idx + 1).padStart(2, "0")}</span> + <p className="text-[var(--hq-text-dim)] text-[13px] font-medium line-clamp-1 flex-1 group-hover:text-[var(--hq-text)] transition-colors">{it.title}</p> + <ChevronRight className="w-3.5 h-3.5 text-[var(--hq-text-ghost)] group-hover:text-[var(--hq-text-dim)] shrink-0 transition-all group-hover:translate-x-0.5" /> + </a> + )) : <Empty>No YouTube ideas yet.</Empty>)} + + {tab === "builds" && (buildIdeas.length > 0 ? buildIdeas.map((it, idx) => ( + <div key={idx} className="flex gap-3 items-center py-2 border-b border-[var(--hq-hairline)] last:border-0"> + <span className="num text-[11px] text-[var(--hq-text-ghost)] w-5 shrink-0">{String(idx + 1).padStart(2, "0")}</span> + <p className="text-[var(--hq-text-dim)] text-[13px] font-medium line-clamp-1 flex-1">{it.title}</p> + <span className="text-[10px] px-1.5 py-0.5 rounded-md num border shrink-0" + style={it.effort === "quick win" + ? { color: "var(--hq-up)", borderColor: "rgba(52,211,153,0.25)", background: "rgba(52,211,153,0.08)" } + : it.effort === "large" + ? { color: "var(--hq-down)", borderColor: "rgba(251,113,133,0.25)", background: "rgba(251,113,133,0.08)" } + : { color: "var(--hq-warn)", borderColor: "rgba(251,191,36,0.25)", background: "rgba(251,191,36,0.08)" }}> + {it.effort} + </span> + </div> + )) : <Empty>No build ideas yet.</Empty>)} + </div> + </div> + ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return <p className="text-[var(--hq-text-ghost)] text-[13px] py-8 text-center">{children}</p>; +} + +// ── Top tweets ──────────────────────────────────────────── +function TopTweetsPanel({ tweets }: { tweets: Tweet[] }) { + return ( + <div className="panel flex flex-col p-6 h-full"> + <div className="flex items-center gap-2 mb-4"> + <Twitter className="w-3.5 h-3.5" style={{ color: "#38bdf8" }} /> + <span className="eyebrow">Top Tweets · 7d</span> + </div> + {tweets.length === 0 ? <Empty>No tweet data yet</Empty> : ( + <div className="space-y-0"> + {tweets.slice(0, 3).map((t, i) => ( + <a key={t.id} href={t.tweetUrl || "/x"} target="_blank" rel="noreferrer" + className="group flex gap-3 py-3 border-b border-[var(--hq-hairline)] last:border-0"> + <span className="num text-[11px] text-[var(--hq-text-ghost)] w-5 shrink-0 mt-0.5">{String(i + 1).padStart(2, "0")}</span> + <div className="flex-1 min-w-0"> + {t.text + ? <p className="text-[var(--hq-text-dim)] text-[13px] leading-snug line-clamp-2 mb-1.5 group-hover:text-[var(--hq-text)] transition-colors">{t.text}</p> + : <p className="text-[var(--hq-text-ghost)] text-[13px] italic mb-1.5">External tweet</p>} + <div className="flex items-center gap-3 text-[11px] num"> + <span className="text-[var(--hq-text)] font-semibold">{fmt(t.views)}<span className="text-[var(--hq-text-ghost)] font-normal"> views</span></span> + <span className="text-[var(--hq-text-faint)]">{t.engRate.toFixed(1)}% eng</span> + {t.postedAt && <span className="text-[var(--hq-text-ghost)]">{timeAgo(t.postedAt)}</span>} + </div> + </div> + <ArrowUpRight className="w-3.5 h-3.5 text-[var(--hq-text-ghost)] group-hover:text-[var(--hq-text-dim)] shrink-0 mt-0.5 transition-all group-hover:translate-x-0.5 group-hover:-translate-y-0.5" /> + </a> + ))} + </div> + )} + </div> + ); +} + +// ── X analytics panel ───────────────────────────────────── +function XAnalyticsPanel({ views, trend, totalTweets, bestDay, bestHour }: { + views: number; trend: number[]; totalTweets: number; bestDay: string; bestHour: string; +}) { + return ( + <div className="panel flex flex-col p-6 h-full"> + <div className="flex items-center gap-2 mb-4"> + <Twitter className="w-3.5 h-3.5" style={{ color: "#38bdf8" }} /> + <span className="eyebrow">X Analytics</span> + </div> + <div className="space-y-4"> + <div> + <div className="eyebrow mb-2 !text-[9.5px]">Views · 7d</div> + <div className="num font-semibold text-[40px] leading-[0.95] tracking-[-0.02em] text-[var(--hq-text)]">{fmt(views)}</div> + {trend.some(v => v > 0) && <Sparkline data={trend} color="#38bdf8" area idSeed="xviews" className="h-9 mt-3" />} + </div> + <div className="grid grid-cols-2 gap-3 pt-1"> + <div> + <div className="eyebrow mb-1.5 !text-[9.5px]">Tracked</div> + <div className="num font-semibold text-[18px] text-[var(--hq-text)]">{fmtExact(totalTweets)}</div> + </div> + <div> + <div className="eyebrow mb-1.5 !text-[9.5px]">Best window</div> + <div className="text-[13px] font-medium text-[var(--hq-text-dim)]">{bestDay}<span className="num"> · {bestHour}</span></div> + </div> + </div> + </div> + <a href="/x" className="mt-auto pt-4 flex items-center gap-1 text-[var(--hq-text-faint)] text-[11px] font-medium hover:text-[var(--hq-text-dim)] transition-colors group"> + Open X dashboard <ArrowUpRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" /> + </a> + </div> + ); +} + +// ── YouTube ─────────────────────────────────────────────── +function YouTubeCard({ video, label }: { video: Video; label: string }) { + return ( + <a href={video.url} target="_blank" rel="noreferrer" className="panel panel-interactive group flex flex-col overflow-hidden"> + {video.thumbnail && ( + // eslint-disable-next-line @next/next/no-img-element + <img src={video.thumbnail} alt={video.title} className="w-full aspect-video object-cover opacity-75 group-hover:opacity-100 transition-opacity" /> + )} + <div className="p-4"> + <div className="eyebrow mb-2" style={{ color: "#f87171" }}>{label}</div> + <p className="text-[var(--hq-text-dim)] text-[13px] font-medium line-clamp-2 leading-snug group-hover:text-[var(--hq-text)] transition-colors">{video.title}</p> + {video.publishedAt && <p className="num text-[var(--hq-text-ghost)] text-[11px] mt-2">{timeAgo(video.publishedAt)}</p>} + </div> + </a> + ); +} + +// ── YouTube: Top Performing vs Latest (tabbed) ──────────── +function YouTubeVideoTabs({ topVideo, latestVideo }: { topVideo: Video | null; latestVideo: Video | null }) { + const [tab, setTab] = useState<"top" | "latest">("top"); + const video = tab === "top" ? (topVideo ?? latestVideo) : (latestVideo ?? topVideo); + if (!video) return null; + const Btn = ({ k, label }: { k: "top" | "latest"; label: string }) => ( + <button + onClick={() => setTab(k)} + className={`px-3 py-1.5 rounded-md text-[11px] font-medium transition-colors ${ + tab === k ? "bg-white/[0.08] text-[var(--hq-text)]" : "text-[var(--hq-text-ghost)] hover:text-[var(--hq-text-dim)]" + }`} + > + {label} + </button> + ); + return ( + <div className="panel flex flex-col overflow-hidden"> + <div className="flex items-center gap-1 p-2 border-b border-[var(--hq-hairline)]"> + <span className="eyebrow ml-2 mr-1" style={{ color: "#f87171" }}>YouTube</span> + <Btn k="top" label="Top Performing" /> + <Btn k="latest" label="Latest" /> + </div> + <a href={video.url} target="_blank" rel="noreferrer" className="group block"> + {video.thumbnail && ( + // eslint-disable-next-line @next/next/no-img-element + <img src={video.thumbnail} alt={video.title} className="w-full aspect-video object-cover opacity-80 group-hover:opacity-100 transition-opacity" /> + )} + <div className="p-4"> + <p className="text-[var(--hq-text-dim)] text-[13px] font-medium line-clamp-2 leading-snug group-hover:text-[var(--hq-text)] transition-colors">{video.title}</p> + {video.publishedAt && <p className="num text-[var(--hq-text-ghost)] text-[11px] mt-2">{timeAgo(video.publishedAt)}</p>} + </div> + </a> + </div> + ); +} + +// ── Agents strip ────────────────────────────────────────── +function AgentsStrip({ processes }: { processes: Process[] }) { + if (processes.length === 0) return null; + return ( + <div className="flex items-center gap-2 flex-wrap"> + <span className="eyebrow mr-1">System</span> + {processes.map((p, i) => ( + <div key={i} className="flex items-center gap-1.5 rounded-lg border border-[var(--hq-hairline)] bg-white/[0.02] px-2.5 py-1.5"> + <span className="relative flex w-1.5 h-1.5"> + {p.status === "online" && <span className="absolute inline-flex h-full w-full rounded-full animate-ping" style={{ background: "color-mix(in srgb, var(--up) 60%, transparent)" }} />} + <span className="relative inline-flex w-1.5 h-1.5 rounded-full" style={{ background: p.status === "online" ? "var(--up)" : "var(--down)" }} /> + </span> + <span className="text-[var(--hq-text-dim)] text-[12px]">{p.name}</span> + <span className="num text-[var(--hq-text-ghost)] text-[10px]">{p.uptime}</span> + </div> + ))} + </div> + ); +} + +// ── Hermes Kanban ───────────────────────────────────────── +function HermesKanbanPanel({ kanban }: { kanban: HermesKanban }) { + const statusColor = (s: string) => { + const k = s.toLowerCase(); + if (k.includes("done") || k.includes("complete")) return "var(--hq-up)"; + if (k.includes("progress") || k.includes("doing")) return "var(--accent)"; + if (k.includes("block")) return "var(--hq-down)"; + return "var(--hq-text-faint)"; + }; + const entries = Object.entries(kanban.counts || {}); + return ( + <div className="panel flex flex-col p-6 h-full"> + <div className="flex items-center justify-between mb-4"> + <div className="min-w-0"> + <span className="eyebrow">Hermes Board</span> + <p className="text-[13px] text-[var(--hq-text-dim)] truncate mt-1">{kanban.board}</p> + </div> + <span className="num text-[22px] font-semibold text-[var(--hq-text)] shrink-0">{kanban.total}</span> + </div> + + {entries.length > 0 && ( + <div className="flex flex-wrap gap-1.5 mb-4"> + {entries.map(([status, count]) => ( + <span key={status} className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium num" + style={{ color: statusColor(status), background: `color-mix(in srgb, ${statusColor(status)} 12%, transparent)`, border: `1px solid color-mix(in srgb, ${statusColor(status)} 22%, transparent)` }}> + {status} {count} + </span> + ))} + </div> + )} + + {kanban.tasks.length === 0 ? <Empty>No active tasks.</Empty> : ( + <div className="space-y-0"> + {kanban.tasks.slice(0, 5).map((t) => ( + <div key={t.id} className="flex items-center gap-3 py-2.5 border-b border-[var(--hq-hairline)] last:border-0"> + <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: statusColor(t.status) }} /> + <p className="text-[13px] text-[var(--hq-text-dim)] leading-snug line-clamp-1 flex-1">{t.title}</p> + {t.assignee && <span className="num text-[10.5px] text-[var(--hq-text-ghost)] shrink-0">{t.assignee}</span>} + </div> + ))} + </div> + )} + </div> + ); +} + +// ── Momentum Score gauge (dashboard hero) ───────────────── +function ScoreGauge({ score }: { score: ScoreData }) { + const tier = score.score >= 80 ? "var(--hq-up)" : score.score >= 60 ? "var(--hq-warn)" : "var(--hq-down)"; + const counted = useCountUp(score.score, 1400, true); + const R = 50, C = 2 * Math.PI * R; + const pct = Math.min(100, Math.max(0, counted)); + const comps = Object.entries(score.components || {}).slice(0, 4); + return ( + <div className="flex items-center gap-5"> + <div className="relative w-[112px] h-[112px] shrink-0"> + <svg viewBox="0 0 120 120" className="w-full h-full -rotate-90"> + <circle cx="60" cy="60" r="50" fill="none" stroke="var(--hq-hairline)" strokeWidth="6" /> + <circle cx="60" cy="60" r="50" fill="none" stroke={tier} strokeWidth="6" strokeLinecap="round" + strokeDasharray={`${(C * pct) / 100} ${C}`} style={{ transition: "stroke-dasharray 0.5s var(--ease)" }} /> + </svg> + <div className="absolute inset-0 flex flex-col items-center justify-center"> + <span className="num font-semibold text-[32px] leading-none tracking-[-0.02em]" style={{ color: tier }}>{Math.round(counted)}</span> + <span className="num text-[10.5px] text-[var(--hq-text-ghost)] mt-1">{score.grade}</span> + </div> + </div> + <div className="hidden sm:block w-[176px]"> + <div className="eyebrow !text-[9.5px]">Momentum</div> + <div className="text-[14px] font-semibold mt-0.5 mb-2.5" style={{ color: tier }}>{score.label}</div> + <div className="space-y-[7px]"> + {comps.map(([k, c]) => { + const cc = c.score >= 80 ? "var(--hq-up)" : c.score >= 40 ? "var(--hq-warn)" : "var(--hq-down)"; + return ( + <div key={k} className="flex items-center gap-2"> + <span className="num text-[9.5px] text-[var(--hq-text-ghost)] w-14 truncate">{c.label.split(" ")[0]}</span> + <div className="h-[3px] flex-1 rounded-full bg-white/[0.06] overflow-hidden"> + <div className="h-full rounded-full" style={{ width: `${c.score}%`, background: cc, opacity: 0.85, transition: "width 1s var(--ease)" }} /> + </div> + </div> + ); + })} + </div> + </div> + </div> + ); +} + +// ── Main ────────────────────────────────────────────────── +export default function Dashboard() { + const [data, setData] = useState<HomeData>(EMPTY); + const [time, setTime] = useState(new Date()); + const [loaded, setLoaded] = useState(false); + const [mounted, setMounted] = useState(false); + const [score, setScore] = useState<ScoreData | null>(null); + + useEffect(() => { setMounted(true); }, []); + useEffect(() => { + fetch("/api/score").then(r => r.ok ? r.json() : null).then(d => { if (d) setScore(d); }).catch(() => {}); + }, []); + useEffect(() => { + const t = setInterval(() => setTime(new Date()), 1000); + return () => clearInterval(t); + }, []); + useEffect(() => { + fetch("/api/home") + .then(r => r.ok ? r.json() : null) + .then(d => { if (d) { setData(d); setTimeout(() => setLoaded(true), 100); } }) + .catch(() => {}); + const iv = setInterval(() => { + fetch("/api/home").then(r => r.ok ? r.json() : null).then(d => { if (d) setData(d); }).catch(() => {}); + }, 60_000); + return () => clearInterval(iv); + }, []); + + if (!mounted) return null; + + const xd = withDevPreview(snapDelta(data.snapshots, "xf"), data.xFollowers); + const ytd = withDevPreview(snapDelta(data.snapshots, "yt"), data.ytSubscribers); + const xViewsSeries = DEV_PREVIEW && !data.xViewsTrend.some(v => v > 0) + ? sampleSeries(data.xViewsThisWeek || 42000, 14) + : data.xViewsTrend; + + const stale = data.daysSincePost > 3 && data.daysSincePost < 999; + const rise = (i: number) => ({ animationDelay: `${i * 60}ms` }); + + return ( + <> + <div className="relative z-10 w-full mx-auto pb-16"> + + {/* ── Header ─────────────────────────────────────── */} + <div className="hq-rise pt-4 pb-10 flex flex-wrap items-end justify-between gap-6" style={rise(0)}> + <div> + <div className="eyebrow mb-2.5">{greeting()}</div> + <h1 className="text-[40px] font-semibold tracking-[-0.025em] leading-none text-[var(--hq-text)]">{process.env.NEXT_PUBLIC_OWNER_NAME || "Founder"}</h1> + <p className="num text-[var(--hq-text-ghost)] text-[12.5px] mt-3"> + {time.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })} + {" · "} + {time.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })} + </p> + </div> + <div className="flex flex-col items-end gap-4"> + <div className="flex items-center gap-2.5"> + {data.daysSincePost < 999 && ( + <div className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium" + style={stale + ? { color: "var(--hq-warn)", borderColor: "rgba(251,191,36,0.22)", background: "rgba(251,191,36,0.07)" } + : { color: "var(--hq-up)", borderColor: "rgba(52,211,153,0.22)", background: "rgba(52,211,153,0.07)" }}> + <span className="num">{data.daysSincePost === 0 ? "Posted today" : `${data.daysSincePost}d since post`}</span> + </div> + )} + <div className="flex items-center gap-1.5 rounded-full border border-[var(--hq-hairline)] bg-white/[0.02] px-2.5 py-1"> + <span className="relative flex w-1.5 h-1.5"> + <span className="absolute inline-flex h-full w-full rounded-full animate-ping" style={{ background: "color-mix(in srgb, var(--up) 60%, transparent)" }} /> + <span className="relative inline-flex w-1.5 h-1.5 rounded-full" style={{ background: "var(--up)" }} /> + </span> + <span className="eyebrow !text-[9.5px] !text-[var(--hq-text-faint)]">Live</span> + </div> + </div> + {score && <ScoreGauge score={score} />} + </div> + </div> + + {/* ── Platform stacks: X (with analytics) · YouTube (with video) ─ */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-start"> + {/* X */} + <div className="flex flex-col gap-5 hq-rise" style={rise(1)}> + <MetricCard + label="X Followers" value={data.xFollowers} format={fmtExact} + delta={xd.delta} deltaPct={xd.deltaPct} deltaLabel={xd.label} trend={xd.series} + goal={data.xGoal} goalFormat={fmt} + icon={<Twitter className="w-4 h-4" />} accent="#38bdf8" href="/x" loaded={loaded} + /> + <XAnalyticsPanel views={data.xViewsThisWeek} trend={xViewsSeries} totalTweets={data.totalTweets} bestDay={data.bestPostingDay} bestHour={data.bestPostingHourStr} /> + </div> + {/* YouTube */} + <div className="flex flex-col gap-5 hq-rise" style={rise(2)}> + <MetricCard + label="YouTube Subscribers" value={data.ytSubscribers} format={fmtExact} + delta={ytd.delta} deltaPct={ytd.deltaPct} deltaLabel={ytd.label} trend={ytd.series} + goal={data.ytGoal} goalFormat={fmt} + icon={<Youtube className="w-4 h-4" />} accent="#f87171" href="/youtube" loaded={loaded} + /> + {(data.topVideo || data.latestVideo) && ( + <div className="grid grid-cols-1 sm:grid-cols-2 gap-5"> + {data.topVideo && <YouTubeCard video={data.topVideo} label="Top Performing" />} + {data.latestVideo && <YouTubeCard video={data.latestVideo} label="Latest" />} + </div> + )} + </div> + </div> + + {/* ── Brief + Approval inbox (side-by-side on wide) ─ */} + <div className="mt-5 grid grid-cols-1 xl:grid-cols-3 gap-5 items-start"> + <div className="xl:col-span-2 hq-rise" style={rise(5)}> + <HermesBriefing /> + </div> + <div className="xl:col-span-1 hq-rise" style={rise(6)}> + <ApprovalInbox compact /> + </div> + </div> + + {/* ── Signal ──────────────────────────────────────── */} + <div className="mt-14"> + <SectionLabel>Signal</SectionLabel> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> + <div className="hq-rise" style={rise(4)}><TopTweetsPanel tweets={data.topTweets} /></div> + <div className="hq-rise" style={rise(5)}><IdeasPanel sageDrafts={data.topSageDrafts} ytIdeas={data.topYoutubeIdeas} buildIdeas={data.topBuildIdeas} /></div> + </div> + </div> + + {/* ── Agents strip ────────────────────────────────── */} + <div className="mt-14"> + <AgentsStrip processes={data.processes} /> + </div> + </div> + </> + ); +} diff --git a/src/app/tasks/page.tsx b/src/app/tasks/page.tsx new file mode 100644 index 0000000..f265c4d --- /dev/null +++ b/src/app/tasks/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Pill, rise } from "@/components/ui/kit"; + +interface Task { + id: string; + name: string; + status: string; + priority: string; + category: string; + dueDate?: string; +} + +const columns = [ + { id: "Not started", label: "To Do" }, + { id: "Approved", label: "Approved" }, + { id: "In progress", label: "In Progress" }, + { id: "Done", label: "Done" }, +]; + +export default function TasksPage() { + const [tasks, setTasks] = useState<Task[]>([]); + const [loading, setLoading] = useState(true); + const [newTask, setNewTask] = useState(""); + const [showAddTask, setShowAddTask] = useState(false); + + useEffect(() => { + fetchTasks(); + }, []); + + async function fetchTasks() { + try { + const res = await fetch("/api/tasks"); + const data = await res.json(); + setTasks(data.tasks || []); + } catch (e) { + console.error("Failed to fetch tasks", e); + } finally { + setLoading(false); + } + } + + async function addTask() { + if (!newTask.trim()) return; + try { + await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newTask, status: "Not started" }), + }); + setNewTask(""); + setShowAddTask(false); + fetchTasks(); + } catch (e) { + console.error("Failed to add task", e); + } + } + + async function updateTaskStatus(taskId: string, newStatus: string) { + try { + await fetch("/api/tasks", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: taskId, status: newStatus }), + }); + fetchTasks(); + } catch (e) { + console.error("Failed to update task", e); + } + } + + if (loading) { + return ( + <> + <div className="relative z-10 w-full mx-auto pt-4"> + <div className="flex justify-between items-center mb-10"> + <div> + <div className="sk h-3 w-20 mb-3" /> + <div className="sk h-7 w-28" /> + </div> + <div className="sk h-9 w-28 rounded-full" /> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5"> + {[...Array(4)].map((_, i) => ( + <div key={i} className="panel p-4"> + <div className="sk h-4 w-16 mb-4" /> + <div className="space-y-2"> + {[...Array(i + 1)].map((_, j) => <div key={j} className="sk h-16 rounded-[var(--r-md)]" />)} + </div> + </div> + ))} + </div> + </div> + </> + ); + } + + return ( + <> + <div className="relative z-10 h-full flex flex-col w-full mx-auto pt-4 pb-16"> + <div className="hq-rise flex justify-between items-end gap-4 mb-10" style={rise(0)}> + <div> + <div className="eyebrow mb-2">Synced with Notion</div> + <h1 className="text-[32px] font-semibold tracking-[-0.025em] leading-none text-[var(--text)]">Tasks</h1> + </div> + <Button variant="primary" onClick={() => setShowAddTask(true)}>+ Add Task</Button> + </div> + + {showAddTask && ( + <div className="hq-rise elevated mb-8 p-5"> + <input + type="text" + value={newTask} + onChange={(e) => setNewTask(e.target.value)} + placeholder="What needs to be done?" + className="w-full bg-[var(--surface-1)] border border-[var(--line)] text-[var(--text)] placeholder-[var(--text-3)] rounded-[var(--r-md)] px-4 py-3 mb-3 text-[14px] focus:outline-none focus:border-[var(--line-strong)]" + onKeyDown={(e) => e.key === "Enter" && addTask()} + autoFocus + /> + <div className="flex gap-2"> + <Button variant="primary" onClick={addTask}>Add Task</Button> + <Button variant="ghost" onClick={() => setShowAddTask(false)}>Cancel</Button> + </div> + </div> + )} + + <div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 overflow-hidden"> + {columns.map((column, idx) => { + const count = tasks.filter((t) => t.status === column.id).length; + return ( + <div key={column.id} className="hq-rise panel flex flex-col overflow-hidden" style={rise(idx + 1)}> + <div className="px-4 py-3.5 flex items-center justify-between"> + <span className="eyebrow">{column.label}</span> + <span className="num text-[11px] text-[var(--text-3)]">{count}</span> + </div> + <div className="rule" /> + <div className="flex-1 p-2.5 space-y-2 overflow-y-auto"> + {tasks + .filter((t) => t.status === column.id) + .map((task) => ( + <TaskCard + key={task.id} + task={task} + done={column.id === "Done"} + onStatusChange={(status) => updateTaskStatus(task.id, status)} + /> + ))} + {count === 0 && ( + <p className="text-[var(--text-4)] text-[12.5px] text-center py-8">No tasks</p> + )} + </div> + </div> + ); + })} + </div> + </div> + </> + ); +} + +function TaskCard({ + task, + done, + onStatusChange, +}: { + task: Task; + done?: boolean; + onStatusChange: (status: string) => void; +}) { + const priorityTone: Record<string, "warn" | "neutral"> = { + High: "warn", + Medium: "neutral", + Low: "neutral", + }; + + return ( + <div className="rounded-[var(--r-md)] border border-[var(--line)] bg-[var(--surface-1)] p-3.5 transition-colors hover:border-[var(--line-strong)] hover:bg-[var(--surface-2)] cursor-pointer group"> + <p className={`font-medium text-[13px] mb-3 leading-relaxed ${done ? "text-[var(--text-3)] line-through" : "text-[var(--text)]"}`}> + {task.name} + </p> + <div className="flex items-center gap-2 flex-wrap"> + {task.priority && ( + <Pill tone={priorityTone[task.priority] || "neutral"}>{task.priority}</Pill> + )} + {task.category && ( + <span className="text-[11px] text-[var(--text-3)]">{task.category}</span> + )} + </div> + <div className="mt-3 pt-3 border-t border-[var(--line)] opacity-0 group-hover:opacity-100 transition-opacity"> + <select + className="text-[12px] bg-[var(--surface-1)] text-[var(--text-2)] rounded-[var(--r-sm)] px-3 py-2 w-full border border-[var(--line)] focus:outline-none focus:border-[var(--line-strong)]" + value={task.status} + onChange={(e) => onStatusChange(e.target.value)} + > + {columns.map((col) => ( + <option key={col.id} value={col.id}> + Move to {col.label} + </option> + ))} + </select> + </div> + </div> + ); +} diff --git a/src/app/watchlist-radar/page.tsx b/src/app/watchlist-radar/page.tsx new file mode 100644 index 0000000..bd67618 --- /dev/null +++ b/src/app/watchlist-radar/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { EmptyState } from "@/components/ui/kit"; + +interface Signal { + id: string; + handle: string; + likes: number; + ageMinutes: number; + summary: string; + url: string; + draft: string; + detectedAt: string; +} + +interface WatchlistData { + signals: Signal[]; + lastUpdated: string | null; +} + +function timeAgo(minutes: number): string { + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + return `${Math.floor(minutes / 60)}h ago`; +} + +function SignalCard({ signal }: { signal: Signal }) { + const [copied, setCopied] = useState(false); + const [sniped, setSniped] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(signal.draft); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const handleSnipe = async () => { + setSniped(true); + try { + await fetch("/api/x-content", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-internal-secret": "296cfa48ad6963eb21ac9deb4981ad0c982cff51631bda9cf43fd0fa7217088b", + }, + body: JSON.stringify({ + text: signal.draft, + model: "radar", + type: "tweet", + status: "pending", + quoteUrl: signal.url, + }), + }); + } catch { /* silent */ } + setTimeout(() => setSniped(false), 2000); + }; + + return ( + <div className="panel panel-interactive overflow-hidden flex flex-col"> + {/* Header */} + <div className="px-5 pt-4 pb-3 border-b border-[var(--line)] flex items-start justify-between gap-3"> + <div className="flex items-baseline gap-2"> + <span className="text-[14px] font-semibold text-[var(--text)]">@{signal.handle}</span> + <span className="num text-[12px] text-[var(--text-3)]">{timeAgo(signal.ageMinutes)}</span> + </div> + <div className="flex items-center gap-1.5 shrink-0"> + <span className="num text-[13px] font-semibold text-[var(--text-2)]"> + {signal.likes >= 1000 ? `${(signal.likes / 1000).toFixed(1)}k` : signal.likes} + </span> + <span className="text-[11px] text-[var(--text-3)]">likes</span> + </div> + </div> + + {/* Tweet summary */} + <div className="px-5 py-4"> + <p className="text-[13px] text-[var(--text-2)] leading-relaxed">{signal.summary}</p> + </div> + + {/* Draft tweet box */} + {signal.draft && ( + <div className="mx-5 mb-4 rounded-[var(--r-md)] border border-[var(--line)] bg-[var(--surface-2)] p-3 space-y-2"> + <div className="flex items-center justify-between"> + <span className="eyebrow">Draft tweet</span> + <button onClick={handleCopy} + className="text-[11px] font-medium transition-colors" + style={{ color: copied ? "var(--up)" : "var(--text-3)" }}> + {copied ? "Copied" : "Copy"} + </button> + </div> + <p className="text-[12px] text-[var(--text)] leading-relaxed whitespace-pre-line">{signal.draft}</p> + </div> + )} + + {/* Actions */} + <div className="mt-auto px-5 pb-4 flex gap-2"> + <a href={signal.url} target="_blank" rel="noopener noreferrer" + className="btn-ghost flex-1 justify-center py-2 text-[12px] font-medium text-center"> + View on X + </a> + <button onClick={handleSnipe} + className="flex-1 py-2 rounded-[var(--r-sm)] text-[12px] font-semibold transition-colors text-center inline-flex items-center justify-center gap-1" + style={sniped + ? { color: "var(--up)", background: "color-mix(in srgb, var(--up) 14%, transparent)", border: "1px solid color-mix(in srgb, var(--up) 24%, transparent)" } + : { color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 24%, transparent)" }}> + {sniped ? "Saved to drafts" : "Snipe"} + </button> + </div> + </div> + ); +} + +export default function WatchlistRadarPage() { + const [data, setData] = useState<WatchlistData>({ signals: [], lastUpdated: null }); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + try { + const res = await fetch("/api/watchlist-radar", { cache: "no-store" }); + if (res.ok) setData(await res.json()); + } finally { setLoading(false); } + }, []); + + useEffect(() => { load(); const t = setInterval(load, 60000); return () => clearInterval(t); }, [load]); + + const lastUpdatedMins = data.lastUpdated + ? Math.floor((Date.now() - new Date(data.lastUpdated).getTime()) / 60000) + : null; + + return ( + <div className="space-y-5 pt-6"> + {/* Header */} + <div className="flex items-center justify-between flex-wrap gap-2"> + <div className="space-y-1"> + <div className="text-[12px] text-[var(--text-2)]"> + Monitoring 14 accounts <span className="text-[var(--text-4)]">·</span> updates every 15 min <span className="text-[var(--text-4)]">·</span> threshold 80 likes + </div> + {lastUpdatedMins !== null && ( + <div className="num text-[12px] text-[var(--text-3)]">Last scan {timeAgo(lastUpdatedMins)}</div> + )} + </div> + <button onClick={load} className="btn-ghost px-3 py-1.5 text-[12px] font-medium"> + Refresh + </button> + </div> + + {loading ? ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {[...Array(6)].map((_, i) => <div key={i} className="sk h-56 rounded-[var(--r-lg)]" />)} + </div> + ) : data.signals.length === 0 ? ( + <EmptyState + title="No signals in the last 3 hours" + hint="Radar checks every 15 min across your watchlist." + /> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {data.signals.map((s, i) => ( + <div key={s.id} className="hq-rise" style={{ animationDelay: `${Math.min(i, 12) * 45}ms` }}> + <SignalCard signal={s} /> + </div> + ))} + </div> + )} + </div> + ); +} diff --git a/src/app/x-analytics/page.tsx b/src/app/x-analytics/page.tsx new file mode 100644 index 0000000..fa48f55 --- /dev/null +++ b/src/app/x-analytics/page.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SectionHeader, EmptyState } from "@/components/ui/kit"; + +interface Tweet { + id: string; + text: string; + views: number; + likes: number; + bookmarks: number; + replies: number; + retweets: number; + engRate: number; + postedAt: string; + tweetUrl: string | null; +} + +interface HeatCell { + day: number; + hour: number; + avgViews: number; + count: number; +} + +interface AnalyticsData { + tweets: Tweet[]; + heatmap: HeatCell[]; +} + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function fmt(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toString(); +} + +function fmtDate(s: string): string { + return new Date(s).toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +export default function XAnalyticsPage() { + const [data, setData] = useState<AnalyticsData>({ tweets: [], heatmap: [] }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/x-analytics") + .then((r) => r.json()) + .then((d) => { setData(d); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + const { tweets, heatmap } = data; + + const totalViews = tweets.reduce((s, t) => s + t.views, 0); + const avgEng = tweets.length + ? Math.round((tweets.reduce((s, t) => s + t.engRate, 0) / tweets.length) * 100) / 100 + : 0; + const topViews = tweets[0]?.views || 0; + + // Heatmap cell color + const allAvgViews = heatmap.map((h) => h.avgViews).filter((v) => v > 0).sort((a, b) => a - b); + const q1 = allAvgViews[Math.floor(allAvgViews.length * 0.25)] || 0; + const q2 = allAvgViews[Math.floor(allAvgViews.length * 0.5)] || 0; + const q3 = allAvgViews[Math.floor(allAvgViews.length * 0.75)] || 0; + + // Sequential intensity scale built on the single semantic "up" token. + const HEAT_EMPTY = "var(--surface-2)"; + function cellColor(avg: number): string { + if (!avg) return HEAT_EMPTY; + if (avg >= q3) return "color-mix(in srgb, var(--up) 100%, transparent)"; + if (avg >= q2) return "color-mix(in srgb, var(--up) 66%, var(--surface-1))"; + if (avg >= q1) return "color-mix(in srgb, var(--up) 40%, var(--surface-1))"; + return "color-mix(in srgb, var(--up) 20%, var(--surface-1))"; + } + const HEAT_LEGEND = [ + HEAT_EMPTY, + "color-mix(in srgb, var(--up) 20%, var(--surface-1))", + "color-mix(in srgb, var(--up) 40%, var(--surface-1))", + "color-mix(in srgb, var(--up) 66%, var(--surface-1))", + "color-mix(in srgb, var(--up) 100%, transparent)", + ]; + + const heatmapLookup: Record<string, HeatCell> = {}; + for (const h of heatmap) heatmapLookup[`${h.day}-${h.hour}`] = h; + + const engColor = (r: number) => + r >= 3 ? "var(--up)" : r >= 1 ? "var(--warn)" : "var(--text-3)"; + + const stats = [ + { label: "Tweets Tracked", value: tweets.length.toString() }, + { label: "Total Views", value: fmt(totalViews) }, + { label: "Avg Eng Rate", value: `${avgEng}%` }, + { label: "Top Tweet", value: fmt(topViews) + " views" }, + ]; + + return ( + <div className="space-y-10 pb-8"> + {/* Header */} + <SectionHeader label="X Analytics" title="Tweet performance & posting insights" /> + + {loading ? ( + <div className="space-y-10"> + <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> + {[...Array(4)].map((_, i) => <div key={i} className="sk h-24 rounded-[var(--r-lg)]" />)} + </div> + <div className="sk h-72 rounded-[var(--r-lg)]" /> + <div className="sk h-56 rounded-[var(--r-lg)]" /> + </div> + ) : ( + <> + {/* Stats row */} + <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> + {stats.map((s, i) => ( + <div key={s.label} className="panel p-5 hq-rise" style={{ animationDelay: `${i * 45}ms` }}> + <p className="eyebrow">{s.label}</p> + <p className="num text-[32px] font-semibold tracking-[-0.02em] leading-none text-[var(--text)] mt-3">{s.value}</p> + </div> + ))} + </div> + + {/* Main content */} + <div className="flex flex-col lg:flex-row gap-5"> + {/* Tweet performance table */} + <div className="flex-[3] panel overflow-hidden"> + <div className="p-5 border-b border-[var(--line)]"> + <span className="eyebrow">Tweet Performance</span> + </div> + <div className="overflow-x-auto"> + <table className="w-full text-[13px]"> + <thead> + <tr className="text-[10px] text-[var(--text-3)] uppercase tracking-[0.12em] border-b border-[var(--line)]"> + <th className="text-left px-5 py-3 font-medium">Tweet</th> + <th className="text-right px-5 py-3 font-medium">Views</th> + <th className="text-right px-5 py-3 font-medium">Likes</th> + <th className="text-right px-5 py-3 font-medium">Bookmarks</th> + <th className="text-right px-5 py-3 font-medium">Eng Rate</th> + <th className="text-right px-5 py-3 font-medium">Date</th> + </tr> + </thead> + <tbody> + {tweets.map((t) => ( + <tr key={t.id} className="border-b border-[var(--line)] last:border-0 hover:bg-[var(--surface-2)] transition-colors"> + <td className="px-5 py-3 max-w-xs"> + {t.tweetUrl ? ( + <a + href={t.tweetUrl} + target="_blank" + rel="noopener noreferrer" + title={t.text} + className="text-[var(--text-2)] hover:text-[var(--text)] truncate block transition-colors" + > + {t.text.slice(0, 80)}{t.text.length > 80 ? "…" : ""} + </a> + ) : ( + <span title={t.text} className="text-[var(--text-2)] truncate block"> + {t.text.slice(0, 80)}{t.text.length > 80 ? "…" : ""} + </span> + )} + </td> + <td className="px-5 py-3 text-right num font-semibold text-[var(--text)]">{fmt(t.views)}</td> + <td className="px-5 py-3 text-right num text-[var(--text-2)]">{t.likes.toLocaleString()}</td> + <td className="px-5 py-3 text-right num text-[var(--text-2)]">{t.bookmarks.toLocaleString()}</td> + <td className="px-5 py-3 text-right num font-semibold" style={{ color: engColor(t.engRate) }}>{t.engRate}%</td> + <td className="px-5 py-3 text-right num text-[var(--text-3)] text-[12px]">{t.postedAt ? fmtDate(t.postedAt) : "—"}</td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + + {/* Top 10 */} + <div className="flex-[2] panel overflow-hidden"> + <div className="p-5 border-b border-[var(--line)]"> + <span className="eyebrow">Top 10 Tweets</span> + </div> + <div> + {tweets.slice(0, 10).map((t, i) => ( + <div key={t.id} className="flex items-start gap-3 px-5 py-3 border-b border-[var(--line)] last:border-0 hover:bg-[var(--surface-2)] transition-colors"> + <span className="num text-[13px] font-semibold w-6 shrink-0" style={{ color: i < 3 ? "var(--up)" : "var(--text-3)" }}> + {String(i + 1).padStart(2, "0")} + </span> + <div className="flex-1 min-w-0"> + <p className="text-[12px] text-[var(--text-2)] truncate">{t.text.slice(0, 60)}{t.text.length > 60 ? "…" : ""}</p> + </div> + <span className="num text-[13px] font-semibold shrink-0" style={{ color: i < 3 ? "var(--up)" : "var(--text)" }}> + {fmt(t.views)} + </span> + </div> + ))} + {tweets.length === 0 && ( + <EmptyState title="No tweet data yet" hint="Performance shows up here once tweets are tracked." /> + )} + </div> + </div> + </div> + + {/* Heatmap */} + <div className="panel overflow-hidden"> + <div className="p-5 border-b border-[var(--line)]"> + <span className="eyebrow">Best Posting Times</span> + <p className="text-[12px] text-[var(--text-3)] mt-1.5">Average views by day and hour posted (UTC)</p> + </div> + <div className="p-5 overflow-x-auto"> + <div className="inline-block min-w-full"> + {/* Hour labels */} + <div className="flex items-center mb-1 ml-10"> + {Array.from({ length: 24 }).map((_, h) => ( + <div key={h} className="w-5 shrink-0 text-center"> + {[0, 6, 12, 18, 23].includes(h) ? ( + <span className="num text-[9px] text-[var(--text-3)]">{h}</span> + ) : null} + </div> + ))} + </div> + {/* Grid */} + {DAYS.map((day, d) => ( + <div key={d} className="flex items-center gap-1 mb-1"> + <span className="text-[10px] text-[var(--text-3)] w-8 shrink-0 text-right pr-2">{day}</span> + {Array.from({ length: 24 }).map((_, h) => { + const cell = heatmapLookup[`${d}-${h}`]; + return ( + <div + key={h} + className="w-5 h-5 rounded-sm shrink-0" + style={{ background: cellColor(cell?.avgViews || 0) }} + title={cell ? `${cell.count} tweet${cell.count > 1 ? "s" : ""}, avg ${fmt(cell.avgViews)} views` : "No data"} + /> + ); + })} + </div> + ))} + {/* Legend */} + <div className="flex items-center gap-2 mt-3 ml-10"> + <span className="text-[10px] text-[var(--text-3)]">Less</span> + {HEAT_LEGEND.map((c) => ( + <div key={c} className="w-4 h-4 rounded-sm" style={{ background: c }} /> + ))} + <span className="text-[10px] text-[var(--text-3)]">More</span> + </div> + </div> + </div> + </div> + </> + )} + </div> + ); +} diff --git a/src/app/x-content/page.tsx b/src/app/x-content/page.tsx new file mode 100644 index 0000000..cb1ade8 --- /dev/null +++ b/src/app/x-content/page.tsx @@ -0,0 +1,981 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef, memo } from "react"; +import { EmptyState } from "@/components/ui/kit"; + +// ── shared calm-luxury chip styling ───────────────────────────────────────── +const CHIP = "text-[11px] px-2.5 py-1 rounded-[var(--r-sm)] font-medium transition-colors inline-flex items-center gap-1"; +const GHOST_CHIP = `${CHIP} text-[var(--text-2)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] border border-[var(--line)]`; +function toneStyle(c: string): React.CSSProperties { + return { color: c, background: `color-mix(in srgb, ${c} 12%, transparent)`, border: `1px solid color-mix(in srgb, ${c} 22%, transparent)` }; +} + +// ── Trend Radar types ────────────────────────────────────────────────────── +interface Trend { + id: string; topic: string; summary: string; + source: "x_search" | "rss" | "breaking"; + velocity: "forming" | "rising" | "peaking" | "fading"; + velocityScore: number; ageMinutes: number; + relevance: "crypto" | "ai" | "creator" | "other"; + url: string | null; sageAngle: string | null; detectedAt: string; +} +interface TrendsData { trends: Trend[]; lastUpdated: string; nextUpdate: string; } + +const VELOCITY_META: Record<string, { color: string; badge: string }> = { + forming: { color: "var(--up)", badge: "Forming" }, + rising: { color: "var(--warn)", badge: "Rising" }, + peaking: { color: "var(--down)", badge: "Peaking" }, + fading: { color: "var(--text-3)", badge: "Fading" }, +}; +const RELEVANCE_LABELS: Record<string, string> = { crypto: "Crypto", ai: "AI", creator: "Creator", other: "Other" }; +const SOURCE_LABELS: Record<string, string> = { x_search: "X", rss: "RSS", breaking: "Breaking" }; + +function formatTimeAgo(minutes: number): string { + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + return `${Math.floor(minutes / 60)}h ago`; +} + +function TrendRadarSection() { + const [trendsData, setTrendsData] = useState<TrendsData | null>(null); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<"all" | "crypto" | "ai" | "creator">("all"); + const [snipedId, setSnipedId] = useState<string | null>(null); + + const load = useCallback(async () => { + try { + const res = await fetch("/api/trends", { cache: "no-store" }); + if (res.ok) setTrendsData(await res.json()); + } finally { setLoading(false); } + }, []); + + useEffect(() => { load(); const t = setInterval(load, 60000); return () => clearInterval(t); }, [load]); + + const handleSnipe = useCallback(async (trend: Trend) => { + setSnipedId(trend.id); + try { + await fetch("/api/trends/snipe", { + method: "POST", + headers: { "Content-Type": "application/json", "x-internal-secret": "296cfa48ad6963eb21ac9deb4981ad0c982cff51631bda9cf43fd0fa7217088b" }, + body: JSON.stringify({ trendId: trend.id, topic: trend.topic, sageAngle: trend.sageAngle, url: trend.url }), + }); + } finally { setTimeout(() => setSnipedId(null), 2000); } + }, []); + + const filtered = (trendsData?.trends ?? []).filter(t => filter === "all" || t.relevance === filter); + + if (loading) return ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {[...Array(6)].map((_, i) => <div key={i} className="sk h-52 rounded-[var(--r-lg)]" />)} + </div> + ); + + return ( + <div className="space-y-5"> + {/* Header row */} + <div className="flex items-center justify-between flex-wrap gap-2"> + <div className="text-[12px] text-[var(--text-3)]"> + {trendsData?.lastUpdated ? `Updated ${formatTimeAgo(Math.floor((Date.now() - new Date(trendsData.lastUpdated).getTime()) / 60000))}` : "No data yet"} + {" · "} + <span className="num text-[var(--text-2)]">{filtered.length} signal{filtered.length !== 1 ? "s" : ""}</span> + </div> + <button onClick={load} className="btn-ghost px-3 py-1.5 text-[12px] font-medium">Refresh</button> + </div> + + {/* Filter pills */} + <div className="flex gap-2 flex-wrap"> + {(["all", "ai", "crypto", "creator"] as const).map(f => ( + <button key={f} onClick={() => setFilter(f)} + className={`${CHIP} ${filter === f + ? "text-[var(--text)] bg-[var(--surface-3)] border border-[var(--line-strong)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)] hover:bg-[var(--surface-2)] border border-transparent"}`}> + {f === "all" ? "All" : f === "ai" ? "AI" : f === "crypto" ? "Crypto" : "Creator"} + </button> + ))} + </div> + + {/* Trend cards */} + {filtered.length === 0 ? ( + <EmptyState title="No fresh signals right now" hint="Radar updates every 15 min." /> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {filtered.map(trend => { + const meta = VELOCITY_META[trend.velocity] ?? VELOCITY_META["forming"]; + return ( + <div key={trend.id} className="panel panel-interactive overflow-hidden" + style={{ borderLeft: `2px solid ${meta.color}` }}> + <div className="p-5 space-y-3"> + <div className="flex items-start justify-between gap-2"> + <h3 className="text-[14px] font-semibold text-[var(--text)] leading-snug flex-1">{trend.topic}</h3> + <span className={`${CHIP} whitespace-nowrap shrink-0`} style={toneStyle(meta.color)}>{meta.badge}</span> + </div> + <p className="text-[12px] text-[var(--text-2)] leading-relaxed">{trend.summary}</p> + <div className="flex flex-wrap items-center gap-3 text-[11px] text-[var(--text-3)]"> + <span className="num text-[var(--text-2)] font-semibold">{trend.velocityScore}<span className="text-[var(--text-3)] font-normal"> score</span></span> + <span className="num">{formatTimeAgo(trend.ageMinutes)}</span> + <span>{RELEVANCE_LABELS[trend.relevance]}</span> + <span className="ml-auto text-[var(--text-4)]">{SOURCE_LABELS[trend.source]}</span> + </div> + {trend.sageAngle && ( + <div className="rounded-[var(--r-sm)] border border-[var(--line)] bg-[var(--surface-2)] p-2.5 space-y-1"> + <div className="eyebrow" style={{ color: "var(--up)" }}>Draft angle</div> + <div className="text-[11px] text-[var(--text-2)] italic">{trend.sageAngle}</div> + </div> + )} + <div className="flex gap-2 pt-1"> + {trend.url && ( + <a href={trend.url} target="_blank" rel="noopener noreferrer" + className="btn-ghost flex-1 justify-center py-2 text-[12px] font-medium text-center"> + View Tweet + </a> + )} + <button onClick={() => handleSnipe(trend)} + className={`${CHIP} flex-1 justify-center py-2 text-[12px] font-semibold`} + style={toneStyle(snipedId === trend.id ? "var(--up)" : "var(--accent)")}> + {snipedId === trend.id ? "Sniped" : "Snipe"} + </button> + </div> + </div> + </div> + ); + })} + </div> + )} + </div> + ); +} + +export async function fetchDrafts() { + const res = await fetch("/api/x-content", { cache: "no-store" }); + if (!res.ok) throw new Error(`${res.status}`); + return await res.json(); +} + +const TIME_SLOTS = [ + { label: "Morning (9:00)", value: "09:00" }, + { label: "Midday (12:00)", value: "12:00" }, + { label: "Afternoon (15:00)", value: "15:00" }, + { label: "Evening (18:00)", value: "18:00" }, + { label: "Night (21:00)", value: "21:00" } +]; + +const REJECT_REASONS = [ + "Too AI", "Too Vague", "No Value", + "Inaccurate", "Not My Voice", "Boring" +]; + +interface WeekDay { + date: string; label: string; dayName: string; isToday: boolean; isPast: boolean; +} + +interface Draft { + id: string; + text: string; + title?: string; + model: string; + type?: "tweet" | "article" | "idea" | "content idea" | "industry idea"; + status: "pending" | "approved" | "rejected" | "posted" | "scheduled"; + feedback: { rating: string | null; reason: string }; + viralScore: number | null; + hookType?: string; + category?: string; + scheduledDate: string | null; + scheduledTime?: string | null; + createdAt: string; + postedAt?: string; + quoteUrl?: string; + quoteText?: string; + postedUrl?: string; + postedText?: string; + visualUrl?: string; + metrics?: { + views?: number; likes?: number; retweets?: number; + replies?: number; bookmarks?: number; updatedAt?: string; + }; + tweakRequested?: { comment: string; requestedAt: string; status: "pending" | "processing" | "done"; }; + editHistory?: { original: string; edited: string; editedAt: string }[]; + variantGroup?: string; + impressionScore?: number; +} + +interface DraftCardProps { + draft: Draft; + isEditing: boolean; + isTweaking: boolean; + tweakComment: string; + isShowingRejectPicker: boolean; + isShowingSchedulePicker: boolean; + isShowingPostedUrl: boolean; + postedUrlValue: string; + weekDays: WeekDay[]; + onSetEditing: (id: string | null) => void; + onSetTweaking: (id: string | null) => void; + onSetTweakComment: (v: string) => void; + onSetRejectPicker: (id: string | null) => void; + onSetSchedulePicker: (id: string | null) => void; + onSetPostedUrlDraft: (id: string | null) => void; + onSetPostedUrlValue: (v: string) => void; + onEditSubmit: (id: string, text: string) => void; + onTweak: (id: string, comment: string) => void; + onFeedback: (id: string, rating: string, reason?: string) => void; + onSchedule: (id: string, date: string | null) => void; + onMarkPosted: (id: string, url?: string) => void; + onAttachVisual: (id: string) => void; + onGenerateVisual: (id: string) => void; + onRemoveVisual: (id: string) => void; + onLightbox: (url: string | null) => void; + onLoadDrafts: () => void; +} + +function getWeekDays(): WeekDay[] { + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const days: WeekDay[] = []; + for (let i = -2; i <= 4; i++) { + const d = new Date(now); + d.setDate(now.getDate() + i); + const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + days.push({ date: dateStr, label: `${d.getDate()}/${d.getMonth() + 1}`, dayName: dayNames[d.getDay()], isToday: dateStr === todayStr, isPast: i < 0 }); + } + return days; +} + +function StatusBadge({ status }: { status: string }) { + const tones: Record<string, string> = { + pending: "var(--warn)", + approved: "var(--up)", + rejected: "var(--down)", + posted: "var(--accent)", + scheduled: "var(--accent)", + }; + return <span className="text-[10px] font-medium px-2.5 py-1 rounded-full" style={toneStyle(tones[status] || tones.pending)}>{status}</span>; +} + +// ─── DraftCard (top-level, memo'd) ─────────────────────────────────── +const DraftCard = memo(function DraftCard({ + draft, isEditing, isTweaking, tweakComment, isShowingRejectPicker, + isShowingSchedulePicker, isShowingPostedUrl, postedUrlValue, weekDays, + onSetEditing, onSetTweaking, onSetTweakComment, onSetRejectPicker, + onSetSchedulePicker, onSetPostedUrlDraft, onSetPostedUrlValue, + onEditSubmit, onTweak, onFeedback, onSchedule, onMarkPosted, + onAttachVisual, onGenerateVisual, onRemoveVisual, onLightbox, onLoadDrafts, +}: DraftCardProps) { + const editTextRef = useRef<HTMLTextAreaElement>(null); + + const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => { + e.dataTransfer.setData("text/plain", draft.id); + e.dataTransfer.effectAllowed = "move"; + }; + + const isArticle = draft.type === "article"; + + return ( + <div + id={`draft-${draft.id}`} + draggable="true" + onDragStart={handleDragStart} + className="panel overflow-hidden transition-all" + style={isArticle ? { borderColor: "color-mix(in srgb, var(--accent) 26%, transparent)" } : undefined} + > + {/* Top row: pills + status */} + <div className="flex items-center justify-between gap-2 px-5 pt-4"> + <div className="flex items-center gap-1.5"> + {draft.category && ( + <span className="text-[10px] px-2 py-0.5 rounded-full font-medium text-[var(--text-3)] border border-[var(--line)]"> + {draft.category} + </span> + )} + {isArticle && ( + <span className="text-[10px] px-2 py-0.5 rounded-full font-medium" style={toneStyle("var(--accent)")}>article</span> + )} + {draft.impressionScore != null && ( + <span className="num text-[10px] text-[var(--text-4)]">~{draft.impressionScore.toLocaleString()} imp</span> + )} + </div> + <StatusBadge status={draft.status} /> + </div> + + {draft.status === "rejected" && draft.feedback?.reason && ( + <div className="mx-5 mt-2 text-[12px] px-3 py-1.5 rounded-[var(--r-sm)]" style={toneStyle("var(--down)")}>{draft.feedback.reason}</div> + )} + + {/* Body */} + <div className="px-5 py-4 space-y-3"> + {/* Text: read-only or editable */} + {isEditing ? ( + <div className="space-y-1.5"> + <textarea + ref={editTextRef} + defaultValue={draft.text} + className={`w-full bg-[var(--surface-2)] border border-[var(--line-strong)] rounded-[var(--r-md)] p-3 text-[14px] text-[var(--text)] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--accent)] ${ + isArticle ? "min-h-[200px]" : "min-h-[100px]" + }`} + autoFocus + /> + <div className="flex gap-1.5"> + <button onClick={() => { if (editTextRef.current) onEditSubmit(draft.id, editTextRef.current.value); }} + className={CHIP} style={toneStyle("var(--up)")}>Save</button> + <button onClick={() => onSetEditing(null)} className={GHOST_CHIP}>Cancel</button> + </div> + </div> + ) : ( + <div className="text-[15px] leading-relaxed whitespace-pre-wrap text-[var(--text)]"> + {isArticle && draft.title && <div className="font-semibold text-[var(--accent)] mb-1">{draft.title}</div>} + {draft.text} + {isArticle && <div className="num text-[10px] text-[var(--text-4)] mt-1">{draft.text.split(/\s+/).length} words</div>} + </div> + )} + + {/* Quote tweet preview — Twitter-style embedded card */} + {draft.quoteUrl && (() => { + const match = draft.quoteUrl.match(/x\.com\/([^/]+)\/status\/(\d+)/); + const handle = match ? `@${match[1]}` : "quoted tweet"; + return ( + <a href={draft.quoteUrl} target="_blank" rel="noreferrer" className="block border border-[var(--line)] rounded-[var(--r-md)] p-3 hover:border-[var(--line-strong)] transition-colors bg-[var(--surface-2)]"> + <div className="flex items-center gap-1.5 mb-2"> + <div className="w-4 h-4 rounded-full bg-[var(--surface-3)] flex items-center justify-center shrink-0"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="10" height="10" fill="currentColor" className="text-[var(--text-3)]"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg> + </div> + <span className="text-[12px] font-semibold text-[var(--text-2)]">{handle}</span> + <span className="text-[10px] text-[var(--text-3)] ml-auto">Quoted tweet ↗</span> + </div> + {draft.quoteText ? ( + <p className="text-[12px] text-[var(--text-2)] leading-relaxed whitespace-pre-wrap line-clamp-5">{draft.quoteText}</p> + ) : ( + <p className="text-[11px] text-[var(--text-3)] italic">Tweet text not loaded</p> + )} + <p className="text-[10px] text-[var(--text-4)] mt-2 truncate">{draft.quoteUrl}</p> + </a> + ); + })()} + + {/* Visual */} + {draft.visualUrl ? ( + <div className="relative group"> + <img src={draft.visualUrl} alt="Visual" className="w-full rounded-[var(--r-md)] cursor-pointer" + onClick={() => onLightbox(draft.visualUrl || null)} /> + <div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> + <button className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-1)]/90 text-[var(--text-2)] hover:text-[var(--text)]" + onClick={() => { navigator.clipboard.writeText(draft.visualUrl || ""); }}>Copy HD</button> + <button className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-1)]/90 text-[var(--text-2)] hover:text-[var(--text)]" + onClick={async () => { await onRemoveVisual(draft.id); await onGenerateVisual(draft.id); }}>Regenerate</button> + <button className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-1)]/90 text-[var(--down)] hover:brightness-110" + onClick={() => onRemoveVisual(draft.id)}>Remove</button> + </div> + </div> + ) : null} + + {/* Metrics (posted) */} + {draft.status === "posted" && draft.metrics && ( + <div className="flex gap-4 num text-[12px] text-[var(--text-3)] border-t border-[var(--line)] pt-3"> + <span>{(draft.metrics.views || 0).toLocaleString()} views</span> + <span>{(draft.metrics.likes || 0).toLocaleString()} likes</span> + <span>{(draft.metrics.retweets || 0).toLocaleString()} RT</span> + <span>{(draft.metrics.replies || 0).toLocaleString()} rep</span> + <span>{(draft.metrics.bookmarks || 0).toLocaleString()} bk</span> + </div> + )} + + {/* Schedule info */} + {draft.scheduledDate && ( + <div className="num text-[12px] flex justify-between items-center" style={{ color: "var(--accent)" }}> + <span>{draft.scheduledDate} {draft.scheduledTime || ""}</span> + <button className="text-[var(--text-3)] hover:text-[var(--down)] text-[10px]" onClick={() => onSchedule(draft.id, null)}>Unschedule</button> + </div> + )} + + {draft.variantGroup && <div className="num text-[10px] text-[var(--text-4)]">Variant: {draft.variantGroup}</div>} + + {/* Meta */} + <div className="flex items-center justify-between num text-[11px] text-[var(--text-4)]"> + <span>{draft.model} · {new Date(draft.createdAt).toLocaleDateString()} {new Date(draft.createdAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}</span> + {draft.postedUrl && <a href={draft.postedUrl} target="_blank" rel="noreferrer" className="text-[var(--accent)] hover:brightness-110">View on X ↗</a>} + </div> + </div> + + {/* Actions */} + <div className="flex flex-wrap items-center gap-1.5 px-5 py-3 border-t border-[var(--line)]"> + <button onClick={() => { const copyText = draft.quoteUrl ? `${draft.text}\n\n${draft.quoteUrl}` : (draft.text || ""); navigator.clipboard.writeText(copyText); const btn = document.activeElement as HTMLButtonElement; if (btn) { btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); } }} className={GHOST_CHIP}>Copy</button> + {!isEditing && draft.status !== "posted" && ( + <button onClick={() => onSetEditing(draft.id)} className={GHOST_CHIP}>Edit</button> + )} + + {/* Tweak */} + {draft.status !== "posted" && draft.status !== "rejected" && ( + isTweaking ? ( + <div className="flex gap-1 w-full"> + <input value={tweakComment} onChange={(e) => onSetTweakComment(e.target.value)} + placeholder="What should I change?" autoFocus + className="flex-1 bg-[var(--surface-2)] border border-[var(--line-strong)] rounded-[var(--r-sm)] px-2.5 py-1.5 text-[12px] text-[var(--text)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)]" + onKeyDown={(e) => { if (e.key === "Enter" && tweakComment.trim()) onTweak(draft.id, tweakComment); }} + /> + <button onClick={() => { if (tweakComment.trim()) onTweak(draft.id, tweakComment); }} + className={CHIP} style={toneStyle("var(--accent)")}>Send</button> + <button onClick={() => { onSetTweaking(null); onSetTweakComment(""); }} + className={GHOST_CHIP}>✕</button> + </div> + ) : ( + <button onClick={() => onSetTweaking(draft.id)} className={GHOST_CHIP}>Tweak</button> + ) + )} + + {/* Visual buttons */} + {!draft.visualUrl && draft.status !== "posted" && ( + <> + <button onClick={() => onAttachVisual(draft.id)} className={GHOST_CHIP}>Attach Image</button> + <button onClick={() => onGenerateVisual(draft.id)} className={CHIP} style={toneStyle("var(--accent)")}>Generate Visual</button> + </> + )} + + {/* Reject from approved/scheduled */} + {(draft.status === "approved" || draft.status === "scheduled") && ( + isShowingRejectPicker ? ( + <div className="flex flex-wrap gap-1 w-full mt-1"> + {REJECT_REASONS.map(reason => ( + <button key={reason} onClick={() => { onFeedback(draft.id, "rejected", reason); onSetRejectPicker(null); }} + className="text-[10px] px-2.5 py-1 rounded-[var(--r-sm)]" style={toneStyle("var(--down)")}>{reason}</button> + ))} + <form className="flex gap-1 w-full mt-1" onSubmit={(e) => { + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("customReason") as HTMLInputElement); + if (input.value.trim()) { onFeedback(draft.id, "rejected", input.value.trim()); onSetRejectPicker(null); } + }}> + <input name="customReason" placeholder="Custom reason..." autoFocus + className="flex-1 bg-[var(--surface-2)] border border-[var(--line-strong)] rounded-[var(--r-sm)] px-2.5 py-1 text-[10px] text-[var(--text)] focus:outline-none focus:ring-1 focus:ring-[var(--down)]" /> + <button type="submit" className="text-[10px] px-2.5 py-1 rounded-[var(--r-sm)]" style={toneStyle("var(--down)")}>Reject</button> + </form> + <button onClick={() => onSetRejectPicker(null)} className="text-[10px] text-[var(--text-3)] px-2 py-0.5">Cancel</button> + </div> + ) : ( + <button onClick={() => onSetRejectPicker(draft.id)} className={CHIP} style={toneStyle("var(--down)")}>Reject</button> + ) + )} + + {/* Approve / Reject */} + {draft.status === "pending" && ( + <> + <button onClick={() => onFeedback(draft.id, "approved")} className={CHIP} style={toneStyle("var(--up)")}>Approve</button> + {isShowingRejectPicker ? ( + <div className="flex flex-wrap gap-1 w-full mt-1"> + {REJECT_REASONS.map(reason => ( + <button key={reason} onClick={() => { onFeedback(draft.id, "rejected", reason); onSetRejectPicker(null); }} + className="text-[10px] px-2.5 py-1 rounded-[var(--r-sm)]" style={toneStyle("var(--down)")}>{reason}</button> + ))} + <form className="flex gap-1 w-full mt-1" onSubmit={(e) => { + e.preventDefault(); + const input = (e.currentTarget.elements.namedItem("customReason") as HTMLInputElement); + if (input.value.trim()) { onFeedback(draft.id, "rejected", input.value.trim()); onSetRejectPicker(null); } + }}> + <input name="customReason" placeholder="Custom reason..." autoFocus + className="flex-1 bg-[var(--surface-2)] border border-[var(--line-strong)] rounded-[var(--r-sm)] px-2.5 py-1 text-[10px] text-[var(--text)] focus:outline-none focus:ring-1 focus:ring-[var(--down)]" /> + <button type="submit" className="text-[10px] px-2.5 py-1 rounded-[var(--r-sm)]" style={toneStyle("var(--down)")}>Reject</button> + </form> + <button onClick={() => onSetRejectPicker(null)} className="text-[10px] text-[var(--text-3)] px-2 py-0.5">Cancel</button> + </div> + ) : ( + <button onClick={() => onSetRejectPicker(draft.id)} className={CHIP} style={toneStyle("var(--down)")}>Reject</button> + )} + </> + )} + + {/* Schedule */} + {draft.status === "approved" && !draft.scheduledDate && ( + isShowingSchedulePicker ? ( + <div className="flex flex-wrap gap-1 w-full mt-1"> + {weekDays.filter(d => !d.isPast).map(day => ( + <button key={day.date} onClick={() => { onSchedule(draft.id, day.date); onSetSchedulePicker(null); }} + className="text-[10px] px-2 py-0.5 rounded-[var(--r-sm)] hover:brightness-125" + style={day.isToday ? toneStyle("var(--accent)") : { color: "var(--text-2)", background: "var(--surface-2)", border: "1px solid var(--line)" }}> + {day.dayName} {day.label} + </button> + ))} + <button onClick={() => onSetSchedulePicker(null)} className="text-[10px] text-[var(--text-3)] px-2 py-0.5">Cancel</button> + </div> + ) : ( + <button onClick={() => onSetSchedulePicker(draft.id)} className={CHIP} style={toneStyle("var(--accent)")}>Schedule</button> + ) + )} + + {/* Mark posted */} + {(draft.status === "approved" || draft.status === "scheduled") && ( + isShowingPostedUrl ? ( + <div className="flex gap-1 w-full mt-1"> + <input value={postedUrlValue} onChange={(e) => onSetPostedUrlValue(e.target.value)} + placeholder="Tweet URL (optional)" + className="flex-1 bg-[var(--surface-2)] border border-[var(--line-strong)] rounded-[var(--r-sm)] px-2.5 py-1.5 text-[12px] text-[var(--text)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)]" /> + <button onClick={() => onMarkPosted(draft.id, postedUrlValue || undefined)} + className={CHIP} style={toneStyle("var(--accent)")}>Done</button> + <button onClick={() => { onSetPostedUrlDraft(null); onSetPostedUrlValue(""); }} + className="text-[11px] text-[var(--text-3)] px-1">✕</button> + </div> + ) : ( + <button onClick={() => onSetPostedUrlDraft(draft.id)} className={CHIP} style={toneStyle("var(--accent)")}>Mark Posted</button> + ) + )} + + {/* Restore */} + {draft.status === "rejected" && ( + <button onClick={() => onFeedback(draft.id, "pending")} className={CHIP} style={toneStyle("var(--warn)")}>Restore</button> + )} + </div> + + {/* Tweak status */} + {draft.tweakRequested && ( + <div className="text-[10px] px-5 py-2 border-t border-[var(--line)]" style={{ color: "var(--warn)", background: "color-mix(in srgb, var(--warn) 6%, transparent)" }}> + Tweak requested: “{draft.tweakRequested.comment}” — {draft.tweakRequested.status} + </div> + )} + </div> + ); +}); + +// ─── DroppableDay (top-level, memo'd) ──────────────────────────────── +const DroppableDay = memo(function DroppableDay({ + day, scheduledDrafts, dragOverDate, onDragOver, onDragLeave, onDrop, onClickDraft, +}: { + day: WeekDay; + scheduledDrafts: Draft[]; + dragOverDate: string | null; + onDragOver: (date: string) => void; + onDragLeave: () => void; + onDrop: (draftId: string, date: string) => void; + onClickDraft: (draft: Draft) => void; +}) { + return ( + <div + onDragOver={(e) => { e.preventDefault(); onDragOver(day.date); }} + onDragLeave={() => onDragLeave()} + onDrop={(e) => { + e.preventDefault(); onDragLeave(); + const draftId = e.dataTransfer.getData("text/plain"); + if (draftId && !day.isPast) onDrop(draftId, day.date); + }} + className={`rounded-[var(--r-md)] p-3 space-y-2 border min-h-[100px] transition-all ${ + day.isToday ? "" : day.isPast ? "opacity-60" : "" + } ${dragOverDate === day.date && !day.isPast ? "ring-2 ring-[var(--accent)]/50" : ""}`} + style={day.isToday + ? { background: "color-mix(in srgb, var(--accent) 8%, transparent)", borderColor: "color-mix(in srgb, var(--accent) 26%, transparent)" } + : { background: "var(--surface-1)", borderColor: "var(--line)" }} + > + <div className="flex items-center gap-1.5"> + <span className="text-[12px] font-semibold" style={{ color: day.isToday ? "var(--accent)" : "var(--text-2)" }}>{day.dayName}</span> + <span className="num text-[10px]" style={{ color: day.isToday ? "var(--accent)" : "var(--text-3)" }}>{day.label}</span> + {day.isToday && <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] ml-auto" />} + </div> + <div className="space-y-1.5"> + {scheduledDrafts.map(d => ( + <div key={d.id} onClick={() => onClickDraft(d)} + className="text-[11px] px-2 py-1.5 rounded-[var(--r-sm)] border cursor-pointer hover:brightness-125 transition-all" + style={ + d.status === "scheduled" ? toneStyle("var(--accent)") : + d.status === "approved" ? toneStyle("var(--up)") : + { color: "var(--text-2)", background: "var(--surface-2)", border: "1px solid var(--line)" } + }> + <div className="flex justify-between items-center"> + <p className="line-clamp-2 leading-tight pr-1">{d.text.slice(0, 60)}{d.text.length > 60 ? "…" : ""}</p> + {d.scheduledTime && <span className="num text-[9px] text-[var(--text-3)] ml-1">{d.scheduledTime}</span>} + </div> + </div> + ))} + </div> + </div> + ); +}); + +// ─── Main Page ─────────────────────────────────────────────────────── +export default function XContentPage() { + const [drafts, setDrafts] = useState<Draft[]>([]); + const [fetchError, setFetchError] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"pending" | "approved" | "posted" | "scheduled" | "visuals">("pending"); + const [showRejected, setShowRejected] = useState(false); + const [contentFilter, setContentFilter] = useState<"all" | "tweet" | "article">("all"); + const [editingDraft, setEditingDraft] = useState<string | null>(null); + const [tweakDraft, setTweakDraft] = useState<string | null>(null); + const [tweakComment, setTweakComment] = useState(""); + const [showRejectPicker, setShowRejectPicker] = useState<string | null>(null); + const [showSchedulePicker, setShowSchedulePicker] = useState<string | null>(null); + const [postedUrlDraft, setPostedUrlDraft] = useState<string | null>(null); + const [postedUrlValue, setPostedUrlValue] = useState(""); + const [lightboxUrl, setLightboxUrl] = useState<string | null>(null); + const [dragOverDate, setDragOverDate] = useState<string | null>(null); + const [contentRequest, setContentRequest] = useState(""); + const [requestLoading, setRequestLoading] = useState(false); + + const weekDays = getWeekDays(); + + const loadDrafts = useCallback(async () => { + try { + setFetchError(null); + setDrafts(await fetchDrafts()); + } catch (e: any) { + setFetchError(e?.message || "Failed to load drafts"); + } + setLoading(false); + }, []); + + useEffect(() => { loadDrafts(); }, [loadDrafts]); + + const filterByType = useCallback((list: Draft[]) => { + if (contentFilter === "all") return list; + if (contentFilter === "article") return list.filter(d => d.type === "article"); + return list.filter(d => d.type !== "article"); + }, [contentFilter]); + + // Exclude ideas — those belong on the Ideas page + const nonIdeas = drafts.filter(d => d.type !== "idea" && d.type !== "content idea" && d.type !== "industry idea"); + const pending = filterByType(nonIdeas.filter(d => d.status === "pending")); + const approved = filterByType(nonIdeas.filter(d => d.status === "approved" && !d.scheduledDate)); + const scheduled = filterByType(nonIdeas.filter(d => d.status === "scheduled" || (d.status === "approved" && d.scheduledDate))); + const posted = filterByType(nonIdeas.filter(d => d.status === "posted")); + const rejected = filterByType(nonIdeas.filter(d => d.status === "rejected")); + + const tabCounts: Record<string, number> = { pending: pending.length, approved: approved.length, scheduled: scheduled.length, posted: posted.length }; + + const handleFeedback = useCallback(async (draftId: string, rating: string, reason?: string) => { + const res = await fetch("/api/x-content/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, rating, reason }) }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + console.error("[feedback] POST failed", res.status, err); + alert(`Save failed (${res.status}): ${err?.error || "unknown error"}. Try refreshing the page.`); + return; + } + loadDrafts(); + }, [loadDrafts]); + + const handleScheduleDraft = useCallback(async (draftId: string, scheduledDate: string | null, scheduledTime?: string | null) => { + await fetch("/api/x-content/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, scheduledDate, scheduledTime }) }); + loadDrafts(); + }, [loadDrafts]); + + const handleTweak = useCallback(async (draftId: string, comment: string) => { + await fetch("/api/x-content/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, tweakComment: comment }) }); + setTweakDraft(null); setTweakComment(""); + loadDrafts(); + }, [loadDrafts]); + + const handleEditSubmit = useCallback(async (draftId: string, newText: string) => { + await fetch("/api/x-content/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, editText: newText }) }); + setEditingDraft(null); + loadDrafts(); + }, [loadDrafts]); + + const handleMarkPosted = useCallback(async (draftId: string, url?: string) => { + await fetch("/api/x-content/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, rating: "posted", postedUrl: url }) }); + setPostedUrlDraft(null); setPostedUrlValue(""); + loadDrafts(); + }, [loadDrafts]); + + const handleAttachVisual = useCallback((draftId: string) => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; fileInput.accept = ".png,.jpg,.jpeg"; + fileInput.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + if (file.size > 10 * 1024 * 1024) { alert("File too large. Max 10MB."); return; } + const formData = new FormData(); + formData.append("draftId", draftId); formData.append("file", file); + try { await fetch("/api/x-content/visual", { method: "POST", body: formData }); loadDrafts(); } + catch (err) { console.error("Upload failed", err); } + }; + fileInput.click(); + }, [loadDrafts]); + + const handleGenerateVisual = useCallback(async (draftId: string) => { + const draft = drafts.find(d => d.id === draftId); + if (!draft) return; + try { + const res = await fetch("/api/x-content/generate-visual", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId }) }); + if (!res.ok) { const err = await res.json(); console.error("Generation failed:", err); alert("Visual generation failed: " + (err.error || "unknown")); } + else { loadDrafts(); } + } + catch (err) { console.error("Generation failed", err); alert("Visual generation failed"); } + }, [drafts, loadDrafts]); + + const handleRemoveVisual = useCallback(async (draftId: string) => { + await fetch("/api/x-content/visual", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId }) }); + loadDrafts(); + }, [loadDrafts]); + + const handleCalendarDraftClick = useCallback((draft: Draft) => { + const tab = draft.status === "posted" ? "posted" : "scheduled"; + setActiveTab(tab as typeof activeTab); + setTimeout(() => { + const el = document.getElementById(`draft-${draft.id}`); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + el?.classList.add("ring-2", "ring-sky-500/50"); + setTimeout(() => el?.classList.remove("ring-2", "ring-sky-500/50"), 2000); + }, 100); + }, []); + + const handleDragOver = useCallback((date: string) => setDragOverDate(date), []); + const handleDragLeave = useCallback(() => setDragOverDate(null), []); + + const handleContentRequest = useCallback(async () => { + if (!contentRequest.trim() || requestLoading) return; + setRequestLoading(true); + try { + await fetch("/api/x-content/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: contentRequest.trim() }), + }); + setContentRequest(""); + } catch (err) { console.error("Request failed", err); } + setRequestLoading(false); + }, [contentRequest, requestLoading]); + + if (loading) { + return ( + <div className="p-4 md:p-6"> + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {[...Array(6)].map((_, i) => <div key={i} className="sk h-48 rounded-[var(--r-lg)]" />)} + </div> + </div> + ); + } + + if (fetchError) { + const is401 = fetchError === "401"; + return ( + <EmptyState + title={is401 ? "Session expired" : "Failed to load drafts"} + hint={is401 ? "Your login session expired. Please sign back in." : fetchError} + action={is401 + ? <a href="/login" className="btn-primary px-4 py-2 text-[13px]">Sign in</a> + : <button onClick={loadDrafts} className="btn-ghost px-4 py-2 text-[13px]">Retry</button>} + /> + ); + } + + const activeDrafts = activeTab === "pending" ? pending : activeTab === "approved" ? approved : activeTab === "scheduled" ? scheduled : posted; + + const renderCard = (draft: Draft) => ( + <DraftCard + key={draft.id} + draft={draft} + isEditing={editingDraft === draft.id} + isTweaking={tweakDraft === draft.id} + tweakComment={tweakDraft === draft.id ? tweakComment : ""} + isShowingRejectPicker={showRejectPicker === draft.id} + isShowingSchedulePicker={showSchedulePicker === draft.id} + isShowingPostedUrl={postedUrlDraft === draft.id} + postedUrlValue={postedUrlDraft === draft.id ? postedUrlValue : ""} + weekDays={weekDays} + onSetEditing={setEditingDraft} + onSetTweaking={setTweakDraft} + onSetTweakComment={setTweakComment} + onSetRejectPicker={setShowRejectPicker} + onSetSchedulePicker={setShowSchedulePicker} + onSetPostedUrlDraft={setPostedUrlDraft} + onSetPostedUrlValue={setPostedUrlValue} + onEditSubmit={handleEditSubmit} + onTweak={handleTweak} + onFeedback={handleFeedback} + onSchedule={handleScheduleDraft} + onMarkPosted={handleMarkPosted} + onAttachVisual={handleAttachVisual} + onGenerateVisual={handleGenerateVisual} + onRemoveVisual={handleRemoveVisual} + onLightbox={setLightboxUrl} + onLoadDrafts={loadDrafts} + /> + ); + + return ( + <div className="p-4 md:p-6 space-y-5 overflow-x-hidden"> + {/* Content Request Bar */} + <div className="flex gap-2"> + <div className="flex-1 relative"> + <input + value={contentRequest} + onChange={(e) => setContentRequest(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleContentRequest(); }} + placeholder="Request content... e.g. 'quote retweet my last tweet about bookmarks with an update'" + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] px-4 py-3 text-[13px] text-[var(--text)] placeholder-[var(--text-3)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)] focus:border-[var(--accent)]" + /> + <span className="absolute right-3 top-3.5 text-[10px] text-[var(--text-3)]">Sage</span> + </div> + <button + onClick={handleContentRequest} + disabled={requestLoading || !contentRequest.trim()} + className={`px-4 py-3 rounded-[var(--r-md)] text-[13px] font-medium transition-colors ${ + !contentRequest.trim() || requestLoading ? "opacity-40 cursor-not-allowed" : "" + } ${contentRequest.trim() && !requestLoading ? "btn-primary" : "btn-ghost"}`} + > + {requestLoading ? "Sending..." : "Request"} + </button> + </div> + + {/* Tabs */} + <div className="space-y-2"> + <div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4 md:mx-0 md:px-0"> + {(["pending", "approved", "scheduled", "posted"] as const).map((tab) => ( + <button key={tab} onClick={() => setActiveTab(tab)} + className={`${CHIP} shrink-0 px-3 py-1.5 text-[13px] ${ + activeTab === tab + ? "text-[var(--text)] bg-[var(--surface-3)] border border-[var(--line-strong)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)] hover:bg-[var(--surface-2)] border border-transparent" + }`}> + {tab.charAt(0).toUpperCase() + tab.slice(1)} + <span className="num text-[10px] px-1.5 py-0.5 rounded-full text-[var(--text-3)] bg-[var(--surface-2)]">{tabCounts[tab]}</span> + </button> + ))} + <button onClick={() => setActiveTab("visuals")} + className={`${CHIP} shrink-0 px-3 py-1.5 text-[13px] ${ + activeTab === "visuals" + ? "text-[var(--text)] bg-[var(--surface-3)] border border-[var(--line-strong)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)] hover:bg-[var(--surface-2)] border border-transparent" + }`}> + Visuals + </button> + <div className="shrink-0 border-l border-[var(--line)] ml-1 pl-2"> + <button onClick={() => setShowRejected(!showRejected)} + className={`${CHIP} px-2.5 py-1.5 text-[12px]`} + style={showRejected ? toneStyle("var(--down)") : { color: "var(--text-3)", background: "transparent", border: "1px solid transparent" }}> + Rejected + <span className="num text-[10px] px-1.5 py-0.5 rounded-full text-[var(--text-3)] bg-[var(--surface-2)]">{rejected.length}</span> + </button> + </div> + <select value={contentFilter} onChange={(e) => setContentFilter(e.target.value as typeof contentFilter)} + className="shrink-0 ml-auto bg-[var(--surface-2)] text-[var(--text-2)] px-2.5 py-1.5 rounded-[var(--r-sm)] text-[13px] border border-[var(--line)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)]"> + <option value="all">All</option> + <option value="tweet">Tweets</option> + <option value="article">Articles</option> + </select> + </div> + </div> + + {/* Visuals Tab */} + {activeTab === "visuals" && (() => { + const articleVisuals = [ + // ── 40 hours article ── + { id: "40h-hero", label: "40hrs — Hero / Thumbnail", desc: "Article header — use this when sharing the link on X", url: "/article-visuals/1d706cf0-e41d-4e6d-9cc5-14e4301b1e2b-hero.html", thumb: null }, + { id: "40h-comparison", label: "40hrs — Prompting vs Delegating", desc: "Core contrast card — place after the intro section", url: "/article-visuals/1d706cf0-e41d-4e6d-9cc5-14e4301b1e2b-comparison.html", thumb: null }, + { id: "40h-steps", label: "40hrs — 3-Step Framework", desc: "Step-by-step delegation guide — place before the CTA", url: "/article-visuals/1d706cf0-e41d-4e6d-9cc5-14e4301b1e2b-steps.html", thumb: null }, + // ── Google Workspace article ── + { id: "hero", label: "GWS — Hero / Thumbnail", desc: "Article header — use this when sharing the link on X", url: "/article-visuals/hero.html", thumb: null }, + { id: "skills-grid", label: "GWS — Skills Overview Grid", desc: "All 50 skills organized by app — place after the intro", url: "/article-visuals/skills-grid.html", thumb: null }, + { id: "setup-flow", label: "GWS — Setup Flow", desc: "4-step install guide — place in the setup section", url: "/article-visuals/setup-flow.html", thumb: null }, + { id: "before-after", label: "GWS — Before / After", desc: "Emotional payoff card — place before the conclusion", url: "/article-visuals/before-after.html", thumb: null }, + { id: "cc-hero", label: "Claude Code — Hero / Thumbnail", desc: "Article header — use when sharing the link on X", url: "/article-visuals/claude-code-hero.html", thumb: null }, + { id: "cc-skills", label: "Claude Code — Agent Skills", desc: "Screenshot 1 — SKILL.md setup", url: "/draft-visuals/claude-code-skills-skill-md.png", thumb: "/draft-visuals/claude-code-skills-skill-md.png" }, + { id: "cc-hook", label: "Claude Code — SessionStart Hook", desc: "Screenshot 2 — settings.json hook config", url: "/draft-visuals/claude-code-session-hook.png", thumb: "/draft-visuals/claude-code-session-hook.png" }, + { id: "cc-aws", label: "Claude Code — AWS CloudFormation", desc: "Screenshot 3 — retroactive IaC import session", url: "/draft-visuals/claude-code-aws-cloudformation.png", thumb: "/draft-visuals/claude-code-aws-cloudformation.png" }, + { id: "cc-gsd", label: "Claude Code — GSD Context Manager", desc: "Screenshot 4 — context pruning in action", url: "/draft-visuals/claude-code-gsd-context.png", thumb: "/draft-visuals/claude-code-gsd-context.png" }, + { id: "cc-gws", label: "Claude Code — Google Workspace CLI", desc: "Screenshot 5 — inbox + email draft session", url: "/draft-visuals/claude-code-google-workspace.png", thumb: "/draft-visuals/claude-code-google-workspace.png" }, + ]; + const draftVisuals = drafts.filter(d => d.visualUrl).map(d => ({ + id: d.id, label: d.text?.slice(0, 60) + "…", desc: `Draft visual · ${d.status}`, url: d.visualUrl!, thumb: d.visualUrl, + })); + const allVisuals = [...articleVisuals, ...draftVisuals]; + return ( + <div className="space-y-8"> + <div> + <span className="eyebrow">Article Visuals</span> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4"> + {articleVisuals.map(v => ( + <div key={v.id} className="panel overflow-hidden"> + <div className="relative bg-[var(--bg)]" style={{ height: 200 }}> + <iframe src={v.url} className="w-full h-full border-0 pointer-events-none" style={{ transform: "scale(0.38)", transformOrigin: "top left", width: "263%", height: "263%" }} /> + <a href={v.url} target="_blank" rel="noreferrer" + className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/40 transition group"> + <span className="opacity-0 group-hover:opacity-100 text-[var(--text)] text-[13px] font-medium bg-[var(--surface-3)] px-3 py-1.5 rounded-[var(--r-sm)] transition"> + Open full size + </span> + </a> + </div> + <div className="p-4 flex items-start justify-between gap-3"> + <div> + <div className="text-[13px] font-semibold text-[var(--text)]">{v.label}</div> + <div className="text-[12px] text-[var(--text-3)] mt-0.5">{v.desc}</div> + </div> + <div className="flex gap-2 shrink-0"> + <a href={v.url} target="_blank" rel="noreferrer" + className={`${CHIP} whitespace-nowrap`} style={toneStyle("var(--accent)")}> + Open HD ↗ + </a> + <button onClick={() => { navigator.clipboard.writeText(window.location.origin + v.url); }} + className={GHOST_CHIP}> + Copy URL + </button> + </div> + </div> + </div> + ))} + </div> + </div> + {draftVisuals.length > 0 && ( + <div> + <span className="eyebrow">Draft Visuals (PNG)</span> + <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3 mt-4"> + {draftVisuals.map(v => ( + <div key={v.id} className="panel overflow-hidden"> + <div className="relative bg-[var(--bg)] h-36 cursor-pointer" onClick={() => setLightboxUrl(v.url)}> + <img src={v.thumb!} alt={v.label} className="w-full h-full object-cover opacity-80 hover:opacity-100 transition" /> + </div> + <div className="p-2.5 flex items-center justify-between gap-2"> + <div className="text-[12px] text-[var(--text-2)] truncate">{v.label}</div> + <a href={v.url} download target="_blank" rel="noreferrer" + className={`${GHOST_CHIP} shrink-0 !px-2`}> + DL + </a> + </div> + </div> + ))} + </div> + </div> + )} + </div> + ); + })()} + + {/* Main grid */} + {activeTab !== "visuals" && ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {activeDrafts.map(renderCard)} + {activeDrafts.length === 0 && ( + <div className="col-span-1 md:col-span-2 xl:col-span-3"> + <EmptyState title={`No ${activeTab} drafts${contentFilter !== "all" ? ` (${contentFilter}s)` : ""}`} /> + </div> + )} + </div> + )} + + {/* Rejected (collapsible) */} + {showRejected && rejected.length > 0 && ( + <div className="border-t border-[var(--line)] pt-5 space-y-3"> + <div className="flex items-center justify-between"> + <span className="eyebrow" style={{ color: "var(--down)" }}>Rejected ({rejected.length})</span> + <button onClick={() => setShowRejected(false)} className="text-[12px] text-[var(--text-3)] hover:text-[var(--text-2)]">Hide</button> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 opacity-80">{rejected.map(renderCard)}</div> + </div> + )} + + {/* Calendar */} + <div className="border-t border-[var(--line)] pt-5"> + <div className="eyebrow mb-3">Schedule · drag drafts here</div> + <div className="grid grid-cols-7 gap-2"> + {weekDays.map((day) => ( + <DroppableDay key={day.date} day={day} + scheduledDrafts={drafts.filter(d => d.scheduledDate === day.date)} + dragOverDate={dragOverDate} + onDragOver={handleDragOver} onDragLeave={handleDragLeave} + onDrop={(draftId, date) => handleScheduleDraft(draftId, date)} + onClickDraft={handleCalendarDraftClick} /> + ))} + </div> + </div> + + {/* Lightbox */} + {lightboxUrl && ( + <div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-6" onClick={() => setLightboxUrl(null)}> + <img src={lightboxUrl} alt="Visual Preview" className="max-w-full max-h-full object-contain" /> + </div> + )} + + </div> + ); +} diff --git a/src/app/x/page.tsx b/src/app/x/page.tsx new file mode 100644 index 0000000..3292718 --- /dev/null +++ b/src/app/x/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import dynamic from "next/dynamic"; + +function TabSkeleton() { + return ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 pt-6"> + {[...Array(6)].map((_, i) => ( + <div key={i} className="panel p-5 space-y-3"> + <div className="flex items-center gap-3"> + <div className="sk w-8 h-8 rounded-full" /> + <div className="sk h-3 w-24 rounded" /> + <div className="sk h-5 w-16 rounded-full ml-auto" /> + </div> + <div className="space-y-2"> + <div className="sk h-3 rounded w-full" /> + <div className="sk h-3 rounded w-4/5" /> + <div className="sk h-3 rounded w-3/5" /> + </div> + <div className="flex gap-2 pt-1"> + {[...Array(4)].map((_, j) => <div key={j} className="sk h-6 w-16 rounded-lg" />)} + </div> + </div> + ))} + </div> + ); +} + +const ContentTab = dynamic(() => import("@/app/x-content/page"), { ssr: false, loading: () => <TabSkeleton /> }); +const PerformanceTab = dynamic(() => import("@/app/x-analytics/page"), { ssr: false, loading: () => <TabSkeleton /> }); +const TrendRadarTab = dynamic(() => import("@/app/watchlist-radar/page"), { ssr: false, loading: () => <TabSkeleton /> }); + +type Tab = "content" | "performance" | "radar"; + +export default function XPage() { + const [tab, setTab] = useState<Tab>("content"); + + const tabs: { key: Tab; label: string }[] = [ + { key: "content", label: "Content" }, + { key: "performance", label: "Performance" }, + { key: "radar", label: "Trend Radar" }, + ]; + + return ( + <div className="space-y-0"> + {/* Tab bar */} + <div className="flex items-center gap-1 border-b border-[var(--line)] mb-0"> + {tabs.map((t) => ( + <button + key={t.key} + onClick={() => setTab(t.key)} + className={`text-[13px] px-4 py-3 font-medium transition-colors relative whitespace-nowrap ${ + tab === t.key + ? "text-[var(--text)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)]" + }`} + > + {t.label} + {tab === t.key && ( + <div className="absolute -bottom-px left-3 right-3 h-0.5 bg-[var(--accent)] rounded-full" /> + )} + </button> + ))} + </div> + + {/* Tab content — strip full-page container styles from child pages */} + <div className="[&>div]:min-h-0 [&>div]:pt-0 [&>div]:md\:pt-0 [&>div]:px-0 [&>div]:md\:px-0 [&>div]:p-0 [&>div]:md\:p-0 [&>div]:bg-transparent"> + {tab === "content" && <ContentTab />} + {tab === "performance" && <PerformanceTab />} + {tab === "radar" && <TrendRadarTab />} + </div> + </div> + ); +} diff --git a/src/app/youtube/page.tsx b/src/app/youtube/page.tsx new file mode 100644 index 0000000..dbb5504 --- /dev/null +++ b/src/app/youtube/page.tsx @@ -0,0 +1,879 @@ +"use client"; + +import React, { useEffect, useState, useCallback, useRef } from "react"; +import dynamic from "next/dynamic"; +import { Panel, Button, Skeleton, EmptyState, Pill } from "@/components/ui/kit"; + +function TabSkeleton() { + return ( + <div className="space-y-4 pt-4"> + {[...Array(3)].map((_, i) => ( + <div key={i} className="panel p-5 space-y-3"> + <Skeleton className="h-4 w-48" /> + <Skeleton className="h-3 w-full" /> + <Skeleton className="h-3 w-3/4" /> + </div> + ))} + </div> + ); +} + +const LongformTab = dynamic(() => import("@/app/longform/page"), { ssr: false, loading: () => <TabSkeleton /> }); +const OutlierFeed = dynamic(() => import("@/components/OutlierFeed"), { ssr: false, loading: () => <TabSkeleton /> }); + +interface Idea { + title: string; + hook: string; + angle: string; + hookType?: string; + funnelStage?: string; + rejectedReason?: string; + status?: "pending" | "rejected" | "approved"; + sourceUrl?: string; + sourceSummary?: string; +} + +interface Script { + id: string; + title: string; + hook: string; + storySetup: string; + conflict: string; + insight: string; + cta: string; + caption: string; + onScreenText?: string; + hookType?: string; + funnelStage?: string; + rejectedReason?: string; + factCheck?: { status: string; verified: number; issues: string[]; sourceUrls?: string[] }; + fullScript?: string; + status: "draft" | "approved" | "tofilm" | "filmed" | "rejected"; + createdAt: string; + // SEO Package + seoTitle?: string; + seoDescription?: string; + seoTags?: string[]; + seoChapters?: { time: string; label: string }[]; + titleVariants?: string[]; + thumbnailConcept?: string; +} + +// funnel stage tone: TOF = accent, MOF = warn, BOF = up +function funnelTone(stage?: string): "accent" | "warn" | "up" { + if (stage === "TOF") return "accent"; + if (stage === "MOF") return "warn"; + return "up"; +} + +export default function YouTubePage() { + const [ideas, setIdeas] = useState<Idea[]>([]); + const [rejectedIdeas, setRejectedIdeas] = useState<Idea[]>([]); + const [generating, setGenerating] = useState<string | null>(null); + const [generatedScript, setGeneratedScript] = useState<Script | null>(null); + const [scripts, setScripts] = useState<Script[]>([]); + const [activeView, setActiveView] = useState<"longform" | "shorts" | "performance" | "outliers">("longform"); + const [shortsView, setShortsView] = useState<"ideas" | "scripts" | "tofilm" | "filmed">("ideas"); + const [generatingIdeas, setGeneratingIdeas] = useState(false); + const [selectedIdeas, setSelectedIdeas] = useState<Set<string>>(new Set()); + const [batchGenerating, setBatchGenerating] = useState(false); + const [ideaTab, setIdeaTab] = useState<"pending" | "rejected">("pending"); + const [scriptTab, setScriptTab] = useState<"draft" | "approved" | "rejected">("draft"); + const [rejectModal, setRejectModal] = useState<{ type: "idea" | "script"; id: string; title: string } | null>(null); + const rejectRef = React.useRef<HTMLTextAreaElement>(null); + + // Performance tab state + type PerfVideo = { id: string; title: string; thumbnail: string; publishedAt: string; views: number; likes: number; comments: number; url: string; isShort: boolean; durationSecs: number; }; + const [perfData, setPerfData] = useState<{ videos: PerfVideo[]; totalVideos: number; avgViews: number; avgEngagement: number } | null>(null); + const [perfLoading, setPerfLoading] = useState(false); + const [perfFilter, setPerfFilter] = useState<"all" | "30" | "90">("all"); + const [perfVideoType, setPerfVideoType] = useState<"all" | "longform" | "shorts">("all"); + const [perfSort, setPerfSort] = useState<"views" | "likes" | "comments" | "publishedAt">("views"); + const [perfSortDir, setPerfSortDir] = useState<"desc" | "asc">("desc"); + + const fetchPerformance = useCallback(async () => { + if (perfData) return; + setPerfLoading(true); + try { + const d = await (await fetch("/api/youtube/performance", { cache: "no-store" })).json(); + setPerfData(d); + } catch { /* empty */ } + setPerfLoading(false); + }, [perfData]); + + useEffect(() => { + if (activeView === "performance") fetchPerformance(); + }, [activeView, fetchPerformance]); + + function fmtNum(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toString(); + } + + function fmtPubDate(s: string): string { + return new Date(s).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + } + + const fetchIdeas = useCallback(async () => { + try { + const all: Idea[] = await (await fetch("/api/youtube/ideas", { cache: "no-store" })).json(); + setIdeas(all.filter(i => !i.status || i.status === "pending")); + setRejectedIdeas(all.filter(i => i.status === "rejected")); + } catch { /* empty */ } + }, []); + + const fetchScripts = useCallback(async () => { + try { setScripts(await (await fetch("/api/youtube/scripts", { cache: "no-store" })).json()); } catch { /* empty */ } + }, []); + + useEffect(() => { fetchIdeas(); fetchScripts(); }, [fetchIdeas, fetchScripts]); + + async function generateFromIdea(idea: Idea) { + setGenerating(idea.title); + setGeneratedScript(null); + try { + const res = await fetch("/api/youtube/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic: `${idea.title}. Hook angle: ${idea.hook}. Lesson: ${idea.angle}`, sourceUrl: idea.sourceUrl, sourceSummary: idea.sourceSummary }), + }); + if (res.ok) { + const data = await res.json(); + setGeneratedScript(data); + await fetch("/api/youtube/scripts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + // Mark idea as approved + await fetch("/api/youtube/ideas/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: idea.title, status: "approved" }), + }); + setIdeas(prev => prev.filter(i => i.title !== idea.title)); + fetchScripts(); + } + } catch { /* empty */ } + setGenerating(null); + } + + async function rejectIdea(title: string, reason: string) { + try { + await fetch("/api/youtube/ideas/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, status: "rejected", rejectedReason: reason }), + }); + const idea = ideas.find(i => i.title === title); + if (idea) { + setIdeas(prev => prev.filter(i => i.title !== title)); + setRejectedIdeas(prev => [...prev, { ...idea, status: "rejected", rejectedReason: reason }]); + } + } catch { /* empty */ } + } + + async function updateScript(id: string, updates: Partial<Script>) { + try { + await fetch("/api/youtube/scripts", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, ...updates }), + }); + setScripts(prev => prev.map(s => s.id === id ? { ...s, ...updates } : s)); + if (generatedScript?.id === id) setGeneratedScript(prev => prev ? { ...prev, ...updates } : null); + } catch { /* empty */ } + } + + async function deleteScript(id: string) { + try { + await fetch("/api/youtube/scripts", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, deleted: true }), + }); + setScripts(prev => prev.filter(s => s.id !== id)); + } catch { /* empty */ } + } + + const draftScripts = scripts.filter(s => s.status === "draft"); + const approvedScripts = scripts.filter(s => s.status === "approved"); + const rejectedScripts = scripts.filter(s => s.status === "rejected"); + const tofilmScripts = scripts.filter(s => s.status === "tofilm"); + const filmedScripts = scripts.filter(s => s.status === "filmed"); + + // small ghost copy button + const copyBtn = "text-[10px] px-2 py-0.5 rounded-[var(--r-sm)] bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--line)] hover:text-[var(--text)] transition-colors"; + + function RejectModal() { + if (!rejectModal) return null; + return ( + <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 md:p-6" onClick={() => setRejectModal(null)}> + <div dir="ltr" className="panel p-4 md:p-6 w-full max-w-md" onClick={e => e.stopPropagation()}> + <h3 className="text-sm font-semibold text-[var(--text)] mb-1">Reject: {rejectModal.title}</h3> + <p className="text-xs text-[var(--text-3)] mb-3">Why? This helps me learn your taste.</p> + <textarea + ref={rejectRef} + defaultValue="" + placeholder="e.g. hook is weak, story is overdone, not my angle..." + className="w-full bg-[var(--surface-1)] border border-[var(--line)] rounded-[var(--r-md)] p-3 text-sm text-[var(--text)] placeholder-[var(--text-3)] resize-none h-20 focus:outline-none focus:border-[var(--accent)]" + /> + <div className="flex gap-2 mt-3"> + <button + onClick={() => { + const reason = rejectRef.current?.value || ""; + if (rejectModal.type === "idea") rejectIdea(rejectModal.id, reason); + else updateScript(rejectModal.id, { status: "rejected", rejectedReason: reason } as Partial<Script>); + setRejectModal(null); + }} + className="flex-1 text-sm py-2 rounded-[var(--r-md)] font-medium transition-colors" + style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 14%, transparent)" }} + >Reject</button> + <button onClick={() => { setRejectModal(null); }} className="btn-ghost flex-1 text-sm py-2">Cancel</button> + </div> + </div> + </div> + ); + } + + function ScriptCard({ script, compact }: { script: Script; compact?: boolean }) { + const [expanded, setExpanded] = useState(!compact); + + const fcTone = script.factCheck?.status === "✅" ? "var(--up)" : script.factCheck?.status === "🔧" ? "var(--warn)" : "var(--down)"; + + return ( + <Panel className="overflow-hidden !p-0"> + <div className="p-4 md:p-6 cursor-pointer" onClick={() => setExpanded(!expanded)}> + <div className="flex items-center justify-between mb-2"> + <span className="text-sm font-semibold text-[var(--text)] flex-1 pr-2">{script.title}</span> + <div className="flex items-center gap-2 shrink-0"> + {script.funnelStage && <Pill tone={funnelTone(script.funnelStage)} className="!px-2 !py-0.5 !text-[10px]">{script.funnelStage}</Pill>} + {script.hookType && <Pill tone="neutral" className="!px-2 !py-0.5 !text-[10px]">{script.hookType}</Pill>} + <span className="text-[10px] text-[var(--text-4)]">{expanded ? "▴" : "▾"}</span> + </div> + </div> + {script.factCheck && ( + <div className="rounded-[var(--r-md)] p-2 mb-2 border" style={{ borderColor: `color-mix(in srgb, ${fcTone} 22%, transparent)`, background: `color-mix(in srgb, ${fcTone} 10%, transparent)` }}> + <p className="text-[11px] text-[var(--text-2)]"> + <span className="font-medium text-[var(--text)]">{script.factCheck.status} Fact Check</span> + {script.factCheck.status === "✅" && <span className="num ml-1" style={{ color: "var(--up)" }}>— {script.factCheck.verified} claims verified</span>} + {script.factCheck.status === "🔧" && <span className="ml-1" style={{ color: "var(--warn)" }}>— auto-corrected</span>} + {script.factCheck.status === "⚠️" && <span className="ml-1" style={{ color: "var(--down)" }}>— issues found</span>} + </p> + {script.factCheck.issues.length > 0 && ( + <div className="mt-1 space-y-0.5"> + {script.factCheck.issues.map((issue, i) => ( + <p key={i} className="text-[10px]" style={{ color: "var(--warn)" }}>• {issue}</p> + ))} + </div> + )} + {script.factCheck.sourceUrls && script.factCheck.sourceUrls.length > 0 && ( + <div className="mt-1.5 pt-1.5 border-t border-[var(--line)]"> + <p className="text-[9px] text-[var(--text-3)] mb-0.5">📚 Sources:</p> + {script.factCheck.sourceUrls.slice(0, 5).map((url, i) => ( + <a key={i} href={url} target="_blank" rel="noopener noreferrer" className="block text-[9px] truncate hover:underline" style={{ color: "var(--accent)" }}>🔗 {url}</a> + ))} + </div> + )} + </div> + )} + {script.rejectedReason && ( + <div className="rounded-[var(--r-md)] p-2 mb-2 border" style={{ borderColor: "color-mix(in srgb, var(--down) 22%, transparent)", background: "color-mix(in srgb, var(--down) 10%, transparent)" }}> + <p className="text-[10px]" style={{ color: "var(--down)" }}>💬 {script.rejectedReason}</p> + </div> + )} + {script.onScreenText && ( + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-2 mb-2 border border-[var(--line)]"> + <p className="eyebrow mb-0.5">📺 On-Screen Text</p> + <p className="text-[12px] text-[var(--text-2)] font-medium">{script.onScreenText}</p> + </div> + )} + <div className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3 border border-[var(--line)]"> + <p className="eyebrow mb-1">Hook</p> + <p className="text-[13px] text-[var(--text)] whitespace-pre-line leading-relaxed">{script.hook}</p> + </div> + </div> + + {expanded && ( + <div className="px-4 pb-4 space-y-3"> + {[ + { label: "Story Setup", text: script.storySetup }, + { label: "Conflict / Pivot", text: script.conflict }, + { label: "Insight / Payoff", text: script.insight }, + { label: "CTA", text: script.cta }, + { label: "Caption", text: script.caption }, + ].map(s => ( + <div key={s.label} className="bg-[var(--surface-2)] rounded-[var(--r-md)] p-3"> + <p className="eyebrow mb-1">{s.label}</p> + <p className={`text-[13px] text-[var(--text-2)] whitespace-pre-line leading-relaxed ${s.label === "Caption" ? "italic" : ""}`}>{s.text}</p> + </div> + ))} + {/* SEO Package */} + {(script.seoTitle || script.titleVariants || script.seoTags) && ( + <div className="bg-[var(--surface-2)] rounded-[var(--r-lg)] border border-[var(--line)] p-4 space-y-3"> + <p className="eyebrow" style={{ color: "var(--accent)" }}>🔍 SEO Package</p> + + {script.titleVariants && script.titleVariants.length > 0 && ( + <div> + <p className="eyebrow mb-1.5">Title Variants</p> + <div className="space-y-1"> + {script.titleVariants.map((v, i) => ( + <div key={i} className="flex items-start gap-2 group"> + <span className="num text-[10px] text-[var(--text-4)] w-4 shrink-0 mt-0.5">{i + 1}.</span> + <p className="text-[12px] text-[var(--text-2)] flex-1">{v}</p> + <button + onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(v); }} + className={`opacity-0 group-hover:opacity-100 transition-opacity shrink-0 ${copyBtn}`} + >copy</button> + </div> + ))} + </div> + </div> + )} + + {script.seoTitle && ( + <div> + <p className="eyebrow mb-1">Optimized Title</p> + <div className="flex items-center gap-2"> + <p className="text-[12px] font-medium flex-1" style={{ color: "var(--accent)" }}>{script.seoTitle}</p> + <button + onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(script.seoTitle!); }} + className={copyBtn} + >copy</button> + </div> + <p className="num text-[10px] text-[var(--text-4)] mt-0.5">{script.seoTitle.length}/60 chars</p> + </div> + )} + + {script.seoDescription && ( + <div> + <p className="eyebrow mb-1">Description</p> + <div className="flex items-start gap-2"> + <p className="text-[11px] text-[var(--text-2)] whitespace-pre-line leading-relaxed flex-1">{script.seoDescription}</p> + <button + onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(script.seoDescription!); }} + className={`shrink-0 ${copyBtn}`} + >copy</button> + </div> + </div> + )} + + {script.seoTags && script.seoTags.length > 0 && ( + <div> + <p className="eyebrow mb-1.5">Tags (<span className="num">{script.seoTags.length}</span>)</p> + <div className="flex flex-wrap gap-1.5"> + {script.seoTags.map((tag, i) => ( + <span key={i} className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--surface-3)] text-[var(--text-2)] border border-[var(--line)]">{tag}</span> + ))} + </div> + <button + onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(script.seoTags!.join(', ')); }} + className={`mt-1.5 ${copyBtn}`} + >📋 Copy all tags</button> + </div> + )} + + {script.seoChapters && script.seoChapters.length > 0 && ( + <div> + <p className="eyebrow mb-1.5">Chapters</p> + <div className="space-y-0.5"> + {script.seoChapters.map((ch, i) => ( + <p key={i} className="num text-[11px] text-[var(--text-2)] font-mono">{ch.time} {ch.label}</p> + ))} + </div> + <button + onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(script.seoChapters!.map(c => `${c.time} ${c.label}`).join('\n')); }} + className={`mt-1.5 ${copyBtn}`} + >📋 Copy chapters</button> + </div> + )} + + {script.thumbnailConcept && ( + <div> + <p className="eyebrow mb-1">Thumbnail Concept</p> + <p className="text-[11px] text-[var(--text-2)] italic">{script.thumbnailConcept}</p> + </div> + )} + </div> + )} + + <div className="flex items-center gap-2 flex-wrap"> + <Button size="sm" variant="ghost" + onClick={() => { + const full = `${script.hook}\n\n${script.storySetup}\n\n${script.conflict}\n\n${script.insight}\n\n${script.cta}`; + navigator.clipboard.writeText(full); + }} + >📋 Copy</Button> + + {script.status === "draft" && ( + <> + <button + onClick={(e) => { e.stopPropagation(); updateScript(script.id, { status: "tofilm" }); }} + className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors" + style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }} + >Approve</button> + <button + onClick={(e) => { e.stopPropagation(); setRejectModal({ type: "script", id: script.id, title: script.title }); }} + className="text-xs px-3 py-1.5 rounded-[var(--r-md)] transition-colors" + style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 12%, transparent)" }} + >Reject</button> + </> + )} + + {script.status === "tofilm" && ( + <> + <button + onClick={(e) => { e.stopPropagation(); updateScript(script.id, { status: "filmed" }); }} + className="text-xs px-3 py-1.5 rounded-[var(--r-md)] font-medium transition-colors" + style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }} + >Filmed</button> + <button + onClick={(e) => { e.stopPropagation(); deleteScript(script.id); }} + className="text-xs px-3 py-1.5 rounded-[var(--r-md)] transition-colors" + style={{ color: "var(--down)", background: "color-mix(in srgb, var(--down) 12%, transparent)" }} + >🗑 Delete</button> + <Button size="sm" variant="ghost" onClick={() => updateScript(script.id, { status: "draft" })}>↩ Back to Scripts</Button> + </> + )} + + {script.status === "filmed" && ( + <Button size="sm" variant="ghost" onClick={() => updateScript(script.id, { status: "tofilm" })}>↩ Back to To Film</Button> + )} + + {script.status === "rejected" && ( + <Button size="sm" variant="ghost" onClick={() => updateScript(script.id, { status: "draft" })}>↩ Restore to Draft</Button> + )} + </div> + </div> + )} + </Panel> + ); + } + + // shorts sub-nav pill styling + function subPill(active: boolean) { + return `text-xs px-3 py-1.5 rounded-full transition-colors ${active ? "" : "bg-[var(--surface-1)] text-[var(--text-3)] hover:text-[var(--text-2)] border border-[var(--line)]"}`; + } + const subActiveStyle = { color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)", border: "1px solid color-mix(in srgb, var(--accent) 22%, transparent)" }; + + return ( + <div dir="ltr" className="min-h-screen pt-16 md:pt-6 px-4 md:p-6 space-y-6"> + <RejectModal /> + + {/* Main Tabs */} + <div className="flex items-center gap-0 border-b border-[var(--line)] overflow-x-auto"> + {([ + { key: "longform" as const, label: "📹 Long Form" }, + { key: "shorts" as const, label: "🎬 Shorts" }, + { key: "performance" as const, label: "📊 Performance" }, + { key: "outliers" as const, label: "🔥 Outliers" }, + ]).map(tab => ( + <button + key={tab.key} + onClick={() => setActiveView(tab.key)} + className={`text-sm px-5 py-2.5 font-medium transition-colors relative whitespace-nowrap ${activeView === tab.key ? "text-[var(--text)]" : "text-[var(--text-3)] hover:text-[var(--text-2)]"}`} + > + {tab.label} + {activeView === tab.key && <div className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full" style={{ background: "var(--accent)" }} />} + </button> + ))} + </div> + + {/* LONGFORM VIEW */} + {activeView === "longform" && ( + <div className="[&>div]:min-h-0 [&>div]:pt-0 [&>div]:md\:pt-0 [&>div]:px-0 [&>div]:md\:px-0 [&>div]:p-0 [&>div]:md\:p-0"> + <LongformTab /> + </div> + )} + + {/* SHORTS SUB-NAV */} + {activeView === "shorts" && ( + <div className="flex gap-2 mt-4 mb-2"> + {([ + { key: "ideas" as const, label: "💡 Ideas", count: ideas.length }, + { key: "scripts" as const, label: "📜 Scripts", count: draftScripts.length }, + { key: "tofilm" as const, label: "🎬 To Film", count: tofilmScripts.length }, + { key: "filmed" as const, label: "✅ Filmed", count: filmedScripts.length }, + ]).map(tab => ( + <button + key={tab.key} + onClick={() => setShortsView(tab.key)} + className={subPill(shortsView === tab.key)} + style={shortsView === tab.key ? subActiveStyle : undefined} + > + {tab.label} (<span className="num">{tab.count}</span>) + </button> + ))} + </div> + )} + + {/* IDEAS VIEW */} + {activeView === "shorts" && shortsView === "ideas" && ( + <div> + {/* Sub-tabs */} + <div className="flex gap-2 mb-4"> + <button onClick={() => setIdeaTab("pending")} className={subPill(ideaTab === "pending")} style={ideaTab === "pending" ? subActiveStyle : undefined}> + Pending (<span className="num">{ideas.length}</span>) + </button> + <button onClick={() => setIdeaTab("rejected")} className={subPill(ideaTab === "rejected")} style={ideaTab === "rejected" ? subActiveStyle : undefined}> + Rejected (<span className="num">{rejectedIdeas.length}</span>) + </button> + </div> + + <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 md:p-6"> + <div className="lg:col-span-3 space-y-3"> + <div className="flex items-center justify-between gap-2 flex-wrap"> + <p className="text-xs text-[var(--text-3)]">{ideaTab === "pending" ? "Tap to select · hover for ✕ reject" : "Rejected ideas with your feedback"}</p> + {ideaTab === "pending" && ( + <div className="flex items-center gap-2"> + {selectedIdeas.size > 0 && ( + <Button size="sm" variant="primary" disabled={batchGenerating} + onClick={async () => { + setBatchGenerating(true); + for (const title of selectedIdeas) { + const idea = ideas.find(i => i.title === title); + if (idea) await generateFromIdea(idea); + } + setSelectedIdeas(new Set()); + setBatchGenerating(false); + }} + >{batchGenerating ? `✨ Generating ${selectedIdeas.size}...` : `Generate ${selectedIdeas.size} Script${selectedIdeas.size > 1 ? "s" : ""}`}</Button> + )} + <Button size="sm" variant="ghost" disabled={generatingIdeas} + onClick={async () => { + setGeneratingIdeas(true); + try { + const res = await fetch("/api/youtube/ideas/generate", { method: "POST" }); + if (res.ok) fetchIdeas(); + } catch { /* empty */ } + setGeneratingIdeas(false); + }} + >{generatingIdeas ? "✨ Generating..." : "💡 More Ideas"}</Button> + </div> + )} + </div> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> + {(ideaTab === "pending" ? ideas : rejectedIdeas).map((idea, i) => { + const isSelected = selectedIdeas.has(idea.title); + const gen = generating === idea.title; + return ( + <div + key={i} + className={`relative group text-left p-3 rounded-[var(--r-md)] border transition-all cursor-pointer ${ + gen + ? "animate-pulse" + : ideaTab === "rejected" + ? "bg-[var(--surface-1)] border-[var(--line)] opacity-60" + : isSelected + ? "" + : "bg-[var(--surface-1)] border-[var(--line)] hover:bg-[var(--surface-2)]" + }`} + style={ + gen + ? { background: "color-mix(in srgb, var(--accent) 10%, transparent)", borderColor: "color-mix(in srgb, var(--accent) 30%, transparent)" } + : (ideaTab === "pending" && isSelected) + ? { background: "color-mix(in srgb, var(--up) 10%, transparent)", borderColor: "color-mix(in srgb, var(--up) 40%, transparent)" } + : undefined + } + onClick={() => { + if (ideaTab !== "pending" || generating === idea.title) return; + setSelectedIdeas(prev => { + const next = new Set(prev); + if (next.has(idea.title)) next.delete(idea.title); + else next.add(idea.title); + return next; + }); + }} + > + {ideaTab === "pending" && ( + <button + onClick={(e) => { e.stopPropagation(); setRejectModal({ type: "idea", id: idea.title, title: idea.title }); }} + className="absolute top-2 right-2 w-6 h-6 rounded-full bg-[var(--surface-3)] text-[var(--text-3)] hover:text-[var(--down)] text-[10px] opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center z-10" + >✕</button> + )} + {isSelected && ( + <div className="absolute top-2 left-2 w-5 h-5 rounded-full flex items-center justify-center" style={{ background: "var(--up)" }}> + <span className="text-[10px] text-[var(--bg)] font-bold">✓</span> + </div> + )} + <div className={isSelected ? "pl-5" : ""}> + <div className="flex items-center gap-1.5 mb-1"> + <p className="text-sm font-medium text-[var(--text)] pr-6">{idea.title}</p> + </div> + {(idea.hookType || idea.funnelStage) && ( + <div className="flex gap-1 mb-1"> + {idea.funnelStage && <Pill tone={funnelTone(idea.funnelStage)} className="!px-1.5 !py-0.5 !text-[9px]">{idea.funnelStage}</Pill>} + {idea.hookType && <Pill tone="neutral" className="!px-1.5 !py-0.5 !text-[9px]">{idea.hookType}</Pill>} + </div> + )} + <p className="text-[11px] text-[var(--text-3)] line-clamp-2 leading-relaxed">{idea.hook}</p> + <div className="flex items-center gap-1.5 mt-1"> + {idea.sourceUrl ? ( + <a href={idea.sourceUrl} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} className="text-[9px] truncate max-w-[200px] hover:underline" style={{ color: "var(--accent)" }}>🔗 source</a> + ) : ( + <span className="text-[9px]" style={{ color: "var(--warn)" }}>⚠️ unverified</span> + )} + </div> + {idea.rejectedReason && ( + <p className="text-[10px] mt-1.5" style={{ color: "var(--down)" }}>💬 {idea.rejectedReason}</p> + )} + {gen && ( + <p className="text-[10px] mt-2 font-medium" style={{ color: "var(--accent)" }}>✨ Writing script...</p> + )} + </div> + </div> + ); + })} + </div> + </div> + + {/* Preview */} + <div className="lg:col-span-2"> + <p className="eyebrow mb-3">Preview</p> + {generatedScript ? ( + <ScriptCard script={generatedScript} /> + ) : ( + <Panel> + <EmptyState icon={<span className="text-3xl">✂️</span>} title="Pick an idea to generate a full script" /> + </Panel> + )} + </div> + </div> + </div> + )} + + {/* SCRIPTS VIEW */} + {activeView === "shorts" && shortsView === "scripts" && ( + <div> + <div className="flex gap-2 mb-4"> + <button onClick={() => setScriptTab("draft")} className={subPill(scriptTab === "draft")} style={scriptTab === "draft" ? subActiveStyle : undefined}> + Pending (<span className="num">{draftScripts.length}</span>) + </button> + <button onClick={() => setScriptTab("rejected")} className={subPill(scriptTab === "rejected")} style={scriptTab === "rejected" ? subActiveStyle : undefined}> + Rejected (<span className="num">{rejectedScripts.length}</span>) + </button> + </div> + <div className="space-y-3"> + {(scriptTab === "draft" ? draftScripts : rejectedScripts).length === 0 ? ( + <Panel> + <EmptyState title={`No ${scriptTab} scripts`} /> + </Panel> + ) : ( + (scriptTab === "draft" ? draftScripts : rejectedScripts).map(s => <ScriptCard key={s.id} script={s} compact />) + )} + </div> + </div> + )} + + {/* TO FILM VIEW */} + {activeView === "shorts" && shortsView === "tofilm" && ( + <div className="space-y-3"> + {tofilmScripts.length === 0 ? ( + <Panel> + <EmptyState icon={<span className="text-3xl">🎬</span>} title="No scripts queued for filming" hint="Approve scripts to move them here" /> + </Panel> + ) : ( + <> + <Button size="sm" variant="ghost" + onClick={() => { + const text = tofilmScripts.map((s, i) => { + const parts = [s.hook, s.storySetup, s.conflict, s.insight, s.cta].filter(Boolean); + return `Video ${i + 1}:\n${parts.join("\n\n")}`; + }).join("\n\n---\n\n"); + navigator.clipboard.writeText(text); + }} + >📋 Copy All Scripts ({tofilmScripts.length})</Button> + <button + onClick={async () => { + for (const s of tofilmScripts) { + await fetch("/api/youtube/scripts", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: s.id, status: "filmed" }), + }); + } + setScripts(prev => prev.map(s => s.status === "tofilm" ? { ...s, status: "filmed" } : s)); + }} + className="text-xs px-4 py-2 rounded-[var(--r-md)] font-medium transition-colors ml-2" + style={{ color: "var(--up)", background: "color-mix(in srgb, var(--up) 12%, transparent)" }} + >✅ Mark All as Filmed ({tofilmScripts.length})</button> + {tofilmScripts.map(s => <ScriptCard key={s.id} script={s} compact />)} + </> + )} + </div> + )} + + {/* FILMED VIEW */} + {activeView === "shorts" && shortsView === "filmed" && ( + <div className="space-y-3"> + {filmedScripts.length === 0 ? ( + <Panel> + <EmptyState icon={<span className="text-3xl">✅</span>} title="No filmed scripts yet" /> + </Panel> + ) : ( + filmedScripts.map(s => <ScriptCard key={s.id} script={s} compact />) + )} + </div> + )} + + {/* OUTLIERS VIEW */} + {activeView === "outliers" && <OutlierFeed />} + + {/* PERFORMANCE VIEW */} + {activeView === "performance" && ( + <div className="space-y-5"> + {perfLoading ? ( + <div className="space-y-5"> + <div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> + {[...Array(4)].map((_, i) => <Skeleton key={i} className="h-20 rounded-[var(--r-md)]" />)} + </div> + <Skeleton className="h-64 rounded-[var(--r-md)]" /> + </div> + ) : perfData ? (() => { + // Filtering + const dateFiltered = perfData.videos.filter(v => { + if (perfFilter === "all") return true; + const days = perfFilter === "30" ? 30 : 90; + return new Date(v.publishedAt) >= new Date(Date.now() - days * 86400000); + }); + const typeFiltered = dateFiltered.filter(v => { + if (perfVideoType === "all") return true; + if (perfVideoType === "shorts") return v.isShort; + return !v.isShort; + }); + const sorted = [...typeFiltered].sort((a, b) => { + const aVal = perfSort === "publishedAt" ? new Date(a.publishedAt).getTime() : a[perfSort]; + const bVal = perfSort === "publishedAt" ? new Date(b.publishedAt).getTime() : b[perfSort]; + return perfSortDir === "desc" ? bVal - aVal : aVal - bVal; + }); + + const longformCount = dateFiltered.filter(v => !v.isShort).length; + const shortsCount = dateFiltered.filter(v => v.isShort).length; + + function SortTh({ col, label }: { col: typeof perfSort; label: string }) { + const active = perfSort === col; + return ( + <th + className="text-right px-4 py-3 font-medium cursor-pointer select-none hover:text-[var(--text-2)] transition-colors whitespace-nowrap" + onClick={() => { + if (active) setPerfSortDir(d => d === "desc" ? "asc" : "desc"); + else { setPerfSort(col); setPerfSortDir("desc"); } + }} + > + <span style={active ? { color: "var(--accent)" } : undefined}>{label}</span> + <span className="ml-1 text-[10px]">{active ? (perfSortDir === "desc" ? "▼" : "▲") : "↕"}</span> + </th> + ); + } + + return ( + <> + {/* Stats */} + <div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> + {[ + { label: "Total Videos", value: perfData.totalVideos.toString() }, + { label: "Avg Views", value: fmtNum(perfData.avgViews) }, + { label: "Avg Engagement", value: `${perfData.avgEngagement}%` }, + { label: "Channel", value: "Your Channel" }, + ].map(s => ( + <Panel key={s.label} className="p-4"> + <p className="eyebrow">{s.label}</p> + <p className="num text-[32px] font-semibold tracking-[-0.02em] leading-tight text-[var(--text)] mt-1">{s.value}</p> + </Panel> + ))} + </div> + + {/* Filters row */} + <div className="flex flex-wrap items-center gap-3"> + {/* Content type pills */} + <div className="flex items-center gap-1.5 bg-[var(--surface-1)] rounded-[var(--r-md)] border border-[var(--line)] p-1"> + {([ + { key: "all" as const, label: `All (${dateFiltered.length})` }, + { key: "longform" as const, label: `📹 Long Form (${longformCount})` }, + { key: "shorts" as const, label: `⚡ Shorts (${shortsCount})` }, + ]).map(t => ( + <button + key={t.key} + onClick={() => setPerfVideoType(t.key)} + className="text-xs px-3 py-1.5 rounded-[var(--r-sm)] font-medium transition-colors" + style={perfVideoType === t.key ? { color: "var(--accent)", background: "color-mix(in srgb, var(--accent) 14%, transparent)" } : { color: "var(--text-3)" }} + >{t.label}</button> + ))} + </div> + + {/* Time range */} + <select + value={perfFilter} + onChange={e => setPerfFilter(e.target.value as "all" | "30" | "90")} + className="text-xs bg-[var(--surface-1)] border border-[var(--line)] text-[var(--text-2)] rounded-[var(--r-md)] px-3 py-2 cursor-pointer" + > + <option value="all">All time</option> + <option value="30">Last 30 days</option> + <option value="90">Last 90 days</option> + </select> + + <p className="num text-xs text-[var(--text-3)] ml-auto"> + Showing {sorted.length} video{sorted.length !== 1 ? "s" : ""} + </p> + </div> + + {/* Table */} + <Panel className="overflow-hidden !p-0"> + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <thead> + <tr className="text-[11px] text-[var(--text-3)] uppercase tracking-wider border-b border-[var(--line)]"> + <th className="text-left px-4 py-3 font-medium">Video</th> + <SortTh col="views" label="Views" /> + <SortTh col="likes" label="Likes" /> + <SortTh col="comments" label="Comments" /> + <SortTh col="publishedAt" label="Published" /> + </tr> + </thead> + <tbody> + {sorted.map(v => ( + <tr key={v.id} className="border-b border-[var(--line)] hover:bg-[var(--surface-2)] transition-colors group"> + <td className="px-4 py-3"> + <div className="flex items-center gap-3"> + <a href={v.url} target="_blank" rel="noopener noreferrer" className="relative shrink-0"> + <img + src={v.thumbnail} + alt={v.title} + className="w-28 h-[63px] object-cover rounded-[var(--r-sm)] bg-[var(--surface-3)] border border-[var(--line)] opacity-90 group-hover:opacity-100 transition-all" + /> + {v.isShort && ( + <span className="absolute bottom-1 left-1 text-[9px] px-1.5 py-0.5 rounded bg-[#ff0000] text-white font-bold leading-none">SHORT</span> + )} + </a> + <a + href={v.url} + target="_blank" + rel="noopener noreferrer" + className="text-[var(--text-2)] hover:text-[var(--text)] line-clamp-2 leading-snug text-[13px]" + title={v.title} + > + {v.title} + </a> + </div> + </td> + <td className="num px-4 py-3 text-right text-[var(--text)] font-semibold">{fmtNum(v.views)}</td> + <td className="num px-4 py-3 text-right text-[var(--text-2)]">{fmtNum(v.likes)}</td> + <td className="num px-4 py-3 text-right text-[var(--text-2)]">{v.comments.toLocaleString()}</td> + <td className="num px-4 py-3 text-right text-[var(--text-3)] text-xs whitespace-nowrap">{fmtPubDate(v.publishedAt)}</td> + </tr> + ))} + </tbody> + </table> + </div> + </Panel> + </> + ); + })() : ( + <EmptyState title="Failed to load performance data." /> + )} + </div> + )} + </div> + ); +} diff --git a/src/components/HLPnlChart.tsx b/src/components/HLPnlChart.tsx new file mode 100644 index 0000000..1eaafed --- /dev/null +++ b/src/components/HLPnlChart.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; + +interface ChartPoint { + label: string; + balance: number; + idx: number; +} + +function fmt(n: number, d = 2) { + return n.toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }); +} + +export default function HLPnlChart({ + chartData, + totalPnl, + totalTrades, + walletBalance, +}: { + chartData: ChartPoint[]; + chartMin: number; + chartMax: number; + totalPnl: number; + totalTrades: number; + walletBalance: number; +}) { + const [hoverIdx, setHoverIdx] = useState<number | null>(null); + const svgRef = useRef<SVGSVGElement>(null); + + const W = 600; + const H = 180; + const PAD = { top: 16, right: 16, bottom: 28, left: 48 }; + const innerW = W - PAD.left - PAD.right; + const innerH = H - PAD.top - PAD.bottom; + + if (!chartData || chartData.length < 2) return null; + + const balances = chartData.map(d => d.balance); + const minB = Math.min(...balances); + const maxB = Math.max(...balances); + const pad = (maxB - minB) * 0.1 || 5; + const domainMin = minB - pad; + const domainMax = maxB + pad; + const range = domainMax - domainMin; + + const toX = (i: number) => PAD.left + (i / (chartData.length - 1)) * innerW; + const toY = (b: number) => PAD.top + innerH - ((b - domainMin) / range) * innerH; + + const linePts = chartData.map((d, i) => `${toX(i)},${toY(d.balance)}`).join(" "); + const areaPath = `M ${toX(0)},${PAD.top + innerH} L ${linePts.replace(/,/g, " L ").split(" L").join(" L")} L ${toX(chartData.length - 1)},${PAD.top + innerH} Z`; + + const lineColor = totalPnl >= 0 ? "#a78bfa" : "#f87171"; + const areaColor = totalPnl >= 0 ? "#a78bfa" : "#f87171"; + + // Y ticks + const yTicks = Array.from({ length: 5 }, (_, i) => domainMin + (range / 4) * i); + + // X labels: evenly spaced, max 4 + const xLabelIdxs = chartData.length <= 4 + ? chartData.map((_, i) => i) + : [0, Math.floor(chartData.length / 3), Math.floor((2 * chartData.length) / 3), chartData.length - 1]; + + // Ref line at $50 + const refY = toY(50); + const showRef = refY >= PAD.top && refY <= PAD.top + innerH; + + // Hover handling + const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const scaleX = W / rect.width; + const mouseX = (e.clientX - rect.left) * scaleX; + const chartX = mouseX - PAD.left; + const ratio = Math.max(0, Math.min(1, chartX / innerW)); + const idx = Math.round(ratio * (chartData.length - 1)); + setHoverIdx(idx); + }, [chartData.length]); + + const hovered = hoverIdx !== null ? chartData[hoverIdx] : null; + const hoveredDelta = hovered ? hovered.balance - 50 : 0; + + return ( + <div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"> + <div className="flex items-center justify-between mb-3"> + <p className="text-xs text-neutral-500 uppercase tracking-wider">Portfolio History</p> + <div className="flex items-center gap-3"> + {hovered ? ( + <span className="text-xs text-neutral-400 font-mono">{hovered.label}</span> + ) : null} + <span className={`text-sm font-mono font-bold ${totalPnl >= 0 ? "text-emerald-400" : "text-red-400"}`}> + {hovered + ? `$${fmt(hovered.balance)} (${hoveredDelta >= 0 ? "+" : ""}$${fmt(hoveredDelta)})` + : `${totalPnl >= 0 ? "+" : ""}$${fmt(totalPnl)} total`} + </span> + </div> + </div> + + <svg + ref={svgRef} + viewBox={`0 0 ${W} ${H}`} + className="w-full cursor-crosshair" + style={{ height: 180 }} + onMouseMove={handleMouseMove} + onMouseLeave={() => setHoverIdx(null)} + > + <defs> + <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0%" stopColor={areaColor} stopOpacity="0.25" /> + <stop offset="100%" stopColor={areaColor} stopOpacity="0.02" /> + </linearGradient> + </defs> + + {/* Grid lines */} + {yTicks.map((v, i) => ( + <g key={i}> + <line x1={PAD.left} y1={toY(v)} x2={PAD.left + innerW} y2={toY(v)} stroke="#1a1a1a" strokeWidth="1" /> + <text x={PAD.left - 6} y={toY(v) + 3.5} textAnchor="end" fill="#3a3a3a" fontSize="9"> + ${Math.round(v)} + </text> + </g> + ))} + + {/* $50 ref line */} + {showRef && ( + <> + <line x1={PAD.left} y1={refY} x2={PAD.left + innerW} y2={refY} stroke="#333" strokeWidth="1" strokeDasharray="4 3" /> + <text x={PAD.left - 6} y={refY + 3.5} textAnchor="end" fill="#444" fontSize="8">$50</text> + </> + )} + + {/* Area fill */} + <path d={areaPath} fill="url(#areaGrad)" /> + + {/* Line */} + <polyline points={linePts} fill="none" stroke={lineColor} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" /> + + {/* X axis labels */} + {xLabelIdxs.map(i => ( + <text key={i} x={toX(i)} y={H - 6} textAnchor="middle" fill="#3a3a3a" fontSize="9"> + {chartData[i].label} + </text> + ))} + + {/* Hover crosshair */} + {hoverIdx !== null && ( + <> + <line + x1={toX(hoverIdx)} y1={PAD.top} + x2={toX(hoverIdx)} y2={PAD.top + innerH} + stroke="#444" strokeWidth="1" strokeDasharray="3 2" + /> + <circle + cx={toX(hoverIdx)} + cy={toY(chartData[hoverIdx].balance)} + r="4" fill={lineColor} stroke="#111" strokeWidth="2" + /> + </> + )} + + {/* End dot */} + {hoverIdx === null && ( + <circle + cx={toX(chartData.length - 1)} + cy={toY(chartData[chartData.length - 1].balance)} + r="3.5" fill={lineColor} stroke="#111" strokeWidth="1.5" + /> + )} + </svg> + + <div className="flex items-center justify-between mt-1 text-[10px] text-neutral-700"> + <span>Started $50</span> + <span>{totalTrades} closed trades</span> + <span className={totalPnl >= 0 ? "text-emerald-700" : "text-red-700"}>${fmt(walletBalance)} now</span> + </div> + </div> + ); +} diff --git a/src/components/OfficeView.tsx b/src/components/OfficeView.tsx new file mode 100644 index 0000000..09016ba --- /dev/null +++ b/src/components/OfficeView.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface AgentActivity { + timestamp: string; + action: string; + result?: string; +} + +interface Agent { + id: string; + name: string; + emoji: string; + role: string; + status: string; + currentTask?: string; + lastActive?: string; + tasksCompleted: number; + totalCost: number; + recentActivity: AgentActivity[]; +} + +// ── Desk layout ─────────────────────────────────────────── +const DESK_LAYOUT = [ + { agentId: "max", label: "CEO Corner", zone: "ceo" }, + { agentId: "sage", label: "Research Bay", zone: "team" }, + { agentId: "knox", label: "Ops Desk", zone: "team" }, + { agentId: "nova", label: "Creative Hub", zone: "team" }, + { agentId: "pixel", label: "Lab", zone: "team" }, +]; + +// ── Status → visual config ──────────────────────────────── +const STATUS: Record<string, { glow: string; dot: string; bg: string; ring?: string }> = { + working: { glow: "shadow-[0_0_24px_6px_rgba(56,189,248,0.45)]", dot: "bg-sky-400", bg: "bg-sky-900/30 border-sky-500/40", ring: "rgba(56,189,248,0.5)" }, + idle: { glow: "shadow-[0_0_12px_2px_rgba(251,191,36,0.2)]", dot: "bg-yellow-400", bg: "bg-yellow-900/20 border-yellow-500/30" }, + error: { glow: "shadow-[0_0_12px_2px_rgba(248,113,113,0.3)]", dot: "bg-red-400", bg: "bg-red-900/20 border-red-500/30" }, + offline: { glow: "", dot: "bg-neutral-600", bg: "bg-neutral-800/20 border-neutral-700/20" }, + online: { glow: "shadow-[0_0_16px_3px_rgba(52,211,153,0.3)]", dot: "bg-emerald-400", bg: "bg-emerald-900/20 border-emerald-500/30" }, + active: { glow: "shadow-[0_0_16px_3px_rgba(52,211,153,0.3)]", dot: "bg-emerald-400", bg: "bg-emerald-900/20 border-emerald-500/30" }, + completed: { glow: "shadow-[0_0_12px_2px_rgba(251,191,36,0.2)]", dot: "bg-yellow-400", bg: "bg-yellow-900/20 border-yellow-500/30" }, +}; + +// ── Per-agent walk timing (keeps them out of sync) ──────── +const WALK = { + max: { wanderDur: "14s", bobDur: "0.35s", bobDelay: "0s", wanderDelay: "0s" }, + sage: { wanderDur: "8s", bobDur: "0.40s", bobDelay: "0.1s", wanderDelay: "1.2s" }, + knox: { wanderDur: "11s", bobDur: "0.45s", bobDelay: "0.2s", wanderDelay: "2.5s" }, + nova: { wanderDur: "9s", bobDur: "0.38s", bobDelay: "0.05s", wanderDelay: "0.7s" }, + pixel: { wanderDur: "12s", bobDur: "0.42s", bobDelay: "0.15s", wanderDelay: "3.1s" }, +}; + +// ── Pixel art sprites ───────────────────────────────────── +// '.' = transparent, letter = palette color +const SPRITE_DATA: Record<string, { palette: Record<string, string>; rows: string[] }> = { + max: { + palette: { E: "#374151", F: "#9CA3AF", A: "#F59E0B", S: "#E5E7EB", N: "#1F2937", T: "#F9FAFB" }, + rows: [ + "...EEE..EEE.....", + "..EEEEE.EEEEE...", + "..EFFFEEFFFE....", + "...FFFFFFFF.....", + "..FFFFFFFFFF....", + ".FFFAAFFAAFF....", + ".FFFFFFFFFFFFFF.", + ".FFFFSSSSSSFFF..", + ".FFFSNNNNNSFFF..", + ".FFFSSSSSSSFF...", + ".FFFFTFTTFTFFF..", + "..FFFFFFFFFFF...", + "...FFFFFFFFF....", + "................", + "................", + "................", + ], + }, + sage: { + palette: { L: "#22C55E", D: "#15803D", G: "#86EFAC", Y: "#FDE047", K: "#14532D" }, + rows: [ + "....LLLLLLL.....", + "...LDDDDDDLL....", + "..LGGGGGGGGG....", + ".LGGGGGGGGGGGL..", + ".GGGGGGGGGGGGG..", + ".GGGYYGGGGYYGG..", + ".GGGYYGGGGYYGG..", + ".GGGGGGGGGGGGG..", + ".GGGGGKKKGGGGG..", + ".GGGGKGGGGKGGG..", + ".GGGGGGGGGGGGG..", + "..GGGGGGGGGGG...", + "...GGGGGGGGG....", + "....GGGGGGG.....", + "................", + "................", + ], + }, + knox: { + palette: { H: "#1E3A5F", V: "#0F4C75", B: "#0A2540", C: "#00E5FF", W: "#6B7280" }, + rows: [ + "....HHHHHHHH....", + "...HHHHHHHHHH...", + "..HHHHHHHHHHHH..", + ".HHHVVVVVVVVHHH.", + ".HHHVBBBBBBVHHH.", + ".HHHVBCCBBCBVHH.", + ".HHHVBCCBBCBVHH.", + ".HHHVBBBBBBVHHH.", + ".HHHHHHHHHHHHHH.", + "..HWWWHHHHWWWH..", + "...HHHHHHHHHHH..", + "....HHHHHHHH....", + "................", + "................", + "................", + "................", + ], + }, + nova: { + palette: { P: "#A855F7", S: "#FDE047", W: "#FFFFFF", M: "#7C3AED" }, + rows: [ + "..S...SSSS...S..", + "...S.SPPPP.S....", + "..S.PPPPPPPP.S..", + "..PPPPPPPPPPPP..", + ".PPPPPPPPPPPPPP.", + ".PPPWWPPPPPWWPP.", + ".PPPWWPPPPPWWPP.", + ".PPPPPPPPPPPPP..", + ".PPPPPPPPPPPPP..", + ".PPPPPMMPPPPP...", + ".PPPPMPPPMPP....", + ".PPPPPPPPPPPP...", + "..PPPPPPPPPP....", + "...S.PPPP.S.....", + "....SSSSSS......", + "................", + ], + }, + pixel: { + palette: { B: "#1D4ED8", R: "#EF4444", F: "#FCD34D", K: "#1E1B4B", W: "#FFFFFF" }, + rows: [ + "...BBBBBBBBBB...", + "..BBBRBBBBBBBB..", + "..BFFFFFFFFFF...", + ".BFFFFFFFFFFFF..", + ".BFFFFFFFFFFFF..", + ".BFFKKFFFKKFFF..", + ".BFFKKFFFKKFFF..", + ".BFFFFFFFFFFFF..", + ".BFFFFFFFFFFFF..", + ".BFFFWWFWWFFFF..", + ".BFFWWWWWWWFFF..", + ".BFFFFFFFFFFFF..", + "..BFFFFFFFFFB...", + "...BBBBBBBBB....", + "................", + "................", + ], + }, +}; + +function PixelSprite({ agentId, size }: { agentId: string; size: number }) { + const data = SPRITE_DATA[agentId]; + if (!data) return <span style={{ fontSize: size * 0.6 }}>🤖</span>; + const { palette, rows } = data; + const gw = rows[0]?.length ?? 16; + const gh = rows.length; + return ( + <svg width={size} height={size} viewBox={`0 0 ${gw} ${gh}`} style={{ imageRendering: "pixelated" }}> + {rows.map((row, y) => + row.split("").map((char, x) => { + const color = char === "." ? null : palette[char]; + return color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null; + }) + )} + </svg> + ); +} + +// ── Activity bubble ─────────────────────────────────────── +function ActivityBubble({ text, delay }: { text: string; delay: string }) { + const short = text.length > 52 ? text.slice(0, 52) + "…" : text; + return ( + <div + className="absolute -top-12 left-1/2 z-20 pointer-events-none" + style={{ + transform: "translateX(-50%)", + animation: `bubble-cycle 8s ${delay} infinite`, + opacity: 0, + minWidth: 100, + maxWidth: 160, + }} + > + <div className="bg-neutral-800/90 border border-neutral-600/60 rounded-xl px-2.5 py-1.5 text-[9px] text-neutral-200 leading-snug shadow-lg"> + {short} + </div> + {/* Tail */} + <div className="mx-auto w-2 h-2 overflow-hidden" style={{ marginTop: -1 }}> + <div className="w-2 h-2 bg-neutral-700/80 rotate-45 origin-top-left scale-75 ml-[3px]" /> + </div> + </div> + ); +} + +// ── Animated monitor screen ─────────────────────────────── +function MonitorScreen({ isWorking }: { isWorking: boolean }) { + return ( + <div + className={`w-10 h-7 rounded border flex flex-col gap-0.5 p-1 overflow-hidden + ${isWorking ? "border-sky-500/60 bg-sky-950/60" : "border-neutral-600/40 bg-neutral-800/60"}`} + style={isWorking ? { animation: "screen-flicker 1.2s infinite" } : undefined} + > + {isWorking ? ( + <> + <div className="h-px bg-sky-400/80 rounded" style={{ width: "90%" }} /> + <div className="h-px bg-sky-300/50 rounded" style={{ width: "60%" }} /> + <div className="h-px bg-emerald-400/60 rounded" style={{ width: "75%" }} /> + <div className="h-px bg-sky-400/70 rounded" style={{ width: "40%" }} /> + <div className="h-px bg-sky-300/50 rounded" style={{ width: "85%" }} /> + </> + ) : ( + <div className="w-full h-full flex items-center justify-center"> + <div className="w-1 h-1 rounded-full bg-neutral-600/60" /> + </div> + )} + </div> + ); +} + +// ── Agent desk tile ─────────────────────────────────────── +function AgentDesk({ agent, label, isMax }: { agent: Agent | undefined; label: string; isMax: boolean }) { + const rawStatus = agent?.status ?? "offline"; + const statusKey = STATUS[rawStatus] ? rawStatus : "idle"; + const colors = STATUS[statusKey]; + const isWorking = rawStatus === "working"; + const isOffline = rawStatus === "offline" || !agent; + const spriteSize = isMax ? 56 : 44; + const walk = WALK[agent?.id as keyof typeof WALK] ?? WALK.sage; + + // Pick bubble text: currentTask > last activity > null + const bubbleText = agent?.currentTask + || agent?.recentActivity?.[0]?.action + || null; + + // Bubble cycle delay — stagger so not all pop at once + const bubbleDelay = isMax ? "0.5s" : walk.wanderDelay; + + return ( + <div className="relative flex flex-col items-center gap-2"> + {/* Desk tile */} + <div + className={`relative rounded-2xl border overflow-visible transition-all duration-500 + ${isMax ? "w-44 h-44" : "w-36 h-36"} + ${colors.bg} ${colors.glow} + ${isOffline ? "opacity-40" : ""} + hover:scale-105 hover:z-10`} + style={isWorking ? { animation: "status-ring 1.5s infinite" } : undefined} + > + {/* Desk surface */} + <div className={`absolute bottom-3 left-3 right-3 h-1/3 rounded-lg + ${isOffline ? "bg-neutral-700/30" : "bg-neutral-800/50"} border-t border-neutral-700/40`}> + {/* Monitor */} + {!isOffline && ( + <div className="absolute -top-6 left-1/2 -translate-x-1/2 flex flex-col items-center gap-0"> + <MonitorScreen isWorking={isWorking} /> + <div className="w-3 h-1 bg-neutral-600/50" /> + <div className="w-5 h-0.5 bg-neutral-600/50 rounded" /> + </div> + )} + </div> + + {/* Avatar + walking animation */} + <div className="absolute top-2 left-0 right-0 flex flex-col items-center"> + {/* Activity bubble lives OUTSIDE the flip wrapper so it never mirrors */} + <div className="relative w-full flex justify-center"> + {bubbleText && !isOffline && ( + <ActivityBubble text={bubbleText} delay={bubbleDelay} /> + )} + </div> + + {/* Horizontal wander wrapper */} + <div + style={ + isWorking + ? { animation: `agent-type 0.55s ease-in-out infinite` } + : isOffline + ? undefined + : { animation: `agent-wander ${walk.wanderDur} ${walk.wanderDelay} infinite ease-in-out` } + } + > + {/* Vertical bob wrapper */} + <div + style={ + !isOffline && !isWorking + ? { animation: `agent-bob ${walk.bobDur} ${walk.bobDelay} infinite ease-in-out` } + : undefined + } + > + <PixelSprite agentId={agent?.id ?? ""} size={spriteSize} /> + </div> + </div> + + {/* Name + status dot */} + <div className="flex items-center gap-1 mt-1"> + <div className={`w-1.5 h-1.5 rounded-full ${colors.dot} ${isWorking ? "animate-pulse" : ""}`} /> + <span className={`text-[10px] font-bold tracking-wider uppercase ${isOffline ? "text-neutral-600" : "text-white/80"}`}> + {agent?.name ?? "Empty"} + </span> + </div> + </div> + + {/* Tasks badge */} + {agent && agent.tasksCompleted > 0 && ( + <div className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-neutral-700 border border-neutral-600 flex items-center justify-center z-10"> + <span className="text-[9px] font-bold text-white">{agent.tasksCompleted > 99 ? "99+" : agent.tasksCompleted}</span> + </div> + )} + </div> + + {/* Label */} + <div className="text-center"> + <div className={`text-[10px] uppercase tracking-wider ${isOffline ? "text-neutral-700" : "text-neutral-500"}`}>{label}</div> + {agent?.role && <div className="text-[10px] text-neutral-600 truncate max-w-[140px]">{agent.role}</div>} + </div> + </div> + ); +} + +// ── Scrolling activity ticker ───────────────────────────── +function ActivityTicker({ agents }: { agents: Agent[] }) { + const events = agents + .flatMap(a => (a.recentActivity ?? []).slice(0, 2).map(ev => ({ name: a.name, emoji: a.emoji, action: ev.action }))) + .slice(0, 8); + + const [idx, setIdx] = useState(0); + useEffect(() => { + if (events.length === 0) return; + const t = setInterval(() => setIdx(i => (i + 1) % events.length), 4000); + return () => clearInterval(t); + }, [events.length]); + + if (events.length === 0) return null; + const ev = events[idx]; + + return ( + <div className="flex items-center gap-2 bg-neutral-900/60 border border-neutral-800/40 rounded-xl px-4 py-2 max-w-xl mx-auto"> + <div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse shrink-0" /> + <span className="text-[10px] text-neutral-500 font-mono shrink-0">{ev.name}</span> + <span className="text-[10px] text-neutral-400 truncate">{ev.action.slice(0, 80)}</span> + </div> + ); +} + +// ── Main export ─────────────────────────────────────────── +export default function OfficeView({ agents }: { agents: Agent[] }) { + const getAgent = (id: string) => agents.find(a => a.id === id); + const maxAgent = getAgent("max"); + const teamDesks = DESK_LAYOUT.filter(d => d.agentId !== "max"); + + return ( + <div className="relative rounded-3xl overflow-hidden border border-neutral-800/60 bg-neutral-950/80"> + {/* Floor */} + <div + className="relative p-8" + style={{ + background: + "repeating-linear-gradient(0deg,transparent,transparent 39px,rgba(255,255,255,0.02) 39px,rgba(255,255,255,0.02) 40px)," + + "repeating-linear-gradient(90deg,transparent,transparent 39px,rgba(255,255,255,0.02) 39px,rgba(255,255,255,0.02) 40px)", + }} + > + {/* Office sign */} + <div className="flex items-center justify-center mb-8"> + <div className="flex items-center gap-3 bg-neutral-900/80 border border-neutral-700/40 rounded-2xl px-5 py-2"> + <div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> + <span className="text-xs font-mono font-bold tracking-[0.2em] text-neutral-400 uppercase"> + Hermy HQ · Agent Floor + </span> + <div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> + </div> + </div> + + {/* Desks */} + <div className="flex flex-col items-center gap-8"> + {/* Row 1 — Max */} + <AgentDesk agent={maxAgent} label="CEO Corner" isMax={true} /> + <div className="w-px h-4 bg-neutral-700/60" /> + {/* Row 2 — Team */} + <div className="flex flex-wrap justify-center gap-6 md:gap-8"> + {teamDesks.map(desk => ( + <AgentDesk key={desk.agentId} agent={getAgent(desk.agentId)} label={desk.label} isMax={false} /> + ))} + </div> + </div> + + {/* Activity ticker */} + <div className="mt-8"> + <ActivityTicker agents={agents} /> + </div> + + {/* Floor props */} + <div className="mt-4 flex items-center justify-center gap-6 opacity-20"> + <span className="text-2xl select-none">🌿</span> + <div className="flex items-center gap-1"> + <span className="text-xl">☕</span> + <span className="text-[10px] text-neutral-500 font-mono">FUEL STATION</span> + </div> + <span className="text-2xl select-none">🌿</span> + </div> + </div> + + {/* Legend */} + <div className="border-t border-neutral-800/60 px-6 py-3 flex items-center gap-6 flex-wrap"> + {[ + { status: "working", label: "Working" }, + { status: "idle", label: "Idle" }, + { status: "offline", label: "Offline" }, + { status: "error", label: "Error" }, + ].map(({ status, label }) => ( + <div key={status} className="flex items-center gap-1.5"> + <div className={`w-2 h-2 rounded-full ${STATUS[status]?.dot ?? "bg-neutral-600"}`} /> + <span className="text-[10px] text-neutral-500 uppercase tracking-wider">{label}</span> + </div> + ))} + </div> + </div> + ); +} diff --git a/src/components/OutlierFeed.tsx b/src/components/OutlierFeed.tsx new file mode 100644 index 0000000..a508d2f --- /dev/null +++ b/src/components/OutlierFeed.tsx @@ -0,0 +1,347 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; + +interface Video { + id: string; + title: string; + publishedAt: string; + thumbnail: string; + views: number; + likes: number; + comments: number; + duration: string; + durationSeconds: number; + outlierScore: number; + velocityScore: number; + daysSincePublish: number; + niche: string; + nicheLabel: string; + query: string; + channelId: string; + channelTitle: string; + channelSubs: number; + channelMedianViews: number; + channelThumb: string; +} + +interface NicheCount { + label: string; + count: number; +} + +interface OutlierData { + date: string; + scannedAt: string | null; + totalOutliers: number; + nicheCounts: Record<string, NicheCount>; + videos: Video[]; + error?: string; +} + +type SortOption = 'outlierScore' | 'views' | 'recent' | 'velocity'; +type MinScore = 2 | 3 | 5 | 10; + +function formatNumber(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K'; + return n.toLocaleString(); +} + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + return `${months}mo ago`; +} + +function scoreBadgeColor(score: number): string { + if (score >= 20) return 'bg-purple-600'; + if (score >= 10) return 'bg-red-600'; + if (score >= 5) return 'bg-orange-500'; + if (score >= 3) return 'bg-yellow-500'; + return 'bg-blue-500'; +} + +const NICHE_COLORS: Record<string, string> = { + 'founder-brand': 'bg-rose-600', + 'crypto-web3': 'bg-amber-600', + 'content-creation': 'bg-sky-600', + 'agency-growth': 'bg-emerald-600', + 'ai-tools': 'bg-violet-600', + // legacy + 'ai-marketing': 'bg-emerald-600', + 'founder-content': 'bg-rose-600', + 'youtube-growth': 'bg-sky-600', + 'business-inspiration': 'bg-violet-600', +}; + +// ── Title pattern analysis ─────────────────────────────────────────────────── +interface TitlePattern { + label: string; + emoji: string; + regex: RegExp; +} + +const TITLE_PATTERNS: TitlePattern[] = [ + { label: 'How I...', emoji: '🙋', regex: /^how i\b/i }, + { label: 'How to...', emoji: '📖', regex: /^how to\b/i }, + { label: 'I [did X]', emoji: '⚡', regex: /^i (built|made|tried|replaced|gave|created|used|started|quit|spent|found|turned|grew|got|ran|tested|broke|sold|hired|scaled)\b/i }, + { label: 'Number list', emoji: '🔢', regex: /^\d+\s|^(top|best|the \d+)/i }, + { label: 'Why...', emoji: '🤔', regex: /^why\b/i }, + { label: 'Stop / Don\'t', emoji: '🚫', regex: /^(stop|don't|never|quit|avoid)\b/i }, + { label: 'The [thing]', emoji: '🎯', regex: /^the [a-z]+ (that|to|for|you|i|we)/i }, + { label: 'Question', emoji: '❓', regex: /\?$/ }, +]; + +function analyzeTitlePatterns(videos: Video[]): { pattern: TitlePattern; count: number; avgScore: number }[] { + return TITLE_PATTERNS.map((pattern) => { + const matches = videos.filter((v) => pattern.regex.test(v.title)); + const avgScore = matches.length + ? Math.round((matches.reduce((s, v) => s + v.outlierScore, 0) / matches.length) * 10) / 10 + : 0; + return { pattern, count: matches.length, avgScore }; + }) + .filter((p) => p.count > 0) + .sort((a, b) => b.avgScore - a.avgScore); +} + +function VideoCard({ video }: { video: Video }) { + return ( + <a + href={`https://www.youtube.com/watch?v=${video.id}`} + target="_blank" + rel="noopener noreferrer" + className="bg-neutral-900/50 rounded-xl border border-neutral-800/50 hover:border-red-500/30 transition-colors p-3 block" + > + <div className="relative aspect-video rounded-lg overflow-hidden"> + <img + src={video.thumbnail} + alt={video.title} + className="w-full h-full object-cover" + /> + <span + className={`absolute top-2 right-2 ${scoreBadgeColor(video.outlierScore)} text-white font-bold text-xs px-2 py-1 rounded-lg`} + > + {video.outlierScore}x + </span> + <span + className={`absolute top-2 left-2 ${NICHE_COLORS[video.niche] || 'bg-neutral-600'} text-white text-[10px] font-medium px-1.5 py-0.5 rounded-md`} + > + {video.nicheLabel} + </span> + <span className="absolute bottom-2 right-2 bg-black/80 text-white text-[10px] font-medium px-1.5 py-0.5 rounded"> + {video.duration} + </span> + </div> + + <h3 className="text-sm font-medium text-neutral-200 line-clamp-2 mt-2"> + {video.title} + </h3> + + <div className="flex items-center gap-2 mt-1.5"> + {video.channelThumb && ( + <img + src={video.channelThumb} + alt={video.channelTitle} + className="w-4 h-4 rounded-full" + /> + )} + <span className="text-xs text-neutral-400 truncate">{video.channelTitle}</span> + <span className="text-xs text-neutral-600">·</span> + <span className="text-xs text-neutral-500">{formatNumber(video.channelSubs)} subs</span> + </div> + + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-neutral-500"> + {formatNumber(video.views)} views vs {formatNumber(video.channelMedianViews)} median + </span> + </div> + + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-neutral-600">{timeAgo(video.publishedAt)}</span> + {video.velocityScore > 0 && ( + <span className="text-xs text-orange-400 font-medium"> + ⚡ {formatNumber(video.velocityScore)}/day + </span> + )} + </div> + </a> + ); +} + +export default function OutlierFeed() { + const [data, setData] = useState<OutlierData | null>(null); + const [loading, setLoading] = useState(true); + const [activeNiche, setActiveNiche] = useState<string>('all'); + const [sortBy, setSortBy] = useState<SortOption>('outlierScore'); + const [minScore, setMinScore] = useState<MinScore>(2); + + useEffect(() => { + fetch('/api/youtube/outliers') + .then((r) => r.json()) + .then((d) => setData(d)) + .catch(() => setData({ videos: [], totalOutliers: 0, nicheCounts: {}, scannedAt: null, date: '', error: 'Fetch failed' })) + .finally(() => setLoading(false)); + }, []); + + const filtered = useMemo(() => { + if (!data) return []; + let vids = data.videos.filter((v) => v.outlierScore >= minScore); + if (activeNiche !== 'all') vids = vids.filter((v) => v.niche === activeNiche); + if (sortBy === 'outlierScore') vids.sort((a, b) => b.outlierScore - a.outlierScore); + else if (sortBy === 'views') vids.sort((a, b) => b.views - a.views); + else if (sortBy === 'velocity') vids.sort((a, b) => (b.velocityScore || 0) - (a.velocityScore || 0)); + else vids.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); + return vids; + }, [data, activeNiche, sortBy, minScore]); + + if (loading) { + return ( + <div className="flex items-center justify-center py-32"> + <div className="w-6 h-6 border-2 border-red-500 border-t-transparent rounded-full animate-spin" /> + </div> + ); + } + + if (!data || data.error || data.videos.length === 0) { + return ( + <div className="flex flex-col items-center justify-center py-32 gap-4"> + <p className="text-neutral-400">{data?.error || 'No outlier data found'}</p> + <pre className="bg-neutral-800/50 text-neutral-300 text-sm px-4 py-2 rounded-lg"> + node scanner/outlier-scanner.js + </pre> + </div> + ); + } + + const bestScore = Math.max(...data.videos.map((v) => v.outlierScore)); + const avgScore = Math.round((data.videos.reduce((s, v) => s + v.outlierScore, 0) / data.videos.length) * 10) / 10; + const titlePatterns = analyzeTitlePatterns(data.videos); + + const minScoreOptions: MinScore[] = [2, 3, 5, 10]; + + return ( + <div className="space-y-6"> + {/* Stats bar */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-3"> + {[ + { label: 'Total Outliers', value: data.totalOutliers }, + { label: 'Best Score', value: `${bestScore}x` }, + { label: 'Avg Score', value: `${avgScore}x` }, + { label: 'Last Scanned', value: data.scannedAt ? timeAgo(data.scannedAt) : 'Never' }, + ].map((stat) => ( + <div + key={stat.label} + className="bg-neutral-900/80 border border-neutral-800/50 rounded-xl px-4 py-3" + > + <p className="text-xs text-neutral-500">{stat.label}</p> + <p className="text-lg font-semibold text-neutral-200 mt-0.5">{stat.value}</p> + </div> + ))} + </div> + + {/* Title Patterns */} + {titlePatterns.length > 0 && ( + <div className="bg-neutral-900/50 border border-neutral-800/50 rounded-xl p-4"> + <p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">📊 Title Patterns That Are Working</p> + <div className="grid grid-cols-2 md:grid-cols-4 gap-2"> + {titlePatterns.map(({ pattern, count, avgScore: avg }) => ( + <div key={pattern.label} className="bg-neutral-800/50 rounded-lg px-3 py-2 flex items-center justify-between gap-2"> + <div> + <p className="text-xs font-medium text-neutral-200">{pattern.emoji} {pattern.label}</p> + <p className="text-[10px] text-neutral-500 mt-0.5">{count} video{count !== 1 ? 's' : ''}</p> + </div> + <span className={`text-xs font-bold px-2 py-1 rounded-md ${scoreBadgeColor(avg)} text-white`}> + {avg}x + </span> + </div> + ))} + </div> + </div> + )} + + {/* Filter row */} + <div className="flex flex-col gap-3"> + {/* Niche pills */} + <div className="flex flex-wrap gap-2"> + <button + onClick={() => setActiveNiche('all')} + className={`text-xs px-3 py-1.5 rounded-lg border transition-colors ${ + activeNiche === 'all' + ? 'bg-red-500/20 border-red-500/50 text-red-400' + : 'bg-neutral-900/50 border-neutral-800/50 text-neutral-400 hover:border-neutral-700' + }`} + > + All ({data.totalOutliers}) + </button> + {Object.entries(data.nicheCounts).map(([key, nc]) => ( + <button + key={key} + onClick={() => setActiveNiche(key)} + className={`text-xs px-3 py-1.5 rounded-lg border transition-colors ${ + activeNiche === key + ? 'bg-red-500/20 border-red-500/50 text-red-400' + : 'bg-neutral-900/50 border-neutral-800/50 text-neutral-400 hover:border-neutral-700' + }`} + > + {nc.label} ({nc.count}) + </button> + ))} + </div> + + {/* Sort + min score */} + <div className="flex flex-wrap items-center gap-3"> + <select + value={sortBy} + onChange={(e) => setSortBy(e.target.value as SortOption)} + className="text-xs bg-neutral-900/80 border border-neutral-800/50 text-neutral-300 rounded-lg px-3 py-1.5 outline-none focus:border-red-500/50" + > + <option value="outlierScore">Outlier Score</option> + <option value="velocity">🔥 Velocity (views/day)</option> + <option value="views">Views</option> + <option value="recent">Recent</option> + </select> + + <div className="flex items-center gap-1.5"> + <span className="text-xs text-neutral-500">Min:</span> + {minScoreOptions.map((s) => ( + <button + key={s} + onClick={() => setMinScore(s)} + className={`text-xs px-2 py-1 rounded-md border transition-colors ${ + minScore === s + ? 'bg-red-500/20 border-red-500/50 text-red-400' + : 'bg-neutral-900/50 border-neutral-800/50 text-neutral-500 hover:border-neutral-700' + }`} + > + {s === 10 ? '10x+' : `${s}x`} + </button> + ))} + </div> + + <span className="text-xs text-neutral-600 ml-auto"> + {filtered.length} video{filtered.length !== 1 ? 's' : ''} + </span> + </div> + </div> + + {/* Video grid */} + {filtered.length === 0 ? ( + <p className="text-neutral-500 text-center py-16 text-sm">No videos match your filters</p> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {filtered.map((video) => ( + <VideoCard key={video.id} video={video} /> + ))} + </div> + )} + </div> + ); +} diff --git a/src/components/approval-inbox.tsx b/src/components/approval-inbox.tsx new file mode 100644 index 0000000..27e7e42 --- /dev/null +++ b/src/components/approval-inbox.tsx @@ -0,0 +1,290 @@ +"use client"; + +/* ─────────────────────────────────────────────────────────── + Hermy HQ · Approval inbox + "Everything that needs your tap" queue. + Self-contained: polls /api/hermes/requests, one-tap + approve / reject / edit via PATCH. Calm Luxury. + ─────────────────────────────────────────────────────────── */ + +import { useCallback, useEffect, useState } from "react"; +import { Check, X, Pencil, Inbox } from "lucide-react"; +import { + Panel, + Pill, + EmptyState, + Eyebrow, +} from "@/components/ui/kit"; + +// ── Types ───────────────────────────────────────────────── +interface Req { + id: string; + origin: string; + kind: string; + title: string; + prompt: string | null; + sideEffecting: boolean; + status: string; + result: string | null; + error: string | null; + createdAt: string; +} + +// ── Helpers ─────────────────────────────────────────────── +function timeAgo(d: string | null): string { + if (!d) return "—"; + const diff = Date.now() - new Date(d).getTime(); + if (Number.isNaN(diff)) return "—"; + const s = Math.floor(diff / 1000); + if (s < 45) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const days = Math.floor(h / 24); + return `${days}d ago`; +} + +async function getJSON<T>(url: string): Promise<T | null> { + try { + const r = await fetch(url); + if (!r.ok) return null; + return (await r.json()) as T; + } catch { + return null; + } +} + +// ── Card ────────────────────────────────────────────────── +function InboxCard({ + req, + compact, + onAction, +}: { + req: Req; + compact: boolean; + onAction: () => void; +}) { + const [busy, setBusy] = useState(false); + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(req.title); + const [draftPrompt, setDraftPrompt] = useState(req.prompt ?? ""); + + const patch = async (body: Record<string, unknown>) => { + setBusy(true); + try { + await fetch(`/api/hermes/requests/${req.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + // optimistic: card fades, parent refetches + onAction(); + } catch { + setBusy(false); + setEditing(false); + } + }; + + const pad = compact ? "p-4" : "p-5"; + + return ( + <Panel className={`${pad} ${busy ? "opacity-50 pointer-events-none" : ""}`}> + <div className="flex items-start justify-between gap-3 mb-2.5"> + <div className="flex items-center gap-2 flex-wrap"> + <Pill tone="neutral">{req.kind}</Pill> + {req.sideEffecting && <Pill tone="warn">side-effecting</Pill>} + </div> + <span className="num text-[10.5px] text-[var(--text-3)] shrink-0 mt-1"> + {timeAgo(req.createdAt)} + </span> + </div> + + {editing ? ( + <div className="space-y-2.5"> + <input + value={draftTitle} + onChange={(e) => setDraftTitle(e.target.value)} + className="w-full bg-transparent text-[14px] font-medium text-[var(--text)] px-3 py-2 rounded-[8px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)]" + /> + <textarea + value={draftPrompt} + onChange={(e) => setDraftPrompt(e.target.value)} + rows={3} + className="w-full bg-transparent text-[13px] text-[var(--text-2)] px-3 py-2 rounded-[8px] border border-[var(--line)] outline-none focus:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] resize-y" + /> + </div> + ) : ( + <> + <h3 className="text-[15px] font-medium text-[var(--text)] leading-snug"> + {req.title} + </h3> + {req.prompt && ( + <p className="mt-1.5 text-[13px] text-[var(--text-2)] leading-snug line-clamp-2"> + {req.prompt} + </p> + )} + </> + )} + + <div className="flex items-center gap-2 mt-4"> + {editing ? ( + <> + <button + type="button" + onClick={() => + patch({ + action: "edit", + title: draftTitle.trim(), + prompt: draftPrompt, + }) + } + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-semibold transition-colors" + style={{ + color: "var(--accent)", + border: + "1px solid color-mix(in srgb, var(--accent) 30%, transparent)", + background: "color-mix(in srgb, var(--accent) 10%, transparent)", + }} + > + <Check className="w-3.5 h-3.5" /> + Save + </button> + <button + type="button" + onClick={() => { + setEditing(false); + setDraftTitle(req.title); + setDraftPrompt(req.prompt ?? ""); + }} + className="btn-ghost inline-flex items-center gap-1.5 px-3.5 py-1.5 text-[12px] font-medium" + > + Cancel + </button> + </> + ) : ( + <> + <button + type="button" + onClick={() => patch({ action: "approve" })} + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-semibold transition-colors" + style={{ + color: "var(--up)", + border: "1px solid color-mix(in srgb, var(--up) 30%, transparent)", + background: "color-mix(in srgb, var(--up) 10%, transparent)", + }} + > + <Check className="w-3.5 h-3.5" /> + Approve + </button> + <button + type="button" + onClick={() => patch({ action: "reject" })} + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-medium transition-colors text-[var(--text-2)] hover:text-[var(--down)]" + style={{ border: "1px solid var(--line)" }} + > + <X className="w-3.5 h-3.5" /> + Reject + </button> + <button + type="button" + onClick={() => setEditing(true)} + className="inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[12px] font-medium transition-colors text-[var(--text-2)] hover:text-[var(--text)]" + style={{ border: "1px solid var(--line)" }} + > + <Pencil className="w-3.5 h-3.5" /> + Edit + </button> + </> + )} + </div> + </Panel> + ); +} + +// ── Main ────────────────────────────────────────────────── +export function ApprovalInbox({ compact = false }: { compact?: boolean }) { + const [requests, setRequests] = useState<Req[]>([]); + const [pending, setPending] = useState(0); + const [loaded, setLoaded] = useState(false); + + const load = useCallback(async () => { + const data = await getJSON<{ requests: Req[]; pending: number }>( + "/api/hermes/requests?status=awaiting_approval&take=50" + ); + if (data) { + setRequests(data.requests ?? []); + setPending(data.pending ?? data.requests?.length ?? 0); + } + setLoaded(true); + }, []); + + useEffect(() => { + load(); + const iv = setInterval(load, 6000); + return () => clearInterval(iv); + }, [load]); + + // optimistic removal, then refetch to reconcile + const handleAction = useCallback( + (id: string) => { + setRequests((prev) => prev.filter((r) => r.id !== id)); + setPending((p) => Math.max(0, p - 1)); + load(); + }, + [load] + ); + + const count = pending || requests.length; + const visible = compact ? requests.slice(0, 3) : requests; + + return ( + <div> + {/* Header */} + <div className="flex items-center justify-between gap-3 mb-4"> + <Eyebrow>Approval inbox</Eyebrow> + <Pill tone={count > 0 ? "accent" : "neutral"}> + {count} pending + </Pill> + </div> + + {loaded && requests.length === 0 ? ( + <Panel className="p-2"> + <EmptyState + icon={<Check className="w-6 h-6" style={{ color: "var(--up)" }} />} + title="Nothing needs you right now — you're clear." + hint="Side-effecting work waiting on your call will land here." + /> + </Panel> + ) : requests.length === 0 ? ( + // pre-load: keep it calm, mirror empty framing + <Panel className="p-2"> + <EmptyState + icon={<Inbox className="w-6 h-6" />} + title="Checking the queue…" + /> + </Panel> + ) : ( + <div className={`flex flex-col ${compact ? "gap-2.5" : "gap-4"}`}> + {visible.map((req) => ( + <InboxCard + key={req.id} + req={req} + compact={compact} + onAction={() => handleAction(req.id)} + /> + ))} + {compact && count > 3 && ( + <a + href="/hermes" + className="inline-flex items-center gap-1 self-start text-[12.5px] font-medium transition-colors" + style={{ color: "var(--accent)" }} + > + View all in Hermes → + </a> + )} + </div> + )} + </div> + ); +} diff --git a/src/components/breadcrumbs.tsx b/src/components/breadcrumbs.tsx new file mode 100644 index 0000000..46e72f2 --- /dev/null +++ b/src/components/breadcrumbs.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const pathLabels: Record<string, string> = { + "/": "Dashboard", + "/x": "X", + "/x-content": "Tweets", + "/x-analytics": "X Analytics", + "/watchlist-radar": "Trend Radar", + "/youtube": "YouTube", + "/longform": "Longform", + "/articles": "Articles", + "/client-pulse": "Client Pulse", + "/agents": "Agents", + "/ideas": "Ideas", + "/garden": "Garden", + "/tasks": "Tasks", +}; + +export function Breadcrumbs() { + const pathname = usePathname(); + + // Don't show breadcrumbs on dashboard + if (pathname === "/") return null; + + const currentLabel = pathLabels[pathname] || "Page"; + + return ( + <div className="flex items-center gap-2 text-sm text-[var(--text-3)] mb-6"> + <Link + href="/" + className="hover:text-neutral-300 transition-colors" + > + Dashboard + </Link> + <span>/</span> + <span className="text-neutral-400">{currentLabel}</span> + </div> + ); +} diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx new file mode 100644 index 0000000..1469b68 --- /dev/null +++ b/src/components/command-palette.tsx @@ -0,0 +1,341 @@ +"use client"; + +/* ─────────────────────────────────────────────────────────── + Hermy HQ · Command Palette (⌘K / Ctrl-K) + Globally mounted. Fuzzy nav + dispatch-to-Hermes fallback. + Calm Luxury tokens, no external deps beyond lucide-react. + ─────────────────────────────────────────────────────────── */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + LayoutDashboard, + Twitter, + FileText, + Youtube, + Activity, + Bot, + Lightbulb, + Sprout, + ListChecks, + Sparkles, + CornerDownLeft, + Search, + Check, + type LucideIcon, +} from "lucide-react"; + +interface NavItem { + label: string; + href: string; + icon: LucideIcon; +} + +const NAV: NavItem[] = [ + { label: "Dashboard", href: "/", icon: LayoutDashboard }, + { label: "X", href: "/x", icon: Twitter }, + { label: "Articles", href: "/articles", icon: FileText }, + { label: "YouTube", href: "/youtube", icon: Youtube }, + { label: "Client Pulse", href: "/client-pulse", icon: Activity }, + { label: "Agents", href: "/agents", icon: Bot }, + { label: "Ideas", href: "/ideas", icon: Lightbulb }, + { label: "Garden", href: "/garden", icon: Sprout }, + { label: "Tasks", href: "/tasks", icon: ListChecks }, + { label: "Hermes", href: "/hermes", icon: Sparkles }, +]; + +type Row = + | { kind: "nav"; item: NavItem } + | { kind: "dispatch"; query: string }; + +export function CommandPalette() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const [dispatched, setDispatched] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + const listRef = useRef<HTMLDivElement>(null); + + // ── global open/close hotkey ────────────────────────────── + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) { + e.preventDefault(); + setOpen((o) => !o); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + // ── reset + focus when opening ──────────────────────────── + useEffect(() => { + if (open) { + setQuery(""); + setActive(0); + setDispatched(false); + // focus after paint so the trap works reliably + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + // ── lock scroll while open ──────────────────────────────── + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + // ── fuzzy (case-insensitive substring) nav filter ───────── + const navMatches = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return NAV; + return NAV.filter( + (n) => n.label.toLowerCase().includes(q) || n.href.toLowerCase().includes(q), + ); + }, [query]); + + // Build the flat row list. Dispatch shows as the last option whenever + // there's a query, or as the sole option when nothing matches nav. + const rows = useMemo<Row[]>(() => { + const out: Row[] = navMatches.map((item) => ({ kind: "nav", item })); + if (query.trim()) out.push({ kind: "dispatch", query: query.trim() }); + return out; + }, [navMatches, query]); + + // keep highlight in range as rows shrink/grow + useEffect(() => { + setActive((a) => (rows.length === 0 ? 0 : Math.min(a, rows.length - 1))); + }, [rows.length]); + + const close = useCallback(() => setOpen(false), []); + + const runDispatch = useCallback(async (q: string) => { + // Route anything that acts on the outside world to the Approval Inbox + // instead of running it immediately. + const sideEffecting = /\b(post|tweet|send|dm|message|reply|email|buy|purchase|order|pay|transfer|withdraw|deposit|trade|delete|remove|publish|schedule|book|cancel|unsubscribe)\b/i.test(q); + try { + await fetch("/api/hermes/dispatch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "oneshot", title: q, prompt: q, sideEffecting }), + }); + } catch { + /* non-blocking — dispatch is best-effort */ + } + }, []); + + const run = useCallback( + async (row: Row | undefined) => { + if (!row) return; + if (row.kind === "nav") { + close(); + router.push(row.item.href); + return; + } + // dispatch: confirm briefly, then jump to the Hermes page so you can + // watch it run in the Dispatches panel. + setDispatched(true); + await runDispatch(row.query); + setTimeout(() => { setOpen(false); router.push("/hermes"); }, 650); + }, + [close, router, runDispatch], + ); + + // ── keyboard nav within the palette ─────────────────────── + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((a) => (rows.length ? (a + 1) % rows.length : 0)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((a) => (rows.length ? (a - 1 + rows.length) % rows.length : 0)); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + run(rows[active]); + } + }; + + // scroll the active row into view + useEffect(() => { + if (!open || !listRef.current) return; + const el = listRef.current.querySelector<HTMLElement>(`[data-idx="${active}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, [active, open]); + + if (!open) return null; + + const navRows = rows.filter((r) => r.kind === "nav") as Extract<Row, { kind: "nav" }>[]; + const dispatchRow = rows.find((r) => r.kind === "dispatch") as + | Extract<Row, { kind: "dispatch" }> + | undefined; + + return ( + <div + className="fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm flex items-start justify-center px-4 pt-[18vh] motion-safe:animate-[hq-rise_0.18s_ease-out]" + onMouseDown={(e) => { + if (e.target === e.currentTarget) close(); + }} + role="presentation" + > + <div + role="dialog" + aria-modal="true" + aria-label="Command palette" + className="elevated w-full max-w-[560px] overflow-hidden" + style={{ borderRadius: "var(--r-md)" }} + onKeyDown={onKeyDown} + > + {/* input */} + <div className="flex items-center gap-3 px-4 py-3.5 border-b border-[var(--line)]"> + <Search className="w-4 h-4 text-[var(--text-3)] shrink-0" /> + <input + ref={inputRef} + value={query} + onChange={(e) => { + setQuery(e.target.value); + setActive(0); + }} + placeholder="Search, or ask Hermes…" + spellCheck={false} + autoComplete="off" + className="num flex-1 bg-transparent border-0 outline-none text-[15px] text-[var(--text)] placeholder-[var(--text-3)]" + /> + </div> + + {/* results */} + <div ref={listRef} className="max-h-[52vh] overflow-y-auto py-2"> + {navRows.length > 0 && ( + <div className="px-2 pb-1"> + <div className="eyebrow px-3 py-1.5">Navigate</div> + {navRows.map((r) => { + const idx = rows.indexOf(r); + return ( + <PaletteRow + key={r.item.href} + idx={idx} + active={active === idx} + onHover={() => setActive(idx)} + onSelect={() => run(r)} + icon={<r.item.icon className="w-4 h-4" />} + > + <span className="text-[var(--text)]">{r.item.label}</span> + <span className="num ml-auto text-[11px] text-[var(--text-4)]"> + {r.item.href} + </span> + </PaletteRow> + ); + })} + </div> + )} + + {dispatchRow && ( + <div className="px-2 pt-1"> + <div className="eyebrow px-3 py-1.5">Dispatch to Hermes</div> + {(() => { + const idx = rows.indexOf(dispatchRow); + return ( + <PaletteRow + idx={idx} + active={active === idx} + onHover={() => setActive(idx)} + onSelect={() => run(dispatchRow)} + icon={ + dispatched ? ( + <Check className="w-4 h-4" style={{ color: "var(--up)" }} /> + ) : ( + <Sparkles className="w-4 h-4" style={{ color: "var(--accent)" }} /> + ) + } + > + {dispatched ? ( + <span style={{ color: "var(--up)" }}>Dispatched ✓</span> + ) : ( + <span className="text-[var(--text)] truncate"> + Ask Hermes:{" "} + <span className="text-[var(--text-2)]"> + “{dispatchRow.query}” + </span> + </span> + )} + </PaletteRow> + ); + })()} + </div> + )} + + {rows.length === 0 && ( + <div className="px-5 py-8 text-center text-[13px] text-[var(--text-3)]"> + No matches. + </div> + )} + </div> + + {/* hint bar */} + <div className="flex items-center gap-4 px-4 py-2.5 border-t border-[var(--line)] text-[11px] num text-[var(--text-3)]"> + <span className="inline-flex items-center gap-1.5"> + <CornerDownLeft className="w-3 h-3" /> to run + </span> + <span aria-hidden>·</span> + <span>esc to close</span> + <span className="ml-auto inline-flex items-center gap-1.5"> + <kbd className="rounded px-1.5 py-0.5 bg-[var(--surface-1)] border border-[var(--line)]"> + ↑↓ + </kbd> + to move + </span> + </div> + </div> + </div> + ); +} + +function PaletteRow({ + idx, + active, + onHover, + onSelect, + icon, + children, +}: { + idx: number; + active: boolean; + onHover: () => void; + onSelect: () => void; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( + <button + type="button" + data-idx={idx} + onMouseMove={onHover} + onClick={onSelect} + className="relative flex w-full items-center gap-3 rounded-[var(--r-sm)] px-3 py-2 text-left text-[13.5px] transition-colors" + style={active ? { background: "var(--surface-1)" } : undefined} + > + {active && ( + <span + aria-hidden + className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-[2.5px] rounded-full" + style={{ background: "var(--accent)" }} + /> + )} + <span className="text-[var(--text-3)] shrink-0">{icon}</span> + <span className="flex flex-1 items-center gap-2 min-w-0">{children}</span> + </button> + ); +} diff --git a/src/components/conditional-layout.tsx b/src/components/conditional-layout.tsx new file mode 100644 index 0000000..6f5413d --- /dev/null +++ b/src/components/conditional-layout.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { Sidebar } from '@/components/sidebar'; +import { CommandPalette } from '@/components/command-palette'; + +export function ConditionalLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const isStandalone = pathname === '/login'; + + if (isStandalone) { + return <>{children}</>; + } + + return ( + <div className="flex h-screen"> + {/* One app-level ambient at the root — outside the scrolling/transformed + main, so it's truly viewport-fixed and never scrolls away. */} + <div className="hq-ambient" aria-hidden /> + <Sidebar /> + <main className="relative flex-1 overflow-auto pt-16 md:pt-0 pb-20 md:pb-0 px-4 sm:px-6 md:px-10 lg:px-12 py-4 md:py-8 page-enter"> + <div className="pb-safe"> + {children} + </div> + </main> + <CommandPalette /> + </div> + ); +} diff --git a/src/components/donut-chart.tsx b/src/components/donut-chart.tsx new file mode 100644 index 0000000..6f232f8 --- /dev/null +++ b/src/components/donut-chart.tsx @@ -0,0 +1,78 @@ +"use client"; + +interface DonutChartProps { + percentage: number; + size?: number; + strokeWidth?: number; + centerText?: string; + centerSubtext?: string; + color?: string; + backgroundColor?: string; +} + +export function DonutChart({ + percentage, + size = 120, + strokeWidth = 8, + centerText, + centerSubtext, + color = "#10b981", // emerald-500 + backgroundColor = "#374151" // gray-600 +}: DonutChartProps) { + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const offset = circumference - (percentage / 100) * circumference; + const center = size / 2; + + return ( + <div className="relative inline-flex"> + <svg + width={size} + height={size} + className="transform -rotate-90" + > + {/* Background circle */} + <circle + cx={center} + cy={center} + r={radius} + stroke={backgroundColor} + strokeWidth={strokeWidth} + fill="none" + /> + {/* Progress circle */} + <circle + cx={center} + cy={center} + r={radius} + stroke={color} + strokeWidth={strokeWidth} + fill="none" + strokeDasharray={circumference} + strokeDashoffset={offset} + strokeLinecap="round" + className="transition-all duration-1000 ease-out" + style={{ + filter: 'drop-shadow(0 0 6px rgba(16, 185, 129, 0.3))' + }} + /> + </svg> + + {/* Center text */} + {(centerText || centerSubtext) && ( + <div className="absolute inset-0 flex flex-col items-center justify-center text-center"> + {centerText && ( + <div className="font-bold text-white leading-none" style={{ fontSize: Math.max(12, size * 0.18) }}> + {centerText} + </div> + )} + {centerSubtext && ( + <div className="text-neutral-400 mt-0.5" style={{ fontSize: Math.max(8, size * 0.11) }}> + {centerSubtext} + </div> + )} + </div> + )} + </div> + ); +} diff --git a/src/components/hermes-briefing.tsx b/src/components/hermes-briefing.tsx new file mode 100644 index 0000000..804d1c8 --- /dev/null +++ b/src/components/hermes-briefing.tsx @@ -0,0 +1,142 @@ +"use client"; + +/* ─────────────────────────────────────────────────────────── + Hermy HQ · Chief-of-Staff brief + Renders Hermes' daily brief (GET /api/hermes/briefing), a live + "needs you" chip, and a Generate-now button (POST → bridge runs it). + ─────────────────────────────────────────────────────────── */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Sunrise, RefreshCw, ArrowUpRight } from "lucide-react"; +import { Panel, Eyebrow, Button } from "@/components/ui/kit"; + +interface Section { label: string; items: string[] } +interface Briefing { + generatedAt: string | null; + greeting?: string | null; + summary: string | null; + sections?: Section[]; +} + +function timeAgo(d: string) { + const diff = Date.now() - new Date(d).getTime(); + const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), dy = Math.floor(diff / 86400000); + if (dy > 0) return `${dy}d ago`; + if (h > 0) return `${h}h ago`; + if (m > 0) return `${m}m ago`; + return "just now"; +} + +// tone a section by its intent +function sectionTone(label: string): string { + const l = label.toLowerCase(); + if (l.includes("decision") || l.includes("approv")) return "var(--warn)"; + if (l.includes("ship") || l.includes("done") || l.includes("win")) return "var(--up)"; + if (l.includes("next") || l.includes("priorit")) return "var(--accent)"; + return "var(--text-3)"; +} + +export function HermesBriefing() { + const [data, setData] = useState<Briefing | null>(null); + const [pending, setPending] = useState(0); + const [loaded, setLoaded] = useState(false); + const [generating, setGenerating] = useState(false); + const genAt = useRef<string | null>(null); + + const load = useCallback(async () => { + try { + const [b, r] = await Promise.all([ + fetch("/api/hermes/briefing").then((x) => (x.ok ? x.json() : null)), + fetch("/api/hermes/requests?status=awaiting_approval&take=1").then((x) => (x.ok ? x.json() : null)), + ]); + if (b) { + setData(b); + // stop the "generating" spinner once a fresh brief lands + if (generating && b.generatedAt && b.generatedAt !== genAt.current) setGenerating(false); + } + if (r) setPending(r.pending ?? 0); + } catch { /* ignore */ } + setLoaded(true); + }, [generating]); + + useEffect(() => { + load(); + const iv = setInterval(load, generating ? 6000 : 20000); + return () => clearInterval(iv); + }, [load, generating]); + + const generate = async () => { + genAt.current = data?.generatedAt ?? null; + setGenerating(true); + try { await fetch("/api/hermes/briefing", { method: "POST" }); } catch { /* ignore */ } + }; + + const empty = !data || !data.generatedAt || !data.summary; + + return ( + <Panel className="p-6"> + <div className="flex items-center justify-between gap-4 mb-3"> + <div className="flex items-center gap-2.5"> + <Sunrise className="w-4 h-4 text-[var(--accent)]" /> + <Eyebrow>Chief of Staff</Eyebrow> + {!empty && ( + <span className="num text-[11px] text-[var(--text-3)]">· {timeAgo(data!.generatedAt as string)}</span> + )} + </div> + <div className="flex items-center gap-2 shrink-0"> + {pending > 0 && ( + <a href="/hermes" className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium num" + style={{ color: "var(--warn)", background: "color-mix(in srgb, var(--warn) 12%, transparent)", border: "1px solid color-mix(in srgb, var(--warn) 24%, transparent)" }}> + {pending} need{pending === 1 ? "s" : ""} you <ArrowUpRight className="w-3 h-3" /> + </a> + )} + <Button variant="ghost" size="sm" onClick={generate} disabled={generating}> + <RefreshCw className={`w-3.5 h-3.5 ${generating ? "animate-spin" : ""}`} /> + {generating ? "Generating…" : "Generate"} + </Button> + </div> + </div> + + {empty ? ( + <div className="py-6 text-center"> + <p className="text-[14px] text-[var(--text-2)]"> + {generating ? "Hermes is writing your brief… (~1 min)" : loaded ? "No brief yet." : "Loading…"} + </p> + {!generating && loaded && ( + <p className="mt-1 text-[12.5px] text-[var(--text-3)]"> + It auto-generates each morning — or hit Generate to get one now. + </p> + )} + </div> + ) : ( + <> + {data!.greeting && ( + <p className="text-[15px] font-medium text-[var(--text)] mb-1.5">{data!.greeting}</p> + )} + <p className="text-[14px] leading-relaxed text-[var(--text-2)] max-w-[75ch]">{data!.summary}</p> + + {(data!.sections ?? []).length > 0 && ( + <div className="mt-6 grid sm:grid-cols-2 gap-x-8 gap-y-6"> + {data!.sections!.map((s, i) => ( + <div key={i}> + <div className="flex items-center gap-2 mb-2"> + <span className="w-1.5 h-1.5 rounded-full" style={{ background: sectionTone(s.label) }} /> + <Eyebrow className="!text-[9.5px]">{s.label}</Eyebrow> + </div> + <div> + {s.items.map((item, j) => ( + <div key={j} className="flex gap-2.5 py-1.5 border-b border-[var(--line)] last:border-0"> + <span className="text-[var(--text-4)] shrink-0 pt-0.5 text-[12px]">·</span> + <p className="flex-1 text-[13px] leading-snug text-[var(--text-2)]">{item}</p> + </div> + ))} + </div> + </div> + ))} + </div> + )} + </> + )} + </Panel> + ); +} diff --git a/src/components/hermes-dispatches.tsx b/src/components/hermes-dispatches.tsx new file mode 100644 index 0000000..6a2eca1 --- /dev/null +++ b/src/components/hermes-dispatches.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { SectionHeader, Panel, Pill, EmptyState } from "@/components/ui/kit"; +import { Send } from "lucide-react"; + +type Req = { + id: string; + origin: string; + kind: string; + title: string; + prompt: string | null; + sideEffecting: boolean; + status: string; + result: string | null; + error: string | null; + createdAt: string; + finishedAt: string | null; +}; + +function ago(d: string | null): string { + if (!d) return ""; + const s = Math.max(0, (Date.now() - new Date(d).getTime()) / 1000); + if (s < 60) return `${Math.floor(s)}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +const TONE: Record<string, "neutral" | "up" | "down" | "warn" | "accent"> = { + queued: "neutral", + awaiting_approval: "warn", + approved: "accent", + running: "accent", + done: "up", + failed: "down", + rejected: "neutral", +}; +const LABEL: Record<string, string> = { + queued: "Queued", + awaiting_approval: "Awaiting approval", + approved: "Approved", + running: "Running", + done: "Done", + failed: "Failed", + rejected: "Rejected", +}; + +export function HermesDispatches() { + const [reqs, setReqs] = useState<Req[]>([]); + const [loaded, setLoaded] = useState(false); + + const load = useCallback(async () => { + try { + const r = await fetch("/api/hermes/requests?take=15"); + if (r.ok) { const d = await r.json(); setReqs(d.requests ?? []); } + } catch { /* ignore */ } + setLoaded(true); + }, []); + + useEffect(() => { + load(); + const iv = setInterval(load, 5000); + return () => clearInterval(iv); + }, [load]); + + return ( + <div> + <SectionHeader label="Dispatches" title="What you've sent Hermes" /> + {!loaded ? ( + <Panel><div className="sk h-24 m-1 rounded-[10px]" /></Panel> + ) : reqs.length === 0 ? ( + <Panel> + <EmptyState + icon={<Send className="w-5 h-5" />} + title="No dispatches yet" + hint="Send a task with ⌘K or the bar above — it'll appear here with its live status and result." + /> + </Panel> + ) : ( + <div className="flex flex-col gap-2.5"> + {reqs.map((r) => { + const tone = TONE[r.status] ?? "neutral"; + const running = r.status === "running"; + return ( + <div key={r.id} className="panel p-4"> + <div className="flex items-center gap-3"> + {running && ( + <span className="relative flex w-1.5 h-1.5 shrink-0"> + <span className="absolute inline-flex h-full w-full rounded-full animate-ping" style={{ background: "color-mix(in srgb, var(--accent) 60%, transparent)" }} /> + <span className="relative inline-flex w-1.5 h-1.5 rounded-full" style={{ background: "var(--accent)" }} /> + </span> + )} + <p className="flex-1 min-w-0 text-[14px] text-[var(--text)] truncate">{r.title}</p> + <Pill tone={tone}>{LABEL[r.status] ?? r.status}</Pill> + <span className="num text-[11px] text-[var(--text-3)] shrink-0 w-16 text-right">{ago(r.createdAt)}</span> + </div> + {(r.result || r.error) && ( + <p className={`mt-2.5 text-[12.5px] leading-snug whitespace-pre-wrap line-clamp-4 ${r.error ? "text-[var(--down)]" : "text-[var(--text-2)]"}`}> + {r.error || r.result} + </p> + )} + </div> + ); + })} + </div> + )} + </div> + ); +} diff --git a/src/components/hermes-runs.tsx b/src/components/hermes-runs.tsx new file mode 100644 index 0000000..334a3d3 --- /dev/null +++ b/src/components/hermes-runs.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Activity, ChevronRight, Gauge } from "lucide-react"; +import { Panel, SectionHeader, Pill, EmptyState, Eyebrow } from "@/components/ui/kit"; + +// ── Types ───────────────────────────────────────────────── +type RunStatus = + | "queued" + | "awaiting_approval" + | "approved" + | "running" + | "done" + | "failed" + | "rejected"; + +interface Req { + id: string; + origin: string; + kind: string; + title: string; + prompt: string | null; + sideEffecting: boolean; + status: RunStatus; + result: string | null; + error: string | null; + createdAt: string; + startedAt: string | null; + finishedAt: string | null; +} + +interface ModelUsage { + model: string; + tokens?: number; + cost?: number; + calls?: number; +} + +interface Cost { + summary?: string | null; + byModel?: ModelUsage[]; + totalCost?: number | null; + totalTokens?: number | null; + syncedAt?: string | null; +} + +// ── Helpers ─────────────────────────────────────────────── +function timeAgo(d: string | null): string { + if (!d) return "—"; + const diff = Date.now() - new Date(d).getTime(); + if (Number.isNaN(diff)) return "—"; + const s = Math.floor(diff / 1000); + if (s < 45) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const days = Math.floor(h / 24); + return `${days}d ago`; +} + +function duration(start: string | null, finish: string | null): string { + if (!start || !finish) return "—"; + const ms = new Date(finish).getTime() - new Date(start).getTime(); + if (Number.isNaN(ms) || ms < 0) return "—"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const totalS = Math.round(ms / 1000); + const m = Math.floor(totalS / 60); + const s = totalS % 60; + return `${m}m ${String(s).padStart(2, "0")}s`; +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`; + return n.toLocaleString("en-US"); +} +function fmtUsd(n: number): string { + return `$${n.toLocaleString("en-US", { + minimumFractionDigits: n < 100 ? 2 : 0, + maximumFractionDigits: n < 100 ? 2 : 0, + })}`; +} + +async function getJSON<T>(url: string): Promise<T | null> { + try { + const r = await fetch(url); + if (!r.ok) return null; + return (await r.json()) as T; + } catch { + return null; + } +} + +function usePrefersReducedMotion(): boolean { + const [reduce, setReduce] = useState(false); + useEffect(() => { + const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!mq) return; + const on = () => setReduce(mq.matches); + on(); + mq.addEventListener?.("change", on); + return () => mq.removeEventListener?.("change", on); + }, []); + return reduce; +} + +// ── Status → tone / dot ─────────────────────────────────── +type Tone = "neutral" | "up" | "down" | "warn" | "accent"; +const STATUS_TONE: Record<RunStatus, Tone> = { + queued: "neutral", + awaiting_approval: "warn", + approved: "accent", + running: "accent", + done: "up", + failed: "down", + rejected: "neutral", +}; +const STATUS_LABEL: Record<RunStatus, string> = { + queued: "Queued", + awaiting_approval: "Awaiting approval", + approved: "Approved", + running: "Running", + done: "Done", + failed: "Failed", + rejected: "Rejected", +}; +function toneVar(t: Tone): string { + return t === "neutral" ? "var(--text-3)" : `var(--${t})`; +} + +// ── Status dot ──────────────────────────────────────────── +function StatusDot({ status, reduce }: { status: RunStatus; reduce: boolean }) { + const tone = STATUS_TONE[status]; + const color = toneVar(tone); + const pulse = status === "running" && !reduce; + return ( + <span className="relative flex w-1.5 h-1.5 shrink-0"> + {pulse && ( + <span + className="absolute inline-flex h-full w-full rounded-full animate-ping" + style={{ background: `color-mix(in srgb, ${color} 60%, transparent)` }} + /> + )} + <span + className="relative inline-flex w-1.5 h-1.5 rounded-full" + style={{ background: color }} + /> + </span> + ); +} + +// ── Usage strip ─────────────────────────────────────────── +function UsageStrip({ cost }: { cost: Cost | null }) { + const byModel = cost?.byModel ?? []; + const hasAny = + !!cost && + (!!cost.summary || + byModel.length > 0 || + cost.totalCost != null || + cost.totalTokens != null); + + if (!hasAny) { + return ( + <Panel className="p-5"> + <p className="text-[13px] text-[var(--text-3)] flex items-center gap-2"> + <Gauge className="w-3.5 h-3.5" /> + Usage syncing from Hermes… + </p> + </Panel> + ); + } + + // Bars are relative to the largest per-model magnitude (tokens, else cost, else calls). + const magnitude = (m: ModelUsage) => m.tokens ?? m.cost ?? m.calls ?? 0; + const max = byModel.reduce((mx, m) => Math.max(mx, magnitude(m)), 0) || 1; + + return ( + <Panel className="p-5"> + <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> + <div> + <Eyebrow>Total cost</Eyebrow> + <div className="num font-semibold text-[26px] tracking-[-0.02em] text-[var(--text)] leading-none mt-2"> + {cost?.totalCost != null ? fmtUsd(cost.totalCost) : "—"} + </div> + </div> + <div> + <Eyebrow>Total tokens</Eyebrow> + <div className="num font-semibold text-[26px] tracking-[-0.02em] text-[var(--text)] leading-none mt-2"> + {cost?.totalTokens != null ? fmtTokens(cost.totalTokens) : "—"} + </div> + </div> + <div> + <Eyebrow>Synced</Eyebrow> + <div className="num text-[13px] text-[var(--text-2)] mt-2.5"> + {timeAgo(cost?.syncedAt ?? null)} + </div> + </div> + </div> + + {cost?.summary && ( + <p className="mt-4 text-[12.5px] text-[var(--text-2)] leading-snug"> + {cost.summary} + </p> + )} + + {byModel.length > 0 && ( + <div className="mt-5 pt-4 border-t border-[var(--line)] flex flex-col gap-2.5"> + <Eyebrow>By model</Eyebrow> + {byModel.map((m) => { + const val = magnitude(m); + const pct = Math.max(3, Math.round((val / max) * 100)); + return ( + <div key={m.model} className="flex items-center gap-3"> + <span className="text-[12px] text-[var(--text-2)] w-40 shrink-0 truncate"> + {m.model} + </span> + <div className="flex-1 h-[6px] rounded-full bg-[var(--surface-2)] overflow-hidden"> + <div + className="h-full rounded-full" + style={{ + width: `${pct}%`, + background: "color-mix(in srgb, var(--accent) 70%, transparent)", + }} + /> + </div> + <span className="num text-[11px] text-[var(--text-3)] shrink-0 w-24 text-right"> + {m.tokens != null + ? `${fmtTokens(m.tokens)} tok` + : m.cost != null + ? fmtUsd(m.cost) + : m.calls != null + ? `${m.calls} calls` + : "—"} + </span> + </div> + ); + })} + </div> + )} + </Panel> + ); +} + +// ── Run row ─────────────────────────────────────────────── +function RunRow({ run, reduce }: { run: Req; reduce: boolean }) { + const [open, setOpen] = useState(false); + const tone = STATUS_TONE[run.status]; + const body = run.error || run.result; + const canExpand = !!body; + const dur = duration(run.startedAt, run.finishedAt); + + return ( + <div className="px-3.5 py-3"> + <button + type="button" + onClick={() => canExpand && setOpen((o) => !o)} + className={`w-full flex items-center gap-3 text-left ${ + canExpand ? "cursor-pointer" : "cursor-default" + }`} + aria-expanded={canExpand ? open : undefined} + > + <StatusDot status={run.status} reduce={reduce} /> + <div className="min-w-0 flex-1"> + <p className="text-[13.5px] text-[var(--text)] leading-snug truncate"> + {run.title} + </p> + <div className="flex items-center gap-2 mt-1"> + <span className="text-[11px] text-[var(--text-3)] truncate">{run.kind}</span> + <Pill tone={tone} className="!py-0.5 !text-[10px]"> + {STATUS_LABEL[run.status]} + </Pill> + </div> + </div> + <div className="shrink-0 flex items-center gap-4 text-right"> + <div> + <div className="num text-[12px] text-[var(--text-2)]">{dur}</div> + <div className="num text-[10.5px] text-[var(--text-3)] mt-0.5"> + {timeAgo(run.finishedAt || run.startedAt || run.createdAt)} + </div> + </div> + {canExpand && ( + <ChevronRight + className="w-3.5 h-3.5 text-[var(--text-3)] transition-transform" + style={{ transform: open ? "rotate(90deg)" : "none" }} + /> + )} + </div> + </button> + {open && body && ( + <p + className="mt-3 ml-[18px] text-[12.5px] leading-snug whitespace-pre-wrap rounded-[8px] border border-[var(--line)] bg-[var(--surface-2)] p-3" + style={{ color: run.error ? "var(--down)" : "var(--text-2)" }} + > + {body} + </p> + )} + </div> + ); +} + +// ── Run history ─────────────────────────────────────────── +type Filter = "all" | "running" | "done" | "failed"; +const FILTERS: { key: Filter; label: string }[] = [ + { key: "all", label: "All" }, + { key: "running", label: "running" }, + { key: "done", label: "done" }, + { key: "failed", label: "failed" }, +]; + +function RunHistory({ + runs, + loaded, + reduce, +}: { + runs: Req[]; + loaded: boolean; + reduce: boolean; +}) { + const [filter, setFilter] = useState<Filter>("all"); + const shown = filter === "all" ? runs : runs.filter((r) => r.status === filter); + + const count = (f: Filter) => + f === "all" ? runs.length : runs.filter((r) => r.status === f).length; + + return ( + <> + <SectionHeader + label="Run history" + title="Recent runs" + action={ + <div className="flex items-center gap-1 rounded-lg border border-[var(--line)] p-0.5"> + {FILTERS.map((f) => { + const active = filter === f.key; + return ( + <button + key={f.key} + type="button" + onClick={() => setFilter(f.key)} + className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-colors ${ + active + ? "bg-[var(--surface-2)] text-[var(--text)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)]" + }`} + > + {f.label} + <span className="num text-[var(--text-3)] ml-1">{count(f.key)}</span> + </button> + ); + })} + </div> + } + /> + {!loaded ? ( + <Panel className="p-2"> + <div className="sk h-40 m-1 rounded-[10px]" /> + </Panel> + ) : shown.length === 0 ? ( + <Panel className="p-2"> + <EmptyState + icon={<Activity className="w-6 h-6" />} + title={filter === "all" ? "No runs yet" : `No ${filter} runs`} + hint="Runs dispatched to Hermes will show up here with duration and results." + /> + </Panel> + ) : ( + <Panel className="p-2"> + <div className="divide-y divide-[var(--line)]"> + {shown.map((r) => ( + <RunRow key={r.id} run={r} reduce={reduce} /> + ))} + </div> + </Panel> + )} + </> + ); +} + +// ── Main ────────────────────────────────────────────────── +export function HermesRuns() { + const [runs, setRuns] = useState<Req[]>([]); + const [cost, setCost] = useState<Cost | null>(null); + const [loaded, setLoaded] = useState(false); + const reduce = usePrefersReducedMotion(); + const mounted = useRef(true); + + const load = useCallback(async () => { + const [reqs, c] = await Promise.all([ + getJSON<{ requests: Req[]; pending: number }>("/api/hermes/requests?take=60"), + getJSON<Cost>("/api/hermes/cost"), + ]); + if (!mounted.current) return; + if (reqs) setRuns(reqs.requests ?? []); + if (c) setCost(c); + setLoaded(true); + }, []); + + useEffect(() => { + mounted.current = true; + load(); + const iv = setInterval(load, 8000); + return () => { + mounted.current = false; + clearInterval(iv); + }; + }, [load]); + + return ( + <div className="flex flex-col gap-8"> + <div> + <Eyebrow>Observability</Eyebrow> + <h2 className="mt-2 text-[24px] font-semibold tracking-[-0.02em] leading-none text-[var(--text)]"> + Runs & usage + </h2> + </div> + + <UsageStrip cost={cost} /> + + <div> + <RunHistory runs={runs} loaded={loaded} reduce={reduce} /> + </div> + </div> + ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx new file mode 100644 index 0000000..10bb4ab --- /dev/null +++ b/src/components/sidebar.tsx @@ -0,0 +1,233 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState, useEffect } from "react"; +import { + Home, + Twitter, + Play, + Bot, + Lightbulb, + Flower2, + FileText, + ClipboardList, + HeartPulse, + Cpu, + BookOpen, + Workflow, + Menu, + X, +} from "lucide-react"; + +const navGroups = [ + { + name: "Overview", + items: [ + { href: "/", label: "Dashboard", icon: Home }, + { href: "/hermes", label: "Hermes", icon: Cpu }, + { href: "/tasks", label: "Tasks", icon: ClipboardList }, + ], + }, + { + name: "Content", + items: [ + { href: "/x", label: "X", icon: Twitter }, + { href: "/content-os", label: "Pipeline", icon: Workflow }, + { href: "/articles", label: "Articles", icon: FileText }, + { href: "/youtube", label: "YouTube", icon: Play }, + ], + }, + { + name: "Data", + items: [ + { href: "/client-pulse", label: "Client Pulse", icon: HeartPulse }, + ], + }, + { + name: "System", + items: [ + { href: "/agents", label: "Agents", icon: Bot }, + { href: "/memory-wiki", label: "Memory Wiki", icon: BookOpen }, + { href: "/ideas", label: "Ideas", icon: Lightbulb }, + { href: "/garden", label: "Garden", icon: Flower2 }, + ], + }, +]; + +// Mobile tab bar - only show the 5 most important +const mobileTabsRaw = [ + { href: "/", label: "Dashboard", icon: Home }, + { href: "/x", label: "X", icon: Twitter }, + { href: "/youtube", label: "YouTube", icon: Play }, + { href: "/ideas", label: "Ideas", icon: Lightbulb }, + { href: "/agents", label: "Agents", icon: Bot }, +]; + +export function Sidebar() { + const pathname = usePathname(); + const [isOpen, setIsOpen] = useState(false); + + // Close sidebar on route change (mobile) + useEffect(() => { + const frame = requestAnimationFrame(() => setIsOpen(false)); + return () => cancelAnimationFrame(frame); + }, [pathname]); + + // Close sidebar when resizing to desktop + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 768) setIsOpen(false); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const Logo = () => ( + <div className="flex items-center gap-2.5"> + <div className="w-8 h-8 rounded-[10px] bg-[var(--text)] flex items-center justify-center shadow-[inset_0_1px_0_rgba(255,255,255,0.4)]"> + <span className="text-[#0a0b0d] font-bold text-[13px] tracking-tight">H</span> + </div> + <span className="font-semibold text-[var(--text)] tracking-[-0.01em] text-[15px]">Hermy HQ</span> + </div> + ); + + return ( + <> + {/* Mobile header */} + <div className="md:hidden fixed top-0 left-0 right-0 z-50 bg-[var(--bg)]/90 backdrop-blur-xl border-b border-[var(--line)] px-4 py-3 flex items-center justify-between"> + <Logo /> + <button + onClick={() => setIsOpen(!isOpen)} + className="p-2 text-[var(--text-2)] hover:text-[var(--text)] transition-colors rounded-lg hover:bg-[var(--surface-1)]" + aria-label="Toggle menu" + > + {isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />} + </button> + </div> + + {/* Mobile bottom tab bar */} + <div className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-[var(--bg)]/90 backdrop-blur-xl border-t border-[var(--line)] px-2 py-2 safe-area-pb"> + <nav className="flex justify-around"> + {mobileTabsRaw.map((item) => { + const isActive = pathname === item.href; + const Icon = item.icon; + return ( + <Link + key={item.href} + href={item.href} + className={`flex flex-col items-center gap-1 p-2 px-3 rounded-lg transition-all ${ + isActive + ? "text-[var(--text)] bg-[var(--surface-2)]" + : "text-[var(--text-3)] hover:text-[var(--text-2)] active:scale-95" + }`} + > + <Icon className="w-5 h-5" /> + <span className="text-[10px] font-medium">{item.label}</span> + </Link> + ); + })} + </nav> + </div> + + {/* Mobile overlay */} + {isOpen && ( + <div + className="md:hidden fixed inset-0 bg-black/60 z-40" + onClick={() => setIsOpen(false)} + /> + )} + + {/* Desktop Sidebar */} + <aside + className={` + fixed md:relative z-50 md:z-10 + w-64 md:w-[15rem] h-full + bg-[var(--bg)] md:bg-transparent border-r border-[var(--line)] + flex flex-col + transition-transform duration-300 ease-in-out + ${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} + top-0 left-0 + `} + > + {/* Logo */} + <div className="hidden md:block px-5 pt-6 pb-8"> + <Logo /> + </div> + + {/* Spacer for mobile header */} + <div className="h-16 md:hidden" /> + + {/* Nav */} + <nav className="flex-1 px-3 overflow-y-auto"> + <div className="space-y-5"> + {navGroups.map((group) => ( + <div key={group.name}> + <h3 className="eyebrow px-3 mb-1.5 !text-[10px] !text-[var(--text-4)]"> + {group.name} + </h3> + <div className="space-y-0.5"> + {group.items.map((item) => { + const isActive = + pathname === item.href || pathname.startsWith(item.href + "/"); + const Icon = item.icon; + return ( + <div key={item.href}> + <Link + href={item.href} + className={`group relative flex items-center gap-3 px-3 py-[7px] rounded-[10px] transition-all duration-150 ${ + isActive + ? "bg-[var(--surface-2)] text-[var(--text)]" + : "text-[var(--text-2)] hover:text-[var(--text)] hover:bg-[var(--surface-1)]" + }`} + > + {isActive && ( + <span className="absolute left-0 top-1/2 -translate-y-1/2 h-4 w-[2px] rounded-full bg-[var(--accent)]" /> + )} + <Icon + className={`w-[17px] h-[17px] shrink-0 ${ + isActive ? "text-[var(--text)]" : "text-[var(--text-3)] group-hover:text-[var(--text-2)]" + }`} + /> + <span className="text-[13.5px] font-medium">{item.label}</span> + </Link> + {"anchors" in group && + isActive && + (group as { anchors?: { href: string; label: string }[] }).anchors && ( + <div className="ml-[26px] mt-0.5 space-y-0.5 border-l border-[var(--line)] pl-3"> + {(group as { anchors: { href: string; label: string }[] }).anchors.map( + (a) => ( + <a + key={a.href} + href={a.href} + className="block text-[12px] text-[var(--text-3)] hover:text-[var(--text-2)] py-1 transition-colors" + > + {a.label} + </a> + ) + )} + </div> + )} + </div> + ); + })} + </div> + </div> + ))} + </div> + </nav> + + {/* Footer */} + <div className="px-4 py-4 border-t border-[var(--line)]"> + <div className="flex items-center gap-2 text-[var(--text-3)] text-[11.5px]"> + <span className="relative flex w-1.5 h-1.5"> + <span className="absolute inline-flex h-full w-full rounded-full bg-[var(--up)] opacity-60 animate-ping" /> + <span className="relative inline-flex w-1.5 h-1.5 rounded-full bg-[var(--up)]" /> + </span> + <span>All systems online</span> + </div> + </div> + </aside> + </> + ); +} diff --git a/src/components/sparkline.tsx b/src/components/sparkline.tsx new file mode 100644 index 0000000..910a8f6 --- /dev/null +++ b/src/components/sparkline.tsx @@ -0,0 +1,74 @@ +interface SparklineProps { + data: number[]; + positive?: boolean; + /** Explicit stroke color; overrides `positive`. */ + color?: string; + /** Render a soft gradient area under the line. */ + area?: boolean; + className?: string; + /** A stable id seed so multiple sparklines don't share a gradient def. */ + idSeed?: string; +} + +export function Sparkline({ data, positive = true, color, area = false, className = "", idSeed = "" }: SparklineProps) { + if (!data || data.length === 0) return null; + + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min; + + const coords = data.map((value, index) => { + const x = data.length === 1 ? 0 : (index / (data.length - 1)) * 100; + // pad vertically so the line never hugs the very edge + const y = range === 0 ? 50 : 92 - ((value - min) / range) * 84; + return { x, y }; + }); + + const points = coords.map(c => `${c.x},${c.y}`).join(" "); + const strokeColor = color ?? (positive ? "#10b981" : "#ef4444"); + const gid = `spark-${idSeed || strokeColor.replace("#", "")}-${data.length}`; + + const areaPath = area + ? `M ${coords[0].x},100 L ${coords.map(c => `${c.x},${c.y}`).join(" L ")} L ${coords[coords.length - 1].x},100 Z` + : null; + + const last = coords[coords.length - 1]; + + return ( + <div className={`relative w-full h-10 ${className}`}> + <svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none" className="overflow-visible"> + {area && ( + <defs> + <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1"> + <stop offset="0%" stopColor={strokeColor} stopOpacity="0.28" /> + <stop offset="100%" stopColor={strokeColor} stopOpacity="0" /> + </linearGradient> + </defs> + )} + {areaPath && <path d={areaPath} fill={`url(#${gid})`} stroke="none" />} + <polyline + points={points} + fill="none" + stroke={strokeColor} + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + vectorEffect="non-scaling-stroke" + /> + </svg> + {/* emphasized endpoint dot — overlaid so it stays a true circle */} + <span + className="absolute rounded-full" + style={{ + left: `${last.x}%`, + top: `${last.y}%`, + width: 6, + height: 6, + transform: "translate(-50%, -50%)", + background: strokeColor, + boxShadow: `0 0 0 3px color-mix(in srgb, ${strokeColor} 20%, transparent)`, + }} + /> + </div> + ); +} diff --git a/src/components/ui/kit.tsx b/src/components/ui/kit.tsx new file mode 100644 index 0000000..4960920 --- /dev/null +++ b/src/components/ui/kit.tsx @@ -0,0 +1,231 @@ +"use client"; + +/* ─────────────────────────────────────────────────────────── + Hermy HQ · Calm Luxury primitive kit + Shared building blocks so every page inherits one system. + Import from "@/components/ui/kit". + ─────────────────────────────────────────────────────────── */ + +import { useEffect, useRef, useState } from "react"; +import { ArrowUpRight, ArrowDownRight } from "lucide-react"; + +/* ── motion: count-up (first paint only, reduced-motion aware) ── */ +export function useCountUp(target: number, duration = 900, enabled = true) { + const [val, setVal] = useState(target); + const raf = useRef<number | null>(null); + const done = useRef(false); + useEffect(() => { + if (done.current) { setVal(target); return; } + const reduce = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (!enabled || target === 0 || reduce) { setVal(target); done.current = true; return; } + const start = performance.now(); + const from = 0; + const tick = (now: number) => { + const t = Math.min((now - start) / duration, 1); + const ease = 1 - Math.pow(1 - t, 4); + setVal(from + (target - from) * ease); + if (t < 1) raf.current = requestAnimationFrame(tick); + else done.current = true; + }; + raf.current = requestAnimationFrame(tick); + return () => { if (raf.current) cancelAnimationFrame(raf.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target]); + return val; +} + +/* ── entrance helper: staggered rise delay ── */ +export const rise = (i: number): React.CSSProperties => ({ + animationDelay: `${Math.min(i, 12) * 45}ms`, +}); + +/* ── Eyebrow ── */ +export function Eyebrow({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return <span className={`eyebrow ${className}`}>{children}</span>; +} + +/* ── SectionHeader — eyebrow + optional action, hairline underline ── */ +export function SectionHeader({ + label, + title, + action, + className = "", +}: { + label?: string; + title?: React.ReactNode; + action?: React.ReactNode; + className?: string; +}) { + return ( + <div className={`mb-4 ${className}`}> + <div className="flex items-end justify-between gap-4"> + <div className="min-w-0"> + {label && <Eyebrow>{label}</Eyebrow>} + {title && ( + <h2 className="mt-1 text-[19px] font-semibold tracking-[-0.015em] text-[var(--text)] truncate"> + {title} + </h2> + )} + </div> + {action && <div className="shrink-0">{action}</div>} + </div> + <div className="rule mt-3" /> + </div> + ); +} + +/* ── Panel — the calm luxury card material ── */ +export function Panel({ + children, + interactive = false, + href, + className = "", + style, +}: { + children: React.ReactNode; + interactive?: boolean; + href?: string; + className?: string; + style?: React.CSSProperties; +}) { + const cls = `panel ${interactive || href ? "panel-interactive" : ""} ${className}`; + if (href) { + return ( + <a href={href} className={`block ${cls}`} style={style}> + {children} + </a> + ); + } + return <div className={cls} style={style}>{children}</div>; +} + +/* ── Delta pill — semantic up/down/flat ── */ +const UP = "var(--up)"; +const DOWN = "var(--down)"; +const FLAT = "var(--text-3)"; + +export function Delta({ + value, + pct, + format, + className = "", +}: { + value?: number | null; + pct?: number | null; + format?: (n: number) => string; + className?: string; +}) { + const has = (value !== null && value !== undefined) || (pct !== null && pct !== undefined); + if (!has) return null; + const basis = pct ?? value ?? 0; + const up = basis > 0; + const down = basis < 0; + const color = up ? UP : down ? DOWN : FLAT; + const Icon = down ? ArrowDownRight : ArrowUpRight; + const text = + pct !== null && pct !== undefined + ? `${Math.abs(pct).toFixed(Math.abs(pct) < 10 && pct !== 0 ? 1 : 0)}%` + : format + ? format(Math.abs(value as number)) + : Math.abs(value as number).toLocaleString("en-US"); + return ( + <span + className={`inline-flex items-center gap-0.5 num text-[12px] font-semibold ${className}`} + style={{ color }} + > + {(up || down) && <Icon className="w-3.5 h-3.5" strokeWidth={2.25} />} + {text} + </span> + ); +} + +/* ── Button ── */ +export function Button({ + children, + variant = "ghost", + size = "md", + onClick, + href, + type = "button", + disabled, + className = "", +}: { + children: React.ReactNode; + variant?: "primary" | "ghost"; + size?: "sm" | "md"; + onClick?: () => void; + href?: string; + type?: "button" | "submit"; + disabled?: boolean; + className?: string; +}) { + const pad = size === "sm" ? "px-3 py-1.5 text-[12px]" : "px-4 py-2 text-[13px]"; + const cls = `inline-flex items-center justify-center gap-1.5 font-medium ${pad} ${ + variant === "primary" ? "btn-primary" : "btn-ghost" + } ${disabled ? "opacity-40 pointer-events-none" : ""} ${className}`; + if (href) return <a href={href} className={cls}>{children}</a>; + return ( + <button type={type} onClick={onClick} disabled={disabled} className={cls}> + {children} + </button> + ); +} + +/* ── Skeleton ── */ +export function Skeleton({ className = "" }: { className?: string }) { + return <div className={`sk ${className}`} />; +} + +/* ── EmptyState ── */ +export function EmptyState({ + icon, + title, + hint, + action, + className = "", +}: { + icon?: React.ReactNode; + title: string; + hint?: string; + action?: React.ReactNode; + className?: string; +}) { + return ( + <div className={`flex flex-col items-center justify-center text-center py-14 px-6 ${className}`}> + {icon && <div className="mb-3 text-[var(--text-3)]">{icon}</div>} + <p className="text-[14px] font-medium text-[var(--text-2)]">{title}</p> + {hint && <p className="mt-1 text-[12.5px] text-[var(--text-3)] max-w-xs">{hint}</p>} + {action && <div className="mt-4">{action}</div>} + </div> + ); +} + +/* ── Pill — small status/label chip ── */ +export function Pill({ + children, + tone = "neutral", + className = "", +}: { + children: React.ReactNode; + tone?: "neutral" | "up" | "down" | "warn" | "accent"; + className?: string; +}) { + const map: Record<string, string> = { + neutral: "var(--text-3)", + up: "var(--up)", + down: "var(--down)", + warn: "var(--warn)", + accent: "var(--accent)", + }; + const c = map[tone]; + return ( + <span + className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ${className}`} + style={{ color: c, background: `color-mix(in srgb, ${c} 12%, transparent)`, border: `1px solid color-mix(in srgb, ${c} 22%, transparent)` }} + > + {children} + </span> + ); +} diff --git a/src/components/ui/metric-card.tsx b/src/components/ui/metric-card.tsx new file mode 100644 index 0000000..7c36a20 --- /dev/null +++ b/src/components/ui/metric-card.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ArrowUpRight, ArrowDownRight } from "lucide-react"; +import { Sparkline } from "@/components/sparkline"; + +// ── Reduced-motion-aware count-up ───────────────────────── +function useCountUp(target: number, duration = 1400, enabled = true) { + const [val, setVal] = useState(0); + const raf = useRef<number | null>(null); + useEffect(() => { + const reduce = typeof window !== "undefined" + && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (!enabled || target === 0 || reduce) { setVal(target); return; } + const start = Date.now(); + const tick = () => { + const t = Math.min((Date.now() - start) / duration, 1); + const ease = 1 - Math.pow(1 - t, 4); + setVal(Math.round(target * ease)); + if (t < 1) raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => { if (raf.current) cancelAnimationFrame(raf.current); }; + }, [target, duration, enabled]); + return val; +} + +const UP = "#34d399"; +const DOWN = "#fb7185"; +const GHOST = "#52525b"; + +export interface MetricCardProps { + label: string; + value: number; + format?: (n: number) => string; + delta?: number | null; + deltaPct?: number | null; + deltaLabel?: string; + trend?: number[]; + goal?: number; + goalFormat?: (n: number) => string; + icon?: React.ReactNode; + accent?: string; + href?: string; + loaded?: boolean; + className?: string; +} + +export function MetricCard({ + label, value, format = (n) => n.toLocaleString("en-US"), + delta = null, deltaPct = null, deltaLabel, trend, goal, goalFormat, + icon, accent = "#a1a1aa", href, loaded = true, className = "", +}: MetricCardProps) { + const counted = useCountUp(value, 1600, loaded); + + const hasDelta = delta !== null && delta !== undefined; + const up = hasDelta && (delta as number) > 0; + const down = hasDelta && (delta as number) < 0; + const deltaColor = up ? UP : down ? DOWN : GHOST; + const DeltaIcon = down ? ArrowDownRight : ArrowUpRight; + + const series = trend && trend.length >= 2 ? trend : null; + const pct = goal && goal > 0 ? Math.min((value / goal) * 100, 100) : null; + + const inner = ( + <div className={`panel panel-interactive flex flex-col p-6 h-full ${className}`}> + {/* label row */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 min-w-0"> + <span style={{ color: accent }} className="shrink-0 opacity-80">{icon}</span> + <span className="eyebrow truncate">{label}</span> + </div> + {hasDelta && ( + <span + className="flex items-center gap-0.5 text-[12.5px] font-semibold num" + style={{ color: deltaColor }} + > + <DeltaIcon className="w-3.5 h-3.5" strokeWidth={2.25} /> + {deltaPct !== null && deltaPct !== undefined + ? `${Math.abs(deltaPct).toFixed(Math.abs(deltaPct) < 10 && deltaPct !== 0 ? 1 : 0)}%` + : format(Math.abs(delta as number))} + </span> + )} + </div> + + {/* value — big quiet number */} + <div className="mt-5 num font-semibold leading-[0.95] tracking-[-0.02em] text-[52px] text-[var(--hq-text)]"> + {format(counted)} + </div> + + {/* sparkline */} + {series && ( + <div className="mt-4"> + <Sparkline data={series} color={deltaColor} area idSeed={label} className="h-9" /> + </div> + )} + + {/* target progress — pinned to bottom for row alignment */} + {pct !== null && ( + <div className="mt-auto pt-4 space-y-1.5"> + <div className="h-[3px] w-full rounded-full bg-white/[0.06] overflow-hidden"> + <div + className="h-full rounded-full transition-all duration-[1200ms] ease-out" + style={{ width: loaded ? `${pct}%` : "0%", background: accent, opacity: 0.85 }} + /> + </div> + <div className="flex items-center justify-between text-[11px] num text-[var(--hq-text-ghost)]"> + <span style={{ color: accent }} className="opacity-90">{pct.toFixed(1)}%</span> + <span>{deltaLabel ? `${deltaLabel} · ` : ""}goal {(goalFormat ?? format)(goal as number)}</span> + </div> + </div> + )} + + {pct === null && deltaLabel && ( + <div className="mt-auto pt-3 text-[11px] num text-[var(--hq-text-ghost)]">{deltaLabel}</div> + )} + </div> + ); + + return href ? <a href={href} className="block h-full">{inner}</a> : inner; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..48c876d --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,43 @@ +import type { NextAuthOptions } from 'next-auth' +import GoogleProvider from 'next-auth/providers/google' + +// Pure JWT auth — no DB adapter required. +// Users are verified via allowedEmails; session is a signed cookie. +// TODO: Add PrismaAdapter once DB-backed sessions are needed. +export const authOptions: NextAuthOptions = { + session: { strategy: 'jwt' }, + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }), + ], + callbacks: { + async signIn({ user }) { + // Comma-separated allowlist from env, e.g. ALLOWED_EMAILS="you@example.com,teammate@example.com" + const allowedEmails = (process.env.ALLOWED_EMAILS ?? '') + .split(',') + .map((e) => e.trim().toLowerCase()) + .filter(Boolean) + if (allowedEmails.length === 0) return false // lock down by default until configured + return allowedEmails.includes((user.email ?? '').toLowerCase()) + }, + async jwt({ token, user }) { + if (user) { + token.id = user.id + token.email = user.email + } + return token + }, + async session({ session, token }) { + if (session.user) { + session.user.id = token.sub ?? '' + session.user.email = token.email as string + } + return session + }, + }, + pages: { + signIn: '/login', + }, +} diff --git a/src/lib/fetch-url-content.ts b/src/lib/fetch-url-content.ts new file mode 100644 index 0000000..68028de --- /dev/null +++ b/src/lib/fetch-url-content.ts @@ -0,0 +1,250 @@ +/** + * Smart URL content extraction. + * + * For JS-rendered sites (X/Twitter, Substack, Medium, etc.), a raw fetch() + * returns empty HTML shells. This module uses Brave Search to get the actual + * indexed content for those URLs, falling back to direct fetch for simple sites. + */ + +const JS_RENDERED_DOMAINS = [ + "x.com", + "twitter.com", + "medium.com", + "substack.com", + "linkedin.com", + "instagram.com", + "facebook.com", + "threads.net", + "reddit.com", +]; + +function isXUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname.replace("www.", ""); + return hostname === "x.com" || hostname === "twitter.com"; + } catch { + return false; + } +} + +function extractTweetId(url: string): string | null { + const match = url.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/); + return match ? match[1] : null; +} + +async function fetchFromTwitterApi(url: string): Promise<{ title: string; body: string; imageUrl: string } | null> { + const bearerToken = process.env.TWITTER_BEARER_TOKEN || ""; + if (!bearerToken) return null; + + const tweetId = extractTweetId(url); + if (!tweetId) return null; + + try { + const res = await fetch( + `https://api.twitter.com/2/tweets/${tweetId}?tweet.fields=article&expansions=article.cover_media&media.fields=url,preview_image_url`, + { + headers: { Authorization: `Bearer ${bearerToken}` }, + signal: AbortSignal.timeout(8000), + } + ); + if (!res.ok) return null; + const data = await res.json(); + const tweet = data.data; + if (!tweet?.article) return null; + + const article = tweet.article; + const title = article.title || ""; + const body = (article.plain_text || "").replace(/https?:\/\/t\.co\/\S+/g, "").trim(); + + // Find cover image + let imageUrl = ""; + const coverKey = article.cover_media || ""; + if (coverKey && data.includes?.media) { + const media = data.includes.media.find((m: any) => m.media_key === coverKey); + if (media) imageUrl = media.url || media.preview_image_url || ""; + } + + return { title, body, imageUrl }; + } catch { + return null; + } +} + +function isJsRenderedUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname.replace("www.", ""); + return JS_RENDERED_DOMAINS.some((d) => hostname === d || hostname.endsWith("." + d)); + } catch { + return false; + } +} + +async function braveSearchUrl( + url: string, + braveKey: string +): Promise<{ title: string; content: string } | null> { + try { + // Search for the exact URL + const res = await fetch( + `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(url)}&count=5`, + { + headers: { + Accept: "application/json", + "X-Subscription-Token": braveKey, + }, + } + ); + if (!res.ok) return null; + const data = await res.json(); + const results = data.web?.results || []; + + // Find the result that matches our URL (or is close) + const urlHost = new URL(url).hostname.replace("www.", ""); + const match = results.find((r: any) => { + try { + const rHost = new URL(r.url).hostname.replace("www.", ""); + return rHost === urlHost; + } catch { + return false; + } + }); + + if (match) { + // Brave provides description (snippet) and sometimes extra_snippets + const extraSnippets = (match.extra_snippets || []).join("\n\n"); + const content = [match.description || "", extraSnippets].filter(Boolean).join("\n\n"); + return { + title: match.title || "", + content: content.trim(), + }; + } + + // If no exact match, return the most relevant result about the topic + if (results.length > 0) { + const top = results[0]; + const extraSnippets = (top.extra_snippets || []).join("\n\n"); + const content = [top.description || "", extraSnippets].filter(Boolean).join("\n\n"); + return { + title: top.title || "", + content: content.trim(), + }; + } + + return null; + } catch { + return null; + } +} + +async function directFetch(url: string, maxChars: number): Promise<{ title: string; content: string } | null> { + try { + const res = await fetch(url, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; HermyHQ/1.0)" }, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) return null; + const html = await res.text(); + + // Extract title + const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); + const title = titleMatch ? titleMatch[1].trim() : ""; + + // Extract meta description + const metaDescMatch = html.match( + /<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i + ) || html.match( + /<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["']/i + ); + const metaDesc = metaDescMatch ? metaDescMatch[1].trim() : ""; + + // Extract og:title if main title is generic + const ogTitleMatch = html.match( + /<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i + ); + const ogTitle = ogTitleMatch ? ogTitleMatch[1].trim() : ""; + + // Strip HTML and get text content + const textContent = html + .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "") + .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "") + .replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "") + .replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "") + .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxChars); + + const content = [metaDesc, textContent].filter(Boolean).join("\n\n"); + const bestTitle = ogTitle || title; + + return { + title: bestTitle, + content: content.trim(), + }; + } catch { + return null; + } +} + +/** + * Extract content from a URL using the best available method. + * - JS-rendered sites (X, Medium, etc.) → Brave Search + * - Static sites → direct fetch with improved parsing + * - Always tries Brave first if available, falls back to direct fetch + */ +export async function fetchUrlContent( + url: string, + options: { maxChars?: number } = {} +): Promise<string> { + const { maxChars = 3000 } = options; + const braveKey = process.env.BRAVE_API_KEY || ""; + const jsRendered = isJsRenderedUrl(url); + + // For X/Twitter article URLs, use Twitter API for full body + title + image + if (isXUrl(url)) { + const twitterResult = await fetchFromTwitterApi(url); + if (twitterResult && twitterResult.body.length > 50) { + const parts = [`Title: ${twitterResult.title}`, `URL: ${url}`]; + if (twitterResult.imageUrl) parts.push(`Hero Image: ${twitterResult.imageUrl}`); + parts.push(`Content:\n${twitterResult.body.slice(0, maxChars)}`); + return parts.join("\n"); + } + } + + // For JS-rendered sites, Brave is the only option + if (jsRendered && braveKey) { + const result = await braveSearchUrl(url, braveKey); + if (result && result.content) { + return `Title: ${result.title}\nURL: ${url}\nContent:\n${result.content.slice(0, maxChars)}`; + } + // Brave didn't return useful content — still try direct fetch as last resort + } + + // For static sites, or when Brave fails on JS sites + if (!jsRendered) { + // Try direct fetch first for static sites (more content) + const directResult = await directFetch(url, maxChars); + if (directResult && directResult.content.length > 100) { + return `Title: ${directResult.title}\nURL: ${url}\nContent:\n${directResult.content}`; + } + } + + // Last resort: try Brave for any URL + if (braveKey) { + const result = await braveSearchUrl(url, braveKey); + if (result && result.content) { + return `Title: ${result.title}\nURL: ${url}\nContent:\n${result.content.slice(0, maxChars)}`; + } + } + + // Final fallback: try direct fetch even for JS sites (might get meta tags at least) + if (jsRendered) { + const directResult = await directFetch(url, maxChars); + if (directResult) { + return `Title: ${directResult.title}\nURL: ${url}\nContent:\n${directResult.content}`; + } + } + + return `URL: ${url}\n(Could not extract content from this URL)`; +} diff --git a/src/lib/google-creds.ts b/src/lib/google-creds.ts new file mode 100644 index 0000000..df37294 --- /dev/null +++ b/src/lib/google-creds.ts @@ -0,0 +1,41 @@ +// Robust parser for the Google service-account credential JSON. +// +// GOOGLE_SERVICE_ACCOUNT_JSON is frequently stored with the private_key's +// real newlines left UNescaped inside the string literal, which makes +// JSON.parse throw "Bad control character in string literal". This parser +// falls back to escaping control characters that appear inside string +// literals (leaving structural whitespace untouched) so it works whether the +// env value is clean or malformed. + +type Creds = Record<string, unknown>; +let cached: Creds | null = null; + +export function parseServiceAccount(): Creds { + if (cached) return cached; + const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; + if (!raw) throw new Error("GOOGLE_SERVICE_ACCOUNT_JSON is not set"); + + try { + cached = JSON.parse(raw) as Creds; + return cached; + } catch { + // Escape raw control chars that occur INSIDE double-quoted strings only. + let out = ""; + let inStr = false; + let esc = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (esc) { out += ch; esc = false; continue; } + if (ch === "\\") { out += ch; esc = true; continue; } + if (ch === '"') { inStr = !inStr; out += ch; continue; } + if (inStr) { + if (ch === "\n") { out += "\\n"; continue; } + if (ch === "\r") { out += "\\r"; continue; } + if (ch === "\t") { out += "\\t"; continue; } + } + out += ch; + } + cached = JSON.parse(out) as Creds; + return cached; + } +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..af2a01e --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +export const prisma = globalForPrisma.prisma ?? new PrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..47b4a92 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getToken } from 'next-auth/jwt'; + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // DEV-ONLY local bypass (never active on Vercel preview/prod builds). + if (process.env.NODE_ENV === 'development') { + return NextResponse.next(); + } + + // Skip auth for NextAuth routes, assets, login, and public embeddable charts + if ( + pathname.startsWith('/api/auth/') || + pathname.startsWith('/api/garden') || + pathname.startsWith('/_next/') || + pathname.startsWith('/favicon.ico') || + pathname === '/login' + ) { + return NextResponse.next(); + } + + // Allow internal agent calls with shared secret + const internalSecret = request.headers.get('x-internal-secret'); + if (internalSecret && internalSecret === process.env.INTERNAL_API_SECRET) { + return NextResponse.next(); + } + + // Check NextAuth JWT session + const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET }); + if (!token) { + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + return NextResponse.redirect(new URL('/login', request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..b164945 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,12 @@ +import 'next-auth' + +declare module 'next-auth' { + interface Session { + user: { + id: string + name?: string | null + email?: string | null + image?: string | null + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b6512bd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,44 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts", + "out/types/**/*.ts", + "out/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..2db038e --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "regions": ["iad1"], + "crons": [ + { + "path": "/api/cron/x-stats", + "schedule": "0 8 * * *" + } + ] +}