Build PROMPT media library with masonry UI and persistent PostgreSQL S3 storage
PROMPT CI and deployment / verify-and-deploy (push) Successful in 8m18s
PROMPT CI and deployment / verify-and-deploy (push) Successful in 8m18s
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
DATABASE_URL=postgresql://prompt:replace-me@127.0.0.1:18132/prompt
|
||||
POSTGRES_PASSWORD=replace-me
|
||||
PG_PORT=18132
|
||||
S3_PORT=18133
|
||||
S3_ENDPOINT=http://127.0.0.1:18133
|
||||
S3_ACCESS_KEY=replace-me
|
||||
S3_SECRET_KEY=replace-with-a-long-random-secret
|
||||
S3_BUCKET=prompt
|
||||
+16
-1
@@ -9,7 +9,7 @@ on:
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -28,6 +28,21 @@ jobs:
|
||||
run: npm run build
|
||||
- name: Test standalone production server
|
||||
run: npm run test:smoke
|
||||
- name: Prepare Docker and FFmpeg for isolated integration tests
|
||||
run: |
|
||||
set -eu
|
||||
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo -n"; fi
|
||||
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y docker.io docker-compose-v2
|
||||
$SUDO systemctl enable --now docker
|
||||
fi
|
||||
if ! command -v ffmpeg >/dev/null; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y ffmpeg
|
||||
fi
|
||||
- name: Test real PostgreSQL, S3 and video processing in isolation
|
||||
run: bash scripts/integration.sh
|
||||
- name: Deploy persistent systemd service
|
||||
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
|
||||
@@ -5,3 +5,4 @@ coverage/
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
dist/
|
||||
|
||||
@@ -1,60 +1,100 @@
|
||||
# PROMPT
|
||||
|
||||
仓库全名:**Probably Rendering One More Pixel Tonight**。日常名称、包名和服务名统一使用 PROMPT / `prompt`。
|
||||
仓库名:Probably Rendering One More Pixel Tonight。其余命名使用 PROMPT / `prompt`。
|
||||
|
||||
个人图文、动效与视频实验项目。当前仅搭建开发环境及 CI/CD,没有业务页面;访问 `/` 返回 404 是预期行为。
|
||||
自己的素材与创作工作台:Next.js Pages Router、React、TypeScript、Tailwind CSS、Node.js 24。中文界面包含瀑布流、日期贡献热力图、素材导入、多媒体详情、分类管理、独立素材架与线条图形工具。没有创建作品的按钮;图文和动态作品由代码实现。
|
||||
|
||||
## 技术栈
|
||||
|
||||
Next.js Pages Router、React、TypeScript(strict)、Tailwind CSS、Node.js 24。依赖版本由 `package-lock.json` 固定。
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
nvm use
|
||||
npm ci
|
||||
cp .env.example .env
|
||||
# 修改 .env 中的三个密码 / 密钥,不使用示例值。
|
||||
docker compose --env-file .env up -d
|
||||
set -a; . ./.env; set +a
|
||||
npm run storage:prepare
|
||||
npm run dev
|
||||
# 另一终端加载同一份 .env 后运行后台视频处理:
|
||||
npm run worker
|
||||
```
|
||||
|
||||
健康检查:`GET /api/health`。支持 HEAD,拒绝写入请求,并返回当前部署 commit。
|
||||
本机需要 Docker Compose 和 FFmpeg / ffprobe。开发中的 .env、数据库文件和对象不进入 Git;生产凭据由部署脚本随机生成。
|
||||
|
||||
## 验证
|
||||
## 页面与数据
|
||||
|
||||
- `/`:所有非空素材包与已注册代码作品,按创建时间倒序;顶部为过去一年的贡献汇总。
|
||||
- `/images`、`/videos`、`/audio`:图片、视频、声音收藏。
|
||||
- `/articles`、`/motion`:代码编写的图文与动态作品。
|
||||
- 上述五类路径可以附加标签树路径,如 `/images/style/white-line`;父分类会包含后代分类的内容。
|
||||
- `/p/<slug>`:素材包的稳定详情地址,不随标签改变。多图切换、多视频及其关联音轨、多声音均可展示。
|
||||
- `/library/import`:导入同一类型的一组文件和说明,自动收为一份收藏。支持最多 16 个文件,单个 250 MB,总计 1 GB。
|
||||
- `/library/assets`:查看每份独立素材及被引用次数,复制 ID 以复用。
|
||||
- `/library/tags`:维护严格单父级分类树;同名 slug 只允许出现在不同分支。
|
||||
- `/tools/doodle`:手绘线条造型参考,可选色、复制和下载透明 SVG。不是 AI 图片编辑器。
|
||||
|
||||
素材库不插入演示或占位帖子。空分类显示简洁的空状态;空素材包不进入任何瀑布流。分页游标由时间与 UUID 组成,重复时间戳下仍稳定。
|
||||
|
||||
详细模型见 `docs/data-model.md`。PostgreSQL 保存引用、分类、元数据、文字和任务;RustFS 通过 S3 协议保存二进制对象。数据库和 S3 端口只监听宿主机 `127.0.0.1`,浏览器通过应用访问媒体,支持 Range 分段读取。
|
||||
|
||||
视频原件保持不变,后台生成无声 MP4、封面,以及存在声音时的 M4A 音轨。音轨记录在 `asset_variants`,始终属于原视频;不会生成独立声音资产。后台单任务处理,使用数据库锁领取任务,失败重试三次,支持超时任务恢复。FFmpeg 禁止输入网络协议。
|
||||
|
||||
## 编程加入素材包与作品
|
||||
|
||||
`POST /api/posts` 接收 `kind`、`title`、可选 `slug`、`assets: [{id, role?}]`、`tags: [tagId]`、`text`、`coverAssetId` 和 `pageKey`。`role` 为 `content` 或 `caption`。素材和说明文字都可以通过 ID 引用已有资产;`text` 是创建新文字资产的快捷方式。引用不会复制原文件。
|
||||
|
||||
`POST /api/tags` 接收 `name`、`slug` 和可选 `parentId`。
|
||||
|
||||
图文或动态作品需要先在 `src/content/registry.tsx` 注册 React 组件与类型,再通过 API 创建引用对应 `pageKey` 的素材包。封面引用普通图片资产。动态作品的 React 组件就是画面本身,不会被强制套入视频播放器。未注册的页面不会出现在瀑布流中,也不会执行数据库中的任意路径或脚本。
|
||||
|
||||
当前不内置作品模板或视频导出引擎;后续一起确定设计系统和制作方案。
|
||||
|
||||
## 贡献记录
|
||||
|
||||
`contribution_events` 保存细分的 `kind`、对象类型、对象 ID、数量、发生时间、元数据和可选幂等键。显示时按上海时区汇总所有类型。当前没有将上传、分类或保存动作自动算作贡献;触发规则留待确定。没有随机或伪造热力图数据。
|
||||
|
||||
## CI/CD 与运维
|
||||
|
||||
`.gitea/workflows/ci.yml` 使用 `runs-on: ces-control`。PR 执行验证;main 上的 push 或手动运行,在类型检查、单元测试、生产构建、启动检查及真实 PostgreSQL/S3/视频集成测试通过后才部署。
|
||||
|
||||
集成测试启动带独立 project 名称和随机本机端口的 Docker 服务,使用临时随机凭据;结束后只删除该测试项目的容器和卷,不触碰生产卷。
|
||||
|
||||
生产约定:
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| Web | `prompt.service`,HTTP `18131` |
|
||||
| 视频处理 | `prompt-worker.service` |
|
||||
| PostgreSQL | Docker Compose `prompt-postgres`,回环端口 `18132` |
|
||||
| S3 / RustFS | Docker Compose `prompt-objects`,回环端口 `18133`,控制台关闭 |
|
||||
| 发布 | `/opt/prompt/releases/<commit>`,`/opt/prompt/current` 原子软链接 |
|
||||
| 部署清单 | `/opt/prompt/infra/compose.yml` |
|
||||
| 密钥 | `/etc/prompt.env`,root:prompt / 0640,不进代码或日志 |
|
||||
| 运行数据 | `/var/lib/prompt`;数据库与 S3 使用 Docker named volumes |
|
||||
| 发布前数据库备份 | `/var/lib/prompt/backups`,保留最近 10 份 |
|
||||
|
||||
服务由 systemd 管理,支持开机启动、故障重启和失败回退。迁移采用追加方式;发布前创建 PostgreSQL dump,回退应用版本不会破坏已写入数据。S3 对象卷不随发布清理;对象备份需要另外快照该卷,数据库 dump 不能代替对象备份。
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:18131/api/health # 当前应用版本
|
||||
curl -fsS http://127.0.0.1:18131/api/ready # 数据库和 S3 连接
|
||||
sudo systemctl status prompt.service prompt-worker.service
|
||||
sudo journalctl -u prompt-worker.service -n 100 --no-pager
|
||||
sudo docker compose -p prompt --env-file /etc/prompt.env -f /opt/prompt/infra/compose.yml ps
|
||||
```
|
||||
|
||||
首次部署可能安装 Docker Compose 和 FFmpeg;runner 需能访问宿主机 systemd、Docker,并具备 root 或免密 sudo。服务不配置公网域名或防火墙,按现有内网访问方式使用。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
npm run test:smoke
|
||||
```
|
||||
|
||||
Smoke test 会启动 standalone 生产服务,验证 HTTP 健康检查、release 标识及不支持的方法,随后停止测试进程。默认自动选择可用测试端口,可设置 `SMOKE_PORT` 覆盖。
|
||||
|
||||
## Gitea Actions 与持久部署
|
||||
|
||||
工作流为 `.gitea/workflows/ci.yml`,使用 `runs-on: ces-control`。PR 运行验证;main 分支 push 或在 main 上手动执行会在全部检查通过后部署。
|
||||
|
||||
Runner 前提:Linux 宿主机可访问 systemd,提供 Bash、Git、flock、基础文件工具及 root 或免密 sudo。Runner 的 label 必须让 Job 在该宿主机环境执行;容器里的 host 模式不一定就是物理宿主机。如果这些条件不满足,部署会明确失败,不能将 CI 中临时启动的进程当成持久服务。
|
||||
|
||||
仓库需要开启 Actions,且 `ces-control` runner 对仓库可用。Checkout 使用 Gitea Actions 提供的临时凭据,不在仓库保存密码或长期 token。
|
||||
|
||||
部署约定:
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| 服务 | `prompt.service` |
|
||||
| 系统用户 | `prompt` |
|
||||
| HTTP 端口 | `18131` |
|
||||
| 发布目录 | `/opt/prompt/releases/<commit>` |
|
||||
| 当前发布 | `/opt/prompt/current` |
|
||||
| 持久数据目录 | `/var/lib/prompt` |
|
||||
| 健康检查 | `http://127.0.0.1:18131/api/health` |
|
||||
|
||||
发布包含独立 Node 运行时。systemd 负责开机启动和故障重启;部署使用宿主机文件锁串行执行,跳过落后的 commit,通过原子软链接切换版本。新版本健康检查失败时恢复旧版本;首次发布失败则停止服务。数据目录不随 checkout 清理或版本切换而删除,当前尚未引入数据库或用户素材。
|
||||
|
||||
端口对外可达性取决于目标机器网络和防火墙;工作流不会修改防火墙。CI 日志会输出部署主机名及地址。当前没有业务接口或 UI,后续再确定图文、视频模块的包和设计系统。
|
||||
|
||||
维护命令(在目标宿主机执行):
|
||||
|
||||
```bash
|
||||
sudo systemctl status prompt.service
|
||||
sudo journalctl -u prompt.service -n 100 --no-pager
|
||||
curl -fsS http://127.0.0.1:18131/api/health
|
||||
# 在已配置的隔离 PostgreSQL / S3 环境:
|
||||
npm run storage:prepare
|
||||
PROMPT_INTEGRATION_TEST=1 npm run test:integration
|
||||
# 或在 Docker 主机上一键创建并清理隔离环境:
|
||||
bash scripts/integration.sh
|
||||
```
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: prompt
|
||||
POSTGRES_USER: prompt
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a unique PostgreSQL password}
|
||||
ports:
|
||||
- "127.0.0.1:${PG_PORT:-18132}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U prompt -d prompt"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
objects:
|
||||
image: rustfs/rustfs:1.0.3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:?Set a unique S3 access key}
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:?Set a unique S3 secret key}
|
||||
RUSTFS_VOLUMES: /data
|
||||
RUSTFS_ADDRESS: ":9000"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
ports:
|
||||
- "127.0.0.1:${S3_PORT:-18133}:9000"
|
||||
volumes:
|
||||
- objects:/data
|
||||
volumes:
|
||||
postgres_data:
|
||||
objects:
|
||||
@@ -0,0 +1,65 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations(version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id uuid PRIMARY KEY, kind text NOT NULL CHECK(kind IN ('image','video','audio','text')),
|
||||
filename text, storage_key text UNIQUE, mime_type text, byte_size bigint CHECK(byte_size > 0),
|
||||
sha256 text, body text, width integer, height integer, duration double precision,
|
||||
has_audio boolean, processing_status text NOT NULL DEFAULT 'ready' CHECK(processing_status IN ('ready','pending','processing','failed')),
|
||||
processing_error text, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK ((kind='text' AND body IS NOT NULL AND storage_key IS NULL) OR (kind<>'text' AND storage_key IS NOT NULL AND byte_size > 0))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS asset_variants (
|
||||
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK(role IN ('silent_video','audio_track','poster')),
|
||||
storage_key text NOT NULL UNIQUE, mime_type text NOT NULL, byte_size bigint NOT NULL CHECK(byte_size>0),
|
||||
PRIMARY KEY(asset_id,role)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id uuid PRIMARY KEY, slug text NOT NULL UNIQUE,
|
||||
kind text NOT NULL CHECK(kind IN ('image','video','audio','article','motion')),
|
||||
title text NOT NULL CHECK(length(trim(title))>0), page_key text,
|
||||
cover_asset_id uuid REFERENCES assets(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK((kind IN ('article','motion') AND page_key IS NOT NULL) OR (kind IN ('image','video','audio') AND page_key IS NULL))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS post_assets (
|
||||
post_id uuid NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
|
||||
position integer NOT NULL CHECK(position>=0), role text NOT NULL DEFAULT 'content' CHECK(role IN ('content','caption')),
|
||||
PRIMARY KEY(post_id,position)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS post_assets_asset ON post_assets(asset_id);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id uuid PRIMARY KEY, parent_id uuid REFERENCES tags(id) ON DELETE RESTRICT,
|
||||
slug text NOT NULL CHECK(slug ~ '^[a-z0-9][a-z0-9-]*$'), name text NOT NULL CHECK(length(trim(name))>0),
|
||||
UNIQUE NULLS NOT DISTINCT(parent_id,slug), CHECK(id IS DISTINCT FROM parent_id)
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION prevent_tag_cycle() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- Serialize taxonomy edits so concurrent moves cannot introduce a cycle.
|
||||
PERFORM pg_advisory_xact_lock(718131);
|
||||
IF NEW.parent_id IS NOT NULL AND EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM tags WHERE id=NEW.parent_id
|
||||
UNION ALL SELECT t.id,t.parent_id FROM tags t JOIN ancestors a ON t.id=a.parent_id
|
||||
) SELECT 1 FROM ancestors WHERE id=NEW.id
|
||||
) THEN RAISE EXCEPTION 'Tag hierarchy cannot contain a cycle'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS tags_no_cycles ON tags;
|
||||
CREATE TRIGGER tags_no_cycles BEFORE INSERT OR UPDATE ON tags FOR EACH ROW EXECUTE FUNCTION prevent_tag_cycle();
|
||||
CREATE TABLE IF NOT EXISTS post_tags(post_id uuid REFERENCES posts(id) ON DELETE CASCADE,tag_id uuid REFERENCES tags(id) ON DELETE CASCADE,PRIMARY KEY(post_id,tag_id));
|
||||
CREATE TABLE IF NOT EXISTS asset_tags(asset_id uuid REFERENCES assets(id) ON DELETE CASCADE,tag_id uuid REFERENCES tags(id) ON DELETE CASCADE,PRIMARY KEY(asset_id,tag_id));
|
||||
CREATE TABLE IF NOT EXISTS media_jobs (
|
||||
id uuid PRIMARY KEY, asset_id uuid NOT NULL UNIQUE REFERENCES assets(id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','ready','failed')),
|
||||
attempts integer NOT NULL DEFAULT 0, available_at timestamptz NOT NULL DEFAULT now(), locked_at timestamptz, last_error text
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS contribution_events (
|
||||
id uuid PRIMARY KEY, kind text NOT NULL, subject_type text NOT NULL, subject_id uuid,
|
||||
amount integer NOT NULL DEFAULT 1 CHECK(amount>0), occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
idempotency_key text UNIQUE, metadata jsonb NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS contribution_events_time ON contribution_events(occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS posts_timeline ON posts(created_at DESC,id DESC);
|
||||
INSERT INTO schema_migrations(version) VALUES('001_library') ON CONFLICT DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,33 @@
|
||||
# 数据模型与网址约定
|
||||
|
||||
唯一具有树形结构的是 `tags`。素材、素材包和对象存储路径都不组成目录树。
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
posts ||--o{ post_assets : references
|
||||
assets ||--o{ post_assets : reused_by
|
||||
posts ||--o{ post_tags : classified_by
|
||||
tags ||--o{ post_tags : groups
|
||||
assets ||--o{ asset_tags : classified_by
|
||||
tags ||--o{ asset_tags : groups
|
||||
tags o|--o{ tags : single_parent
|
||||
assets ||--o{ asset_variants : owns
|
||||
assets ||--o| media_jobs : processed_by
|
||||
```
|
||||
|
||||
- `assets`:每个图片、视频、独立音频或文字片段有稳定 UUID。二进制引用 S3 key;文字保存在 body。宽高、时长、原件是否有声音、处理状态记录在素材上。SHA256 用于识别内容,但不会把不同导入擅自合并。同一素材可以被任意多个素材包引用。
|
||||
- `asset_variants`:视频衍生物,以 `(asset_id, role)` 唯一。无声画面、原视频音轨和封面始终挂在原视频下。没有 `audio` 类型的新资产,因此音轨不会混入独立音频素材。
|
||||
- `posts`:展示单位,稳定 slug、类型、创建时间、标题等元数据。`cover_asset_id` 是图片指针;`page_key` 只引用代码注册的图文或动态组件,不保存可执行代码。正文和描述不是 post 上的重复字段。
|
||||
- `post_assets`:记录素材 ID、展示顺序及 content/caption 角色。删除素材包只移除关联,不删除素材;有引用的素材不能直接删除。
|
||||
- `tags`:相邻表结构,每个节点仅一个 parent;数据库约束拒绝自引用和环,同层 slug 唯一,包括根节点。新增、移动时用事务锁串行化树修改。
|
||||
- `asset_tags`、`post_tags`:两套多对多关联。导入时可同时给素材和素材包分类,之后互相独立。修改素材分类不会悄悄改动所有引用它的素材包。
|
||||
- `media_jobs`:数据库任务队列,支持并发安全领取、崩溃租约恢复、三次重试。状态变化不会创建新的素材包。
|
||||
- `contribution_events`:以细分事件作为事实记录,展示时按日聚合。subject ID 不强制外键,历史事件不因对象删除而消失。唯一幂等键用于将来防止重复计数。当前不配置任何自动触发器。
|
||||
|
||||
分类网址是“内容类型入口 + 从根到叶的 slug 链”。例如标签树 `style → white-line` 可以同时用于 `/images/style/white-line` 和 `/videos/style/white-line`。同一个标签跨不同内容类型共享其身份,内容类型入口只负责过滤素材包类型。父标签筛选包含后代标签;结果按 post 去重。
|
||||
|
||||
素材包详情始终使用 `/p/<slug>`,更换分类不改变它的网址。同一份二进制在多个素材包中仍只有一个 asset ID 和对象引用。
|
||||
|
||||
媒体包必须有对应类型的非空文件引用才进入瀑布流;图文和动态作品必须有已注册的组件。只有标题、空关联或未知组件的对象都被过滤。不存在的分类网址返回 404,不退回全部内容。
|
||||
|
||||
迁移保存在 `db/` 并在 `schema_migrations` 记录;迁移过程加会话锁。不能通过应用回退删除数据库或对象卷。
|
||||
Generated
+668
-7
@@ -8,15 +8,24 @@
|
||||
"name": "prompt",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1155.0",
|
||||
"formidable": "3.5.4",
|
||||
"lucide-react": "1.61.0",
|
||||
"next": "16.4.2",
|
||||
"pg": "8.23.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
"react-dom": "19.3.1",
|
||||
"sharp": "0.35.5",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/formidable": "3.5.1",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"esbuild": "0.28.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "5.9.3"
|
||||
@@ -38,6 +47,314 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1001.5",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.5.tgz",
|
||||
"integrity": "sha512-6erto9nK2OZO/woEgmFIaPh5gChgdNd7nL6jgAf8TAXz3/QHC8Pan2TFe9GYp3IxOIKCCGWPylSjtOHKfNzQlw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1155.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1155.0.tgz",
|
||||
"integrity": "sha512-l3uMeIlzWVgj/RTJTh5sKYKtQz1la3cvWLUPn1cwFH4UZpcqTH66SgJHCeB2FOE9bEAjOdbmCyPKt9aw867Fng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1001.5",
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.88",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.81",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.49",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.978.5",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.5.tgz",
|
||||
"integrity": "sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@aws-sdk/xml-builder": "^3.972.41",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/signature-v4": "^5.7.4",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.76",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.76.tgz",
|
||||
"integrity": "sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.78",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.78.tgz",
|
||||
"integrity": "sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.973.21",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.21.tgz",
|
||||
"integrity": "sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.76",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.78",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.83",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.76",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.20",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
|
||||
"@aws-sdk/nested-clients": "^3.997.50",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.83",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.83.tgz",
|
||||
"integrity": "sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/nested-clients": "^3.997.50",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.88",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.88.tgz",
|
||||
"integrity": "sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.76",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.78",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.21",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.76",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.20",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.76",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.76.tgz",
|
||||
"integrity": "sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.973.20",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.20.tgz",
|
||||
"integrity": "sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/nested-clients": "^3.997.50",
|
||||
"@aws-sdk/token-providers": "3.1155.0",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.82",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.82.tgz",
|
||||
"integrity": "sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/nested-clients": "^3.997.50",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.81",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.81.tgz",
|
||||
"integrity": "sha512-jfanv1pV0sJFZw6/DPiNjvK7B478god0zOum5yIWo+/+1Gy+CSXWpGBWx+3nBJyDvjcu/2kUMr4ADUp9BHH34Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.49",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.50",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.50.tgz",
|
||||
"integrity": "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.49",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.49",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz",
|
||||
"integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/signature-v4": "^5.7.4",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1155.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1155.0.tgz",
|
||||
"integrity": "sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
"@aws-sdk/nested-clients": "^3.997.50",
|
||||
"@aws-sdk/types": "^3.975.0",
|
||||
"@smithy/core": "^3.36.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.975.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz",
|
||||
"integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.41",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
|
||||
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
@@ -495,7 +812,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1175,6 +1491,108 @@
|
||||
"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/@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/@smithy/core": {
|
||||
"version": "3.36.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.2.tgz",
|
||||
"integrity": "sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
|
||||
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.2",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
|
||||
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
|
||||
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
|
||||
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
|
||||
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
@@ -1455,6 +1873,16 @@
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/formidable": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.5.1.tgz",
|
||||
"integrity": "sha512-aFQijSGbD8JCeEST2LEbwR7faHynbt43lojLIcTM/QhB2U06h41ZVRFVEql+Z1xdL+aKGIzm69V/P/uSW9N6XA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
@@ -1465,11 +1893,23 @@
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1485,6 +1925,12 @@
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"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/baseline-browser-mapping": {
|
||||
"version": "2.11.30",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.30.tgz",
|
||||
@@ -1497,6 +1943,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001819",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz",
|
||||
@@ -1527,19 +1979,28 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"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==",
|
||||
"devOptional": true,
|
||||
"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/enhanced-resolve": {
|
||||
"version": "5.26.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
|
||||
@@ -1596,6 +2057,23 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"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/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1889,6 +2367,21 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1970,6 +2463,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.1",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.1",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||
"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.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||
"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",
|
||||
@@ -2004,6 +2595,45 @@
|
||||
"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/react": {
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||
@@ -2036,7 +2666,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -2049,7 +2678,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -2103,6 +2731,15 @@
|
||||
"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/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@@ -2192,6 +2829,30 @@
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/zod": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -7,22 +7,34 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/worker.cjs && node scripts/prepare-standalone.mjs",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"test:smoke": "node scripts/smoke.mjs"
|
||||
"test": "tsx --test tests/health.test.ts tests/model.test.ts",
|
||||
"test:smoke": "node scripts/smoke.mjs",
|
||||
"storage:prepare": "tsx scripts/bootstrap.ts",
|
||||
"worker": "tsx src/worker.ts",
|
||||
"test:integration": "tsx --test tests/integration.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1155.0",
|
||||
"formidable": "3.5.4",
|
||||
"lucide-react": "1.61.0",
|
||||
"next": "16.4.2",
|
||||
"pg": "8.23.1",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
"react-dom": "19.3.1",
|
||||
"sharp": "0.35.5",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/formidable": "3.5.1",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"esbuild": "0.28.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readdir,readFile } from "node:fs/promises";
|
||||
import { db } from "../src/lib/db";
|
||||
import { ensureBucket } from "../src/lib/storage";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
async function main(){
|
||||
for(let i=0;;i++){try{await db().query('SELECT 1');break;}catch(e){if(i>=60)throw e;await setTimeout(2000);}}
|
||||
const client=await db().connect();
|
||||
try{
|
||||
await client.query('SELECT pg_advisory_lock(18131001)');
|
||||
for(const name of (await readdir('db')).filter(n=>n.endsWith('.sql')).sort()){
|
||||
const present=await client.query("SELECT to_regclass('public.schema_migrations') AS table");
|
||||
if(present.rows[0].table && (await client.query('SELECT 1 FROM schema_migrations WHERE version=$1',[name.replace('.sql','')])).rowCount)continue;
|
||||
await client.query(await readFile('db/'+name,'utf8'));console.log('Applied migration '+name);
|
||||
}
|
||||
}catch(error){await client.query('ROLLBACK');throw error;}
|
||||
finally{await client.query('SELECT pg_advisory_unlock(18131001)');client.release();}
|
||||
for(let i=0;;i++){try{await ensureBucket();break;}catch(e){if(i>=90)throw e;await setTimeout(2000);}}
|
||||
console.log('PostgreSQL and S3 are ready');await db().end();
|
||||
}
|
||||
main().catch(error=>{console.error('Storage bootstrap failed:',error instanceof Error?error.message:'Unknown error');process.exit(1);});
|
||||
+36
-1
@@ -49,6 +49,11 @@ if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
useradd --system --home "$DATA_ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$DATA_ROOT"
|
||||
bash scripts/infra.sh "$NODE_SOURCE" "$RELEASE_SHA"
|
||||
set -a
|
||||
. /etc/prompt.env
|
||||
set +a
|
||||
"$NODE_SOURCE" node_modules/tsx/dist/cli.mjs scripts/bootstrap.ts
|
||||
# Keep runtime data outside the checkout and releases; future modules can use it.
|
||||
if [ ! -d "$RELEASE_DIR" ]; then
|
||||
STAGING=$(mktemp -d "$APP_ROOT/releases/.staging.XXXXXX")
|
||||
@@ -57,6 +62,7 @@ if [ ! -d "$RELEASE_DIR" ]; then
|
||||
cp -a .next/static "$STAGING/.next/static"
|
||||
if [ -d public ]; then cp -a public "$STAGING/public"; fi
|
||||
install -m 0755 "$NODE_SOURCE" "$STAGING/bin/node"
|
||||
install -m 0644 dist/worker.cjs "$STAGING/worker.cjs"
|
||||
printf 'RELEASE_SHA=%s\n' "$RELEASE_SHA" > "$STAGING/release.env"
|
||||
chmod -R a+rX "$STAGING"
|
||||
chmod 0755 "$STAGING"
|
||||
@@ -79,6 +85,7 @@ Environment=HOSTNAME=0.0.0.0
|
||||
Environment=PORT=$PORT
|
||||
Environment=PROMPT_DATA_DIR=$DATA_ROOT
|
||||
EnvironmentFile=$APP_ROOT/current/release.env
|
||||
EnvironmentFile=/etc/prompt.env
|
||||
ExecStart=$APP_ROOT/current/bin/node server.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
@@ -89,6 +96,29 @@ UMask=0027
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/prompt-worker.service <<EOF
|
||||
[Unit]
|
||||
Description=PROMPT media processing
|
||||
After=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$SERVICE_USER
|
||||
Group=$SERVICE_USER
|
||||
WorkingDirectory=$APP_ROOT/current
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=/etc/prompt.env
|
||||
ExecStart=$APP_ROOT/current/bin/node worker.cjs
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=650
|
||||
NoNewPrivileges=true
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
SWITCHED=0
|
||||
rollback() {
|
||||
local code=$?
|
||||
@@ -99,9 +129,11 @@ rollback() {
|
||||
ln -sfn "$PREVIOUS" "$APP_ROOT/.current.rollback"
|
||||
mv -Tf "$APP_ROOT/.current.rollback" "$APP_ROOT/current"
|
||||
systemctl restart "$SERVICE"
|
||||
if [ -f "$PREVIOUS/worker.cjs" ]; then systemctl restart prompt-worker.service; else systemctl disable --now prompt-worker.service; fi
|
||||
echo 'Restored previous release'
|
||||
else
|
||||
systemctl disable --now "$SERVICE"
|
||||
systemctl disable --now prompt-worker.service
|
||||
rm -f "$APP_ROOT/current"
|
||||
echo 'Stopped failed first deployment'
|
||||
fi
|
||||
@@ -116,10 +148,12 @@ SWITCHED=1
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE"
|
||||
systemctl restart "$SERVICE"
|
||||
systemctl enable --now prompt-worker.service
|
||||
systemctl restart prompt-worker.service
|
||||
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 30); do
|
||||
if "$NODE_SOURCE" -e "fetch('http://127.0.0.1:$PORT/api/health').then(async r=>{const d=await r.json();if(!r.ok||d.service!=='prompt'||d.release!=='$RELEASE_SHA')process.exit(1)}).catch(()=>process.exit(1))"; then
|
||||
if "$NODE_SOURCE" -e "Promise.all([fetch('http://127.0.0.1:$PORT/api/health'),fetch('http://127.0.0.1:$PORT/api/ready')]).then(async ([r,s])=>{const d=await r.json();if(!r.ok||!s.ok||d.service!=='prompt'||d.release!=='$RELEASE_SHA')process.exit(1)}).catch(()=>process.exit(1))"; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
@@ -131,6 +165,7 @@ if [ "$HEALTHY" -ne 1 ]; then
|
||||
fi
|
||||
systemctl is-enabled "$SERVICE"
|
||||
systemctl is-active "$SERVICE"
|
||||
systemctl is-active prompt-worker.service
|
||||
echo "Persistent deployment verified: service=$SERVICE port=$PORT release=$RELEASE_SHA"
|
||||
echo "Host: $(hostname)"
|
||||
hostname -I || true
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
NODE_SOURCE="${1:?Node executable is required}"
|
||||
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2
|
||||
systemctl enable --now docker
|
||||
fi
|
||||
if ! command -v ffmpeg >/dev/null; then
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg
|
||||
fi
|
||||
umask 027
|
||||
if [ ! -f /etc/prompt.env ]; then
|
||||
"$NODE_SOURCE" <<'NODE' > /etc/prompt.env
|
||||
const {randomBytes}=require('crypto');
|
||||
const password=randomBytes(32).toString('hex');
|
||||
console.log(`POSTGRES_PASSWORD=${password}\nPG_PORT=18132\nS3_PORT=18133\nDATABASE_URL=postgresql://prompt:${password}@127.0.0.1:18132/prompt\nS3_ENDPOINT=http://127.0.0.1:18133\nS3_ACCESS_KEY=${randomBytes(16).toString('hex')}\nS3_SECRET_KEY=${randomBytes(32).toString('hex')}\nS3_BUCKET=prompt\nFFMPEG_PATH=/usr/bin/ffmpeg\nFFPROBE_PATH=/usr/bin/ffprobe`);
|
||||
NODE
|
||||
fi
|
||||
chown root:prompt /etc/prompt.env
|
||||
chmod 0640 /etc/prompt.env
|
||||
install -d -m 0755 /opt/prompt/infra
|
||||
install -m 0644 compose.yml /opt/prompt/infra/compose.yml
|
||||
docker compose -p prompt --env-file /etc/prompt.env -f /opt/prompt/infra/compose.yml up -d
|
||||
for attempt in $(seq 1 60); do
|
||||
if docker compose -p prompt --env-file /etc/prompt.env -f /opt/prompt/infra/compose.yml exec -T postgres pg_isready -U prompt -d prompt >/dev/null 2>&1; then break; fi
|
||||
if [ "$attempt" -eq 60 ]; then echo 'PostgreSQL startup timed out'; exit 1; fi
|
||||
sleep 2
|
||||
done
|
||||
# Additive migrations follow this backup. Keep the most recent 10 database dumps.
|
||||
install -d -o root -g root -m 0700 /var/lib/prompt/backups
|
||||
docker compose -p prompt --env-file /etc/prompt.env -f /opt/prompt/infra/compose.yml exec -T postgres pg_dump -U prompt -d prompt -Fc > "/var/lib/prompt/backups/$(date -u +%Y%m%dT%H%M%S)-${2:-deploy}.dump"
|
||||
find /var/lib/prompt/backups -name '*.dump' -printf '%T@ %p\n' | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm --
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
export COMPOSE_PROJECT_NAME="prompt-ci-${GITEA_RUN_ID:-$$}"
|
||||
export POSTGRES_PASSWORD S3_ACCESS_KEY S3_SECRET_KEY
|
||||
POSTGRES_PASSWORD=$(node -e "console.log(require('crypto').randomBytes(24).toString('hex'))")
|
||||
S3_ACCESS_KEY=$(node -e "console.log(require('crypto').randomBytes(12).toString('hex'))")
|
||||
S3_SECRET_KEY=$(node -e "console.log(require('crypto').randomBytes(24).toString('hex'))")
|
||||
export PG_PORT=0 S3_PORT=0
|
||||
cleanup(){ docker compose -f compose.yml down -v --remove-orphans; }
|
||||
trap cleanup EXIT
|
||||
docker compose -f compose.yml up -d
|
||||
PG_PORT=$(docker compose -f compose.yml port postgres 5432 | tail -1 | sed 's/.*://')
|
||||
S3_PORT=$(docker compose -f compose.yml port objects 9000 | tail -1 | sed 's/.*://')
|
||||
export DATABASE_URL="postgresql://prompt:$POSTGRES_PASSWORD@127.0.0.1:$PG_PORT/prompt"
|
||||
export S3_ENDPOINT="http://127.0.0.1:$S3_PORT" S3_BUCKET=prompt
|
||||
export PROMPT_INTEGRATION_TEST=1
|
||||
npm run storage:prepare
|
||||
npm run test:integration
|
||||
@@ -0,0 +1,4 @@
|
||||
import { cp,mkdir,access } from 'node:fs/promises';
|
||||
await mkdir('.next/standalone/.next',{recursive:true});
|
||||
await cp('.next/static','.next/standalone/.next/static',{recursive:true});
|
||||
try{await access('public');await cp('public','.next/standalone/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useEffect,useRef,useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Search,Image as ImageIcon,ArrowUpRight,AudioLines,Film,Layers,ArrowDown,Upload } from "lucide-react";
|
||||
import { Shell } from "./Shell";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { sections,mediaUrl,type FeedData,type Post } from "@/lib/model";
|
||||
export function PostCard({post}:{post:Post}){
|
||||
const image=post.assets.find(a=>a.kind==='image');const video=post.assets.find(a=>a.kind==='video');const cover=post.cover_asset_id||image?.id;
|
||||
const label=sections.find(s=>s.kind===post.kind)?.name;const count=post.assets.filter(a=>a.kind!=='text').length;
|
||||
const source=cover?mediaUrl(cover):video?.variants.includes('poster')?mediaUrl(video.id,'poster'):null;
|
||||
return <Link className="post-card" href={`/p/${post.slug}`}><div className={`post-cover cover-${post.kind}`} style={image?.width&&image?.height?{aspectRatio:`${image.width}/${image.height}`,maxHeight:460}:undefined}>
|
||||
{source?<img src={source} alt={post.title} loading="lazy"/>:post.kind==='audio'?<div className="waveform">{Array.from({length:36},(_,i)=><i key={i} style={{height:12+((i*31)%65)}}/>)}</div>:<div className="cover-symbol">{post.kind==='video'?<Film size={42}/>:<Layers size={42}/>}<span>{label}</span></div>}
|
||||
{count>1&&<span className="cover-count"><Layers size={12}/>{count}</span>}{video&&<span className="cover-badge">{video.has_audio?'有声音':'无声音'}</span>}
|
||||
</div><h3>{post.title}</h3><div className="post-meta"><span><i className={`type-dot ${post.kind}`}/>{label}</span><time>{new Date(post.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit',timeZone:'Asia/Shanghai'})}</time></div>{post.tags.length>0&&<div className="card-tags">{post.tags.slice(0,2).map(t=><span key={t.id}>#{t.name}</span>)}</div>}</Link>;
|
||||
}
|
||||
function Masonry({posts}:{posts:Post[]}){
|
||||
const element=useRef<HTMLDivElement>(null);const [width,setWidth]=useState(1100);
|
||||
useEffect(()=>{if(!element.current)return;const observer=new ResizeObserver(entries=>setWidth(entries[0].contentRect.width));observer.observe(element.current);return ()=>observer.disconnect();},[]);
|
||||
const count=width>1250?5:width>1000?4:width>720?3:2;
|
||||
const columns:Post[][]=Array.from({length:count},()=>[]);const heights=Array(count).fill(0);const cardWidth=(width-22*(count-1))/count;
|
||||
for(const post of posts){const image=post.assets.find(a=>a.kind==='image');const ratio=image?.width&&image.height?image.height/image.width:post.kind==='audio'?1:1.25;const index=heights.indexOf(Math.min(...heights));columns[index].push(post);heights[index]+=Math.min(cardWidth*ratio,460)+90+Math.ceil(post.title.length/15)*20;}
|
||||
return <div ref={element} className="masonry" style={{gridTemplateColumns:`repeat(${count}, minmax(0,1fr))`}}>{columns.map((items,i)=><div className="masonry-column" key={i}>{items.map(p=><PostCard post={p} key={p.id}/>)}</div>)}</div>;
|
||||
}
|
||||
export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:string;tagPath?:string}){
|
||||
const router=useRouter();const [posts,setPosts]=useState(initial.posts);const [cursor,setCursor]=useState(initial.nextCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const generation=useRef(0);
|
||||
useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]);
|
||||
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags.filter(t=>target?t.parent_id===target.id:!t.parent_id);
|
||||
async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}}
|
||||
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · PROMPT</title><meta name="description" content="自己的图文、视频与声音创作空间"/></Head>
|
||||
{current&&<header className="page-header feed-heading"><div><h1>{target?.name||current.name}</h1><p>{current.hint}</p></div></header>}
|
||||
{!section&&<Heatmap data={initial.contributions}/>}
|
||||
{tagPath&&<div className="breadcrumbs"><Link href={`/${section}`}>全部{current?.name}</Link>{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return <span key={path}> / <Link href={`/${section}/${path}`}>{initial.tags.find(t=>t.path===path)?.name}</Link></span>;})}</div>}
|
||||
<div className="feed-toolbar"><div className="feed-tabs">{!section?<><Link className="selected" href="/">全部</Link>{sections.map(s=><Link key={s.path} href={`/${s.path}`}>{s.name}</Link>)}</>:<><Link className={!tagPath?'selected':''} href={`/${section}`}>全部</Link>{children.map(t=><Link key={t.id} href={`/${section}/${t.path}`}>{t.name}</Link>)}</>}</div><span className="sort-label"><ArrowDown size={13}/>最近收录</span></div>
|
||||
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:posts.length?<Masonry posts={posts}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
|
||||
{cursor&&<div className="load-more"><button disabled={busy} onClick={load}>{busy?'正在翻找…':error||'再看一些'}<ArrowDown size={14}/></button></div>}
|
||||
<footer className="page-footer">PROMPT <span>·</span> PROBABLY RENDERING ONE MORE PIXEL TONIGHT</footer>
|
||||
</Shell>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useState,useRef,useEffect } from "react";
|
||||
export function Heatmap({data}:{data:{day:string;total:number}[]}){
|
||||
const [hover,setHover]=useState('');const values=new Map(data.map(d=>[d.day,d.total]));
|
||||
const scroller=useRef<HTMLDivElement>(null);
|
||||
useEffect(()=>{if(scroller.current)scroller.current.scrollLeft=scroller.current.scrollWidth;},[]);
|
||||
const now=new Date();const today=new Date(now.toLocaleDateString('en-CA',{timeZone:'Asia/Shanghai'})+'T00:00:00Z');
|
||||
const start=new Date(today);start.setUTCDate(start.getUTCDate()-364-start.getUTCDay());
|
||||
const days=Array.from({length:371},(_,i)=>{const d=new Date(start);d.setUTCDate(d.getUTCDate()+i);return d;});
|
||||
const total=data.reduce((s,d)=>s+d.total,0);
|
||||
return <section className="activity-card" aria-label="创作贡献热力图"><div className="activity-top"><div><span className="eyebrow">CREATIVE RHYTHM</span><h2>创作记录<span>{total} 次贡献</span></h2></div><span className="activity-year">过去一年 <span>↗</span></span></div>
|
||||
<div className="heatmap-scroll" ref={scroller}><div className="month-row">{Array.from({length:53},(_,week)=>{const d=days[week*7];const changed=week===0||d.getUTCMonth()!==days[(week-1)*7].getUTCMonth();return <span key={week}>{changed?`${d.getUTCMonth()+1}月`:''}</span>;})}</div><div className="heatmap-body"><div className="weekdays"><span>一</span><span>三</span><span>五</span></div><div className="heatmap-grid">{days.map(d=>{const key=d.toISOString().slice(0,10);const n=values.get(key)||0;const future=d>today;const label=`${key} · ${n} 次贡献`;return <button key={key} tabIndex={n>0?0:-1} disabled={future} aria-label={label} title={label} onFocus={()=>setHover(label)} onBlur={()=>setHover('')} onMouseEnter={()=>setHover(label)} onMouseLeave={()=>setHover('')} className={`heat-cell level-${n===0?0:n<3?1:n<6?2:n<10?3:4} ${future?'future':''}`}/>;})}</div></div></div>
|
||||
<div className="activity-bottom"><span>{hover||'每日贡献汇总'}</span><div className="heat-legend">少{[0,1,2,3,4].map(n=><i key={n} className={`heat-cell level-${n}`}/>)}多</div></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Grid2X2,Image,Film,AudioLines,BookOpen,Clapperboard,Sparkles,ArrowUpRight,Layers,Tags,Upload,Menu,X,Search } from "lucide-react";
|
||||
import { useEffect,useState,type ReactNode } from "react";
|
||||
const groups=[
|
||||
{label:'',items:[{href:'/',label:'首页',icon:Grid2X2}]},
|
||||
{label:'素材',items:[{href:'/images',label:'图片',icon:Image},{href:'/videos',label:'视频',icon:Film},{href:'/audio',label:'声音',icon:AudioLines}]},
|
||||
{label:'作品',items:[{href:'/articles',label:'图文',icon:BookOpen},{href:'/motion',label:'动态',icon:Clapperboard}]},
|
||||
{label:'工具',items:[{href:'/library/assets',label:'素材库',icon:Layers},{href:'/library/tags',label:'分类管理',icon:Tags},{href:'/tools/doodle',label:'线条工具',icon:Sparkles}]},
|
||||
];
|
||||
export function Shell({children}:{children:ReactNode}){
|
||||
const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||''));
|
||||
useEffect(()=>setQuery(String(router.query.q||'')),[router.query.q]);
|
||||
return <div className="app-shell"><button className="mobile-menu icon-button" aria-label="展开导航" onClick={()=>setOpen(!open)}>{open?<X size={22}/>:<Menu size={22}/>}</button>
|
||||
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" onClick={()=>setOpen(false)}><span className="brand-mark">P</span><strong>PROMPT</strong></Link>
|
||||
<nav>{groups.map((g,i)=><div className="nav-group" key={i}>{g.label&&<p>{g.label}</p>}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return <Link key={item.href} href={item.href} title={item.label} className={`nav-item ${active?'active':''}`} onClick={()=>setOpen(false)}><item.icon size={24} strokeWidth={active?2.2:1.8}/><span>{item.label}</span></Link>;})}</div>)}</nav>
|
||||
<div className="sidebar-bottom"><Link href="/library/import" className="import-link"><Upload size={22}/><span>导入素材</span><ArrowUpRight size={17}/></Link></div>
|
||||
</aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=/^\/(images|videos|audio|articles|motion)(\/|$)/.test(path)?path:'/';void router.push({pathname:destination,query:query?{q:query}:{}});}}><Search size={24}/><input aria-label="搜索素材包" placeholder="搜索素材、作品和灵感" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>setQuery('')}><X size={20}/></button>}</form><span className="profile-mark" aria-label="PROMPT 个人空间">P</span></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { ComponentType } from "react";
|
||||
// Register code-authored pages here, then reference their key in the posts API.
|
||||
// No page or sample post is inserted into the user's library automatically.
|
||||
export const works: Record<string, { kind: "article" | "motion"; Component: ComponentType }> = {};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { ZodError } from "zod";
|
||||
export function sameOrigin(req: NextApiRequest) {
|
||||
const origin=req.headers.origin;
|
||||
if (origin && new URL(origin).host !== req.headers.host) throw new Error("Cross-origin request rejected");
|
||||
}
|
||||
export function apiError(res: NextApiResponse,error: unknown) {
|
||||
if(error instanceof ZodError) return res.status(400).json({error:"请检查输入内容",issues:error.issues});
|
||||
const message=error instanceof Error?error.message:"";
|
||||
const clientMessages:Record<string,string>={
|
||||
'Register the coded page before creating its post':'请先在代码中注册对应的作品页面',
|
||||
'Referenced asset does not exist':'引用的素材不存在',
|
||||
'A media post needs matching content':'素材包需要包含对应类型的素材',
|
||||
'Cover must reference an image':'封面需要引用一张图片',
|
||||
'Unsupported image format':'支持 JPG、PNG、WebP、GIF 和 AVIF 图片',
|
||||
'File has no video stream':'文件中没有可用的画面',
|
||||
'Select an audio file':'请选择独立的声音文件',
|
||||
'Unsupported media MIME type':'文件类型不受支持',
|
||||
};
|
||||
if(clientMessages[message])return res.status(400).json({error:clientMessages[message]});
|
||||
const code=(error as {code?:string})?.code;
|
||||
if(code==='23505')return res.status(409).json({error:'这个网址标识已经存在,请换一个'});
|
||||
if(code==='23503')return res.status(400).json({error:'引用的分类或素材不存在'});
|
||||
if(["Invalid cursor","Tag not found","Cross-origin request rejected"].includes(message)) return res.status(400).json({error:message});
|
||||
console.error("API failure",error instanceof Error?error.message:"Unknown error");
|
||||
return res.status(500).json({error:"操作没有完成,请稍后重试"});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
|
||||
const globalDb = globalThis as unknown as { promptPool?: Pool };
|
||||
export function db() {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
|
||||
return globalDb.promptPool ??= new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
|
||||
}
|
||||
export async function transaction<T>(run: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await db().connect();
|
||||
try { await client.query("BEGIN"); const result = await run(client); await client.query("COMMIT"); return result; }
|
||||
catch (error) { await client.query("ROLLBACK"); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { db } from "./db";
|
||||
import { decodeCursor, encodeCursor, type FeedData, type Post, type PostKind, type Tag } from "./model";
|
||||
import { works } from "../content/registry";
|
||||
const tagTree = `WITH RECURSIVE tree AS (SELECT *,slug::text AS path FROM tags WHERE parent_id IS NULL UNION ALL SELECT t.*,tree.path||'/'||t.slug FROM tags t JOIN tree ON t.parent_id=tree.id) SELECT * FROM tree`;
|
||||
export async function tags(): Promise<Tag[]> { return (await db().query(`${tagTree} ORDER BY path`)).rows; }
|
||||
const expand = `SELECT p.*,
|
||||
COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.role) FROM asset_variants v WHERE v.asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets,
|
||||
COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS tags FROM posts p`;
|
||||
function serialize(posts: Post[], allTags: Tag[]): Post[] {
|
||||
return posts.map(p => ({...p, created_at: new Date(p.created_at).toISOString(), tags:p.tags.map(t=>allTags.find(a=>a.id===t.id)!).filter(Boolean)}));
|
||||
}
|
||||
export async function getPost(slug: string): Promise<Post | null> {
|
||||
const result = await db().query(`${expand} WHERE p.slug=$1`,[slug]);
|
||||
return serialize(result.rows,await tags())[0] || null;
|
||||
}
|
||||
export async function feed(options: {kind?: PostKind; path?: string; cursor?: string; q?: string} = {}): Promise<FeedData> {
|
||||
const allTags=await tags(); const target=options.path ? allTags.find(t=>t.path===options.path) : undefined;
|
||||
if(options.path&&!target) throw new Error("Tag not found");
|
||||
const cursor=decodeCursor(options.cursor); const values: unknown[]=[];
|
||||
const where=[`((p.kind IN ('article','motion') AND p.page_key=ANY($1::text[])) OR EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role='content' AND a.kind=p.kind AND a.storage_key IS NOT NULL AND a.byte_size>0))`];
|
||||
values.push(Object.keys(works));
|
||||
if(options.kind){values.push(options.kind);where.push(`p.kind=$${values.length}`);}
|
||||
if(target){values.push(allTags.filter(t=>t.path===target.path||t.path.startsWith(target.path+"/")).map(t=>t.id));where.push(`EXISTS(SELECT 1 FROM post_tags WHERE post_id=p.id AND tag_id=ANY($${values.length}::uuid[]))`);}
|
||||
if(options.q){values.push(`%${options.q.replace(/[\\%_]/g,"\\$&")}%`);where.push(`p.title ILIKE $${values.length}`);}
|
||||
if(cursor){values.push(...cursor);where.push(`(p.created_at,p.id)<($${values.length-1}::timestamptz,$${values.length}::uuid)`);}
|
||||
const rows=(await db().query(`${expand} WHERE ${where.join(' AND ')} ORDER BY p.created_at DESC,p.id DESC LIMIT 25`,values)).rows as Post[];
|
||||
const hasMore=rows.length>24; const posts=serialize(rows.slice(0,24),allTags); const last=posts.at(-1);
|
||||
const contributions=(await db().query(`SELECT to_char(occurred_at AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day,sum(amount)::integer AS total FROM contribution_events WHERE occurred_at>=now()-interval '366 days' GROUP BY day ORDER BY day`)).rows;
|
||||
return {posts,tags:allTags,contributions,nextCursor:hasMore&&last?encodeCursor(last.created_at,last.id):null};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const sections = [
|
||||
{ path: "images", kind: "image", name: "图片素材", hint: "照片、插画与可以反复使用的画面" },
|
||||
{ path: "videos", kind: "video", name: "视频素材", hint: "片段与镜头,声音随画面一起收好" },
|
||||
{ path: "audio", kind: "audio", name: "声音素材", hint: "音乐、环境声与声音片段" },
|
||||
{ path: "articles", kind: "article", name: "图文作品", hint: "用网页编写,慢慢打磨每一页" },
|
||||
{ path: "motion", kind: "motion", name: "动态作品", hint: "让网页本身成为时间里的画面" },
|
||||
] as const;
|
||||
export type PostKind = typeof sections[number]["kind"];
|
||||
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[] };
|
||||
export type Tag = { id: string; parent_id: string | null; name: string; slug: string; path: string };
|
||||
export type Post = { id: string; slug: string; kind: PostKind; title: string; created_at: string; page_key: string | null; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
|
||||
export type FeedData = { posts: Post[]; tags: Tag[]; contributions: { day: string; total: number }[]; nextCursor: string | null; unavailable?: boolean };
|
||||
export function validSection(path: string) { return sections.find(s => s.path === path); }
|
||||
export const mediaUrl = (id: string, variant?: string) => `/api/media/${id}${variant ? `?variant=${variant}` : ""}`;
|
||||
export function encodeCursor(date: string, id: string) { return Buffer.from(JSON.stringify([date,id])).toString("base64url"); }
|
||||
export function decodeCursor(value?: string) {
|
||||
if (!value) return null;
|
||||
try { const [date,id] = JSON.parse(Buffer.from(value,"base64url").toString());
|
||||
if (!Number.isNaN(Date.parse(date)) && /^[0-9a-f-]{36}$/.test(id)) return [date,id];
|
||||
} catch { /* invalid pagination cursor */ }
|
||||
throw new Error("Invalid cursor");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
export const run=promisify(execFile);
|
||||
export async function probe(file:string) {
|
||||
const {stdout}=await run(process.env.FFPROBE_PATH||'ffprobe',['-v','error','-protocol_whitelist','file,pipe','-show_streams','-show_format','-of','json',file],{timeout:30000,maxBuffer:2*1024*1024});
|
||||
return JSON.parse(stdout) as {streams:{codec_type:string;width?:number;height?:number}[];format:{duration?:string;format_name?:string}};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, CreateBucketCommand } from "@aws-sdk/client-s3";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
let instance: S3Client;
|
||||
export function storage() {
|
||||
return instance ??= new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT, region: "us-east-1", forcePathStyle: true,
|
||||
credentials: { accessKeyId: process.env.S3_ACCESS_KEY!, secretAccessKey: process.env.S3_SECRET_KEY! },
|
||||
requestChecksumCalculation: "WHEN_REQUIRED", responseChecksumValidation: "WHEN_REQUIRED",
|
||||
});
|
||||
}
|
||||
export const bucket = () => process.env.S3_BUCKET || "prompt";
|
||||
export async function ensureBucket() {
|
||||
try { await storage().send(new HeadBucketCommand({ Bucket: bucket() })); }
|
||||
catch (e) {
|
||||
if ((e as { $metadata?: { httpStatusCode: number } }).$metadata?.httpStatusCode !== 404) throw e;
|
||||
await storage().send(new CreateBucketCommand({ Bucket: bucket() }));
|
||||
}
|
||||
}
|
||||
export async function putFile(key: string, path: string, mime: string) {
|
||||
const size = (await stat(path)).size;
|
||||
await storage().send(new PutObjectCommand({ Bucket: bucket(), Key: key, Body: createReadStream(path), ContentLength: size, ContentType: mime }));
|
||||
return size;
|
||||
}
|
||||
export const getObject = (key: string, range?: string) => storage().send(new GetObjectCommand({ Bucket: bucket(), Key: key, Range: range }));
|
||||
export const removeObject = (key: string) => storage().send(new DeleteObjectCommand({ Bucket: bucket(), Key: key }));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { transaction } from "./db";
|
||||
import { works } from "../content/registry";
|
||||
export const postInput=z.object({
|
||||
slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,119}$/).optional(),
|
||||
kind:z.enum(["image","video","audio","article","motion"]),title:z.string().trim().min(1).max(200),
|
||||
assets:z.array(z.object({id:z.uuid(),role:z.enum(["content","caption"]).default("content")})).max(100).default([]),
|
||||
text:z.string().trim().max(100000).optional(),tags:z.array(z.uuid()).max(50).default([]),
|
||||
coverAssetId:z.uuid().optional(),pageKey:z.string().max(120).optional(),
|
||||
});
|
||||
export async function createPost(input:z.infer<typeof postInput>) {
|
||||
const id=randomUUID(); const slug=input.slug||id;
|
||||
if(["article","motion"].includes(input.kind)&&(!input.pageKey||works[input.pageKey]?.kind!==input.kind)) throw new Error("Register the coded page before creating its post");
|
||||
return transaction(async client=>{
|
||||
const ids=[...new Set(input.assets.map(a=>a.id))];
|
||||
const assets=ids.length?(await client.query('SELECT id,kind FROM assets WHERE id=ANY($1::uuid[])',[ids])).rows:[];
|
||||
if(assets.length!==ids.length) throw new Error("Referenced asset does not exist");
|
||||
if(["image","video","audio"].includes(input.kind)&&!input.assets.some(link=>link.role==='content'&&assets.some(a=>a.id===link.id&&a.kind===input.kind))) throw new Error("A media post needs matching content");
|
||||
if(input.coverAssetId && !(await client.query("SELECT 1 FROM assets WHERE id=$1 AND kind='image'",[input.coverAssetId])).rowCount) throw new Error("Cover must reference an image");
|
||||
await client.query('INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id) VALUES($1,$2,$3,$4,$5,$6)',[id,slug,input.kind,input.title,input.pageKey||null,input.coverAssetId||null]);
|
||||
for(const [position,a] of input.assets.entries()) await client.query('INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,$4)',[id,a.id,position,a.role]);
|
||||
if(input.text){const textId=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[textId,input.text]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[id,textId,input.assets.length]);}
|
||||
for(const tag of new Set(input.tags)) await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[id,tag]);
|
||||
return {id,slug};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import { Feed } from "@/components/Feed";
|
||||
import { feed } from "@/lib/library";
|
||||
import { validSection } from "@/lib/model";
|
||||
export default Feed;
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query})=>{
|
||||
const section=String(params?.section||'');const item=validSection(section);if(!item)return {notFound:true};
|
||||
const tagPath=Array.isArray(params?.taxonomy)?params.taxonomy.join('/'):'';
|
||||
try{return {props:{initial:await feed({kind:item.kind,path:tagPath,q:String(query.q||'').slice(0,200)}),section,tagPath}};}
|
||||
catch(e){if(e instanceof Error&&e.message==='Tag not found')return {notFound:true};return {props:{initial:{posts:[],tags:[],contributions:[],nextCursor:null,unavailable:true},section,tagPath}};}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextApiRequest,NextApiResponse } from "next";
|
||||
import { feed } from "@/lib/library";
|
||||
import { validSection } from "@/lib/model";
|
||||
import { apiError } from "@/lib/api";
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(req.method!=='GET')return res.status(405).end();
|
||||
try {const section=String(req.query.section||'');if(section&&!validSection(section))return res.status(400).json({error:'未知分类'});
|
||||
res.setHeader('Cache-Control','no-store');return res.json(await feed({kind:validSection(section)?.kind,path:String(req.query.path||''),cursor:String(req.query.cursor||''),q:String(req.query.q||'').slice(0,200)}));
|
||||
}catch(e){return apiError(res,e);}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextApiRequest,NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
import { db } from "@/lib/db";
|
||||
import { getObject } from "@/lib/storage";
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();
|
||||
if(!/^[0-9a-f-]{36}$/.test(String(req.query.id)))return res.status(404).end();
|
||||
try{
|
||||
const variant=String(req.query.variant||'');
|
||||
if(variant&&!['poster','silent_video','audio_track'].includes(variant))return res.status(404).end();
|
||||
const result=variant?await db().query('SELECT storage_key,mime_type FROM asset_variants WHERE asset_id=$1 AND role=$2',[req.query.id,variant]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[req.query.id]);
|
||||
if(!result.rows[0]?.storage_key)return res.status(404).end();
|
||||
const object=await getObject(result.rows[0].storage_key,req.headers.range);
|
||||
res.status(object.ContentRange?206:200);
|
||||
res.setHeader('Content-Type',object.ContentType||'application/octet-stream');res.setHeader('X-Content-Type-Options','nosniff');res.setHeader('Accept-Ranges','bytes');
|
||||
if(object.ContentLength!==undefined)res.setHeader('Content-Length',object.ContentLength);
|
||||
if(object.ContentRange)res.setHeader('Content-Range',object.ContentRange);
|
||||
res.setHeader('Cache-Control','private, max-age=3600');
|
||||
const stream=object.Body as Readable;
|
||||
if(req.method==='HEAD'){stream.destroy();return res.end();}
|
||||
res.on('close',()=>stream.destroy());stream.on('error',()=>res.destroy());return stream.pipe(res);
|
||||
}catch(e){const code=(e as {$metadata?:{httpStatusCode:number}}).$metadata?.httpStatusCode;return res.status(code===416?416:404).end();}
|
||||
}
|
||||
export const config={api:{responseLimit:false}};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextApiRequest,NextApiResponse } from "next";
|
||||
import { createPost,postInput } from "@/lib/write";
|
||||
import { sameOrigin,apiError } from "@/lib/api";
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(req.method!=='POST')return res.status(405).end();
|
||||
try{sameOrigin(req);return res.status(201).json(await createPost(postInput.parse(req.body)));}catch(e){return apiError(res,e);}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextApiRequest,NextApiResponse } from 'next';
|
||||
import { db } from '@/lib/db';
|
||||
import { storage,bucket } from '@/lib/storage';
|
||||
import { HeadBucketCommand } from '@aws-sdk/client-s3';
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(req.method!=='GET')return res.status(405).end();
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
try{await db().query('SELECT 1 FROM schema_migrations LIMIT 1');await storage().send(new HeadBucketCommand({Bucket:bucket()}));return res.json({status:'ok',database:'ready',objects:'ready'});}catch{return res.status(503).json({status:'unavailable'});}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NextApiRequest,NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { tags } from "@/lib/library";
|
||||
import { sameOrigin,apiError } from "@/lib/api";
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
try{
|
||||
if(req.method==='GET')return res.json(await tags());
|
||||
if(req.method!=='POST')return res.status(405).end();
|
||||
sameOrigin(req);
|
||||
const data=z.object({name:z.string().trim().min(1).max(60),slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/),parentId:z.uuid().nullable().default(null)}).parse(req.body);
|
||||
const id=randomUUID(); await db().query('INSERT INTO tags(id,name,slug,parent_id) VALUES($1,$2,$3,$4)',[id,data.name,data.slug,data.parentId]);return res.status(201).json({id});
|
||||
}catch(e){return apiError(res,e);}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { NextApiRequest,NextApiResponse } from "next";
|
||||
import formidable,{type File} from "formidable";
|
||||
import { randomUUID,createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { transaction } from "@/lib/db";
|
||||
import { putFile,removeObject } from "@/lib/storage";
|
||||
import { probe } from "@/lib/probe";
|
||||
import { sameOrigin,apiError } from "@/lib/api";
|
||||
export const config={api:{bodyParser:false}};
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(req.method!=='POST')return res.status(405).end();
|
||||
const temporary:string[]=[];const uploaded:string[]=[];
|
||||
try{
|
||||
sameOrigin(req);
|
||||
const form=formidable({maxFileSize:250*1024*1024,maxTotalFileSize:1024*1024*1024,maxFiles:16,allowEmptyFiles:false});
|
||||
form.on('fileBegin',(_name,file)=>temporary.push(file.filepath));
|
||||
const [fields,files]=await form.parse(req);
|
||||
const kind=z.enum(['image','video','audio']).parse(fields.kind?.[0]);
|
||||
const title=z.string().trim().min(1).max(200).parse(fields.title?.[0]);
|
||||
const description=z.string().trim().max(100000).parse(fields.description?.[0]||'');
|
||||
const tagIds=z.array(z.uuid()).max(50).parse(JSON.parse(fields.tags?.[0]||'[]'));
|
||||
const items=Object.values(files).flat().filter(Boolean) as File[];
|
||||
if(!items.length) return res.status(400).json({error:'请选择文件'});
|
||||
const records:{id:string;key:string;mime:string;width:number|undefined;height:number|undefined;duration:number|undefined;hasAudio:boolean|null;size:number;hash:string;filename:string}[]=[];
|
||||
for(const file of items){
|
||||
let mime='';let width:number|undefined,height:number|undefined,duration:number|undefined,hasAudio:boolean|null=null;
|
||||
if(kind==='image'){
|
||||
const meta=await sharp(file.filepath,{limitInputPixels:100000000}).metadata();
|
||||
const formats:Record<string,string>={jpeg:'image/jpeg',png:'image/png',webp:'image/webp',gif:'image/gif',avif:'image/avif'};
|
||||
mime=formats[meta.format||''];if(!mime)throw new Error('Unsupported image format');width=meta.width;height=meta.height;
|
||||
}else{
|
||||
const meta=await probe(file.filepath);const video=meta.streams.find(s=>s.codec_type==='video');const audio=meta.streams.some(s=>s.codec_type==='audio');
|
||||
if(kind==='video'&&!video)throw new Error('File has no video stream');if(kind==='audio'&&(!audio||video))throw new Error('Select an audio file');
|
||||
if(!file.mimetype?.startsWith(kind+'/'))throw new Error('Unsupported media MIME type');
|
||||
mime=file.mimetype;width=video?.width;height=video?.height;duration=Number(meta.format.duration)||undefined;hasAudio=kind==='video'?audio:null;
|
||||
}
|
||||
const hash=createHash('sha256');for await(const chunk of createReadStream(file.filepath))hash.update(chunk);
|
||||
const id=randomUUID(),key=`originals/${id}`;await putFile(key,file.filepath,mime);uploaded.push(key);
|
||||
records.push({id,key,mime,width,height,duration,hasAudio,size:file.size,hash:hash.digest('hex'),filename:file.originalFilename||'未命名素材'});
|
||||
}
|
||||
const postId=randomUUID();
|
||||
await transaction(async client=>{
|
||||
await client.query('INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,$3,$4)',[postId,postId,kind,title]);
|
||||
for(const [position,a] of records.entries()){
|
||||
await client.query('INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height,duration,has_audio,processing_status) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',[a.id,kind,a.filename,a.key,a.mime,a.size,a.hash,a.width,a.height,a.duration,a.hasAudio,kind==='video'?'pending':'ready']);
|
||||
await client.query('INSERT INTO post_assets(post_id,asset_id,position) VALUES($1,$2,$3)',[postId,a.id,position]);
|
||||
for(const tag of new Set(tagIds))await client.query('INSERT INTO asset_tags(asset_id,tag_id) VALUES($1,$2)',[a.id,tag]);
|
||||
if(kind==='video')await client.query('INSERT INTO media_jobs(id,asset_id) VALUES($1,$2)',[randomUUID(),a.id]);
|
||||
}
|
||||
if(description){const id=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[id,description]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[postId,id,records.length]);}
|
||||
for(const tag of new Set(tagIds))await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[postId,tag]);
|
||||
});
|
||||
uploaded.length=0;return res.status(201).json({id:postId,slug:postId,assets:records.map(a=>a.id)});
|
||||
}catch(e){if(e instanceof Error && (/maxFileSize|maxTotalFileSize|maxFiles/.test(e.message)))return res.status(413).json({error:'最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB'});return apiError(res,e);}
|
||||
finally{await Promise.allSettled(temporary.map(p=>rm(p,{force:true})));await Promise.allSettled(uploaded.map(removeObject));}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import { Feed } from "@/components/Feed";
|
||||
import { feed } from "@/lib/library";
|
||||
export default Feed;
|
||||
export const getServerSideProps:GetServerSideProps=async({query})=>{
|
||||
try{return {props:{initial:await feed({q:String(query.q||'').slice(0,200)})}};}
|
||||
catch{return {props:{initial:{posts:[],tags:[],contributions:[],nextCursor:null,unavailable:true}}};}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Layers,Copy,Upload } from "lucide-react";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { db } from "@/lib/db";
|
||||
import { mediaUrl,type Asset } from "@/lib/model";
|
||||
type Row=Asset&{uses:number};
|
||||
export default function Assets({assets,next}:{assets:Row[];next:string|null}){
|
||||
const [copied,setCopied]=useState('');
|
||||
return <Shell><Head><title>原始素材 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">ONE ASSET, MANY POSSIBILITIES</span><h1>素材库</h1><p>查看全部原始文件和文字,复用已收录的素材。</p></div><Link className="secondary-button" href="/library/import"><Upload size={15}/>导入素材</Link></header>
|
||||
{assets.length?<div className="asset-grid">{assets.map(a=><article className="asset-tile" key={a.id}><div className="asset-preview">{a.kind==='image'?<img src={mediaUrl(a.id)} alt={a.filename||''} loading="lazy"/>:a.kind==='audio'?<audio controls preload="none" src={mediaUrl(a.id)}/>:a.kind==='video'?<video controls preload="metadata" src={mediaUrl(a.id)}/>:<p>{a.body?.slice(0,300)}</p>}</div><div className="asset-tile-info"><h3>{a.filename||'文字片段'}</h3><span>{a.uses} 个素材包正在引用</span><button aria-label="复制素材 ID" onClick={async()=>{await navigator.clipboard.writeText(a.id);setCopied(a.id);}}><Copy size={14}/>{copied===a.id?'已复制':'复制 ID'}</button></div></article>)}</div>:<div className="empty-state"><Layers size={38}/><h2>素材架还是空的</h2><p>导入的图片、视频、声音,以及素材包中的文字都会保存在这里。</p></div>}
|
||||
{next&&<Link className="secondary-button" href={'/library/assets?before='+encodeURIComponent(next)}>下一页素材</Link>}
|
||||
</Shell>;
|
||||
}
|
||||
export const getServerSideProps:GetServerSideProps=async({query})=>{
|
||||
const before=String(query.before||'');const valid=/^[0-9a-f-]{36}$/.test(before);
|
||||
const result=await db().query(`SELECT a.*,(SELECT count(DISTINCT post_id)::integer FROM post_assets WHERE asset_id=a.id) AS uses,'[]'::json AS variants FROM assets a ${valid?'WHERE (a.created_at,a.id)<(SELECT created_at,id FROM assets WHERE id=$1)':''} ORDER BY a.created_at DESC,a.id DESC LIMIT 49`,valid?[before]:[]);
|
||||
const rows=result.rows.slice(0,48).map(({created_at,...a})=>a);return {props:{assets:rows,next:result.rows.length>48?rows.at(-1).id:null}};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import { useState,type FormEvent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Upload,Image,Film,AudioLines,ArrowUpRight,X,Check } from "lucide-react";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { tags } from "@/lib/library";
|
||||
import type { Tag } from "@/lib/model";
|
||||
export default function Import({allTags}:{allTags:Tag[]}){
|
||||
const router=useRouter();const [kind,setKind]=useState(['image','video','audio'].includes(String(router.query.kind))?String(router.query.kind):'image');
|
||||
const [files,setFiles]=useState<File[]>([]);const [title,setTitle]=useState('');const [description,setDescription]=useState('');const [selected,setSelected]=useState<string[]>([]);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
|
||||
function choose(list:FileList|null){if(!list)return;const items=Array.from(list);setFiles(items);if(!title&&items[0])setTitle(items[0].name.replace(/\.[^.]+$/,''));}
|
||||
async function submit(e:FormEvent){e.preventDefault();setError('');setBusy(true);try{const data=new FormData();data.set('kind',kind);data.set('title',title);data.set('description',description);data.set('tags',JSON.stringify(selected));files.forEach(f=>data.append('files',f));const res=await fetch('/api/upload',{method:'POST',body:data});const result=await res.json();if(!res.ok)throw new Error(result.error);await router.push('/p/'+result.slug);}catch(e){setError(e instanceof Error?e.message:'上传失败');}finally{setBusy(false);}}
|
||||
return <Shell><Head><title>素材入库 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">MAKE ROOM FOR INSPIRATION</span><h1>导入素材</h1><p>支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。</p></div></header><form className="editor-panel" onSubmit={submit}>
|
||||
<div className="kind-picker">{[{id:'image',name:'图片',Icon:Image},{id:'video',name:'视频',Icon:Film},{id:'audio',name:'声音',Icon:AudioLines}].map(t=><button type="button" key={t.id} disabled={busy} className={kind===t.id?'selected':''} onClick={()=>{setKind(t.id);setFiles([]);}}><t.Icon size={19}/>{t.name}</button>)}</div>
|
||||
<label className="drop-zone" onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();if(!busy)choose(e.dataTransfer.files);}}><input type="file" multiple disabled={busy} accept={kind==='image'?'image/jpeg,image/png,image/webp,image/gif,image/avif':kind+'/*'} onChange={e=>choose(e.target.files)}/><span className="upload-circle"><Upload size={24}/></span><strong>选择文件或拖放到这里</strong><span>最多 16 个文件,单个不超过 250 MB</span></label>
|
||||
{files.length>0&&<ul className="file-list">{files.map((f,i)=><li key={i}><Check size={15}/><span>{f.name}</span><small>{(f.size/1024/1024).toFixed(1)} MB</small><button type="button" aria-label={`移除 ${f.name}`} disabled={busy} onClick={()=>setFiles(a=>a.filter((_,j)=>i!==j))}><X size={14}/></button></li>)}</ul>}
|
||||
<div className="form-field"><label htmlFor="title">标题</label><input id="title" required maxLength={200} value={title} onChange={e=>setTitle(e.target.value)} placeholder="添加标题"/></div>
|
||||
<div className="form-field"><label htmlFor="description">描述 <small>选填</small></label><textarea id="description" maxLength={100000} rows={4} value={description} onChange={e=>setDescription(e.target.value)} placeholder="拍摄场景、灵感、下次想怎么用…"/></div>
|
||||
{allTags.length>0&&<div className="form-field"><label>放进哪些分类 <small>可多选</small></label><div className="tag-picker">{allTags.map(t=><label key={t.id}><input type="checkbox" checked={selected.includes(t.id)} onChange={e=>setSelected(s=>e.target.checked?[...s,t.id]:s.filter(id=>id!==t.id))}/>{allTags.filter(a=>t.path.startsWith(a.path+'/')).map(a=>a.name+' / ')}{t.name}</label>)}</div></div>}
|
||||
{kind==='video'&&<p className="form-note">视频入库后会自动分离画面和声音。有声、无声都会标记;分离出的音轨始终跟随原视频。</p>}
|
||||
{error&&<p role="alert" className="error-message">{error}</p>}<div className="form-actions"><span>原文件独立保存,素材包只记录引用。</span><button className="primary-button" disabled={!files.length||busy}>{busy?'正在入库,请稍候…':'导入素材'}<ArrowUpRight size={16}/></button></div>
|
||||
</form></Shell>;
|
||||
}
|
||||
export const getServerSideProps:GetServerSideProps=async()=>({props:{allTags:await tags()}});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import { useState,type FormEvent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Hash,CornerDownRight } from "lucide-react";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { tags } from "@/lib/library";
|
||||
import type { Tag } from "@/lib/model";
|
||||
export default function TagsPage({allTags}:{allTags:Tag[]}){
|
||||
const router=useRouter();const [error,setError]=useState('');const [busy,setBusy]=useState(false);
|
||||
async function submit(e:FormEvent<HTMLFormElement>){e.preventDefault();const form=e.currentTarget;const data=new FormData(form);setBusy(true);setError('');try{const r=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:data.get('name'),slug:data.get('slug'),parentId:data.get('parentId')||null})});if(!r.ok)throw new Error((await r.json()).error);form.reset();await router.replace(router.asPath);}catch(e){setError(e instanceof Error?e.message:'保存失败');}finally{setBusy(false);}}
|
||||
return <Shell><Head><title>分类标签 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">A PLACE FOR EVERY IDEA</span><h1>分类管理</h1><p>按层级整理标签。同一个素材包可以同时归入多个分类。</p></div></header><div className="management-grid"><section className="surface"><h2>分类树 <small>{allTags.length} 个分类</small></h2>{allTags.length?allTags.map(t=><div className="taxonomy-row" key={t.id} style={{paddingLeft:18+t.path.split('/').length*18}}>{t.parent_id?<CornerDownRight size={16}/>:<Hash size={16}/>}<strong>{t.name}</strong><code>/{t.path}</code></div>):<div className="quiet-empty">还没有分类。先从一个大方向开始。</div>}</section><form className="surface compact-form" onSubmit={submit}><h2>新建分类</h2><label>分类名称<input required name="name" maxLength={60} placeholder="比如:手绘线条"/></label><label>网址标识<input required name="slug" pattern="[a-z0-9][a-z0-9-]*" maxLength={60} placeholder="hand-drawn"/><small>使用小写字母、数字和连字符。</small></label><label>上级分类<select name="parentId"><option value="">作为一级分类</option>{allTags.map(t=><option key={t.id} value={t.id}>{t.path.split('/').map((_,i)=>allTags.find(a=>a.path===t.path.split('/').slice(0,i+1).join('/'))?.name).join(' / ')}</option>)}</select></label>{error&&<p className="error-message" role="alert">{error}</p>}<button className="primary-button" disabled={busy}>{busy?'保存中…':'保存分类'}</button></form></div></Shell>;
|
||||
}
|
||||
export const getServerSideProps:GetServerSideProps=async()=>({props:{allTags:await tags()}});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useEffect,useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { ArrowLeft,ChevronLeft,ChevronRight,Volume2,VolumeX,Download,Copy,Layers } from "lucide-react";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { getPost } from "@/lib/library";
|
||||
import { mediaUrl,sections,type Post } from "@/lib/model";
|
||||
import { works } from "@/content/registry";
|
||||
export default function PostDetail({post}:{post:Post}){
|
||||
const [index,setIndex]=useState(0);const [copied,setCopied]=useState(false);const router=useRouter();
|
||||
const media=post.assets.filter(a=>a.kind!=='text');const selected=media[index];const section=sections.find(s=>s.kind===post.kind)!;
|
||||
useEffect(()=>{if(post.assets.some(a=>['pending','processing'].includes(a.processing_status))){const timer=setTimeout(()=>{void router.replace(router.asPath,undefined,{scroll:false});},3000);return ()=>clearTimeout(timer);}},[post,router]);
|
||||
const Work=post.page_key?works[post.page_key]?.Component:undefined;
|
||||
return <Shell><Head><title>{post.title} · PROMPT</title></Head><div className="detail-top"><Link href={`/${section.path}`}><ArrowLeft size={17}/>返回{section.name}</Link><span>{section.name}</span></div>
|
||||
<div className={`detail-layout ${Work?'coded-detail':''}`}><section className="detail-media">
|
||||
{Work?<Work/>:selected?<><div className={`media-stage stage-${selected.kind}`}>
|
||||
{selected.kind==='image'&&<img src={mediaUrl(selected.id)} alt={selected.filename||post.title}/>}
|
||||
{selected.kind==='video'&&<video key={selected.id} controls playsInline preload="metadata" poster={selected.variants.includes('poster')?mediaUrl(selected.id,'poster'):undefined} src={mediaUrl(selected.id,selected.variants.includes('silent_video')?'silent_video':undefined)}/>}
|
||||
{selected.kind==='audio'&&<div className="audio-stage"><div className="vinyl"><span/></div><h3>{selected.filename}</h3><audio key={selected.id} controls preload="metadata" src={mediaUrl(selected.id)}/></div>}
|
||||
{media.length>1&&<><button className="gallery-prev icon-button" disabled={index===0} aria-label="上一个素材" onClick={()=>setIndex(i=>i-1)}><ChevronLeft/></button><button className="gallery-next icon-button" disabled={index===media.length-1} aria-label="下一个素材" onClick={()=>setIndex(i=>i+1)}><ChevronRight/></button><span className="gallery-position">{index+1} / {media.length}</span></>}
|
||||
</div>
|
||||
{selected.kind==='video'&&<div className="video-companion"><div><span className="pill">{selected.has_audio?<><Volume2 size={14}/>原视频有声音</>:<><VolumeX size={14}/>原视频无声音</>}</span><small>{selected.processing_status==='ready'?'画面已分离,可独立预览与复用':selected.processing_status==='failed'?'音画分离未完成,原文件仍可使用':'正在后台拆分画面与声音…'}</small></div>{selected.variants.includes('audio_track')&&<div className="bound-audio"><span><Volume2 size={16}/>这段视频的声音</span><audio controls preload="metadata" src={mediaUrl(selected.id,'audio_track')}/><small>音轨随视频保存,不属于独立声音素材。</small></div>}</div>}
|
||||
{media.length>1&&<div className="thumbnail-strip">{media.map((a,i)=><button key={a.id} className={i===index?'selected':''} onClick={()=>setIndex(i)} aria-label={`查看第 ${i+1} 个素材`}>{a.kind==='image'?<img src={mediaUrl(a.id)} alt=""/>:a.variants.includes('poster')?<img src={mediaUrl(a.id,'poster')} alt=""/>:<span>{a.kind==='audio'?'声音':'视频'} {i+1}</span>}</button>)}</div>}</>:<div className="empty-state"><Layers/><p>这个素材包还没有可展示的内容。</p></div>}
|
||||
</section><aside className="detail-info"><span className="eyebrow">A PIECE OF YOUR COLLECTION</span><h1>{post.title}</h1><div className="detail-date">{new Date(post.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}<span>· {media.length} 份素材</span></div>
|
||||
<div className="description">{post.assets.filter(a=>a.kind==='text').map(a=><p key={a.id}>{a.body}</p>)}</div>
|
||||
<div className="tag-list">{post.tags.map(t=><Link key={t.id} href={`/${section.path}/${t.path}`}># {t.name}</Link>)}</div>
|
||||
{selected&&<div className="asset-facts"><h3>素材信息</h3><dl><dt>原文件</dt><dd>{selected.filename}</dd>{selected.width&&<><dt>尺寸</dt><dd>{selected.width} × {selected.height}</dd></>}{selected.duration&&<><dt>时长</dt><dd>{selected.duration.toFixed(1)} 秒</dd></>}<dt>复用方式</dt><dd>引用同一素材,无需再次上传</dd></dl><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(selected.id);setCopied(true);setTimeout(()=>setCopied(false),2000);}}><Copy size={14}/>{copied?'已复制素材 ID':'复制素材 ID'}</button><a className="text-link" href={mediaUrl(selected.id)} download><Download size={14}/>打开原文件</a></div>}
|
||||
</aside></div>
|
||||
</Shell>;
|
||||
}
|
||||
export const getServerSideProps:GetServerSideProps=async({params})=>{
|
||||
const post=await getPost(String(params?.slug||''));return post?{props:{post}}:{notFound:true};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import Head from "next/head";
|
||||
import { useState } from "react";
|
||||
import { Download,Copy,Sparkles } from "lucide-react";
|
||||
import { Shell } from "@/components/Shell";
|
||||
const strokes=[
|
||||
{name:'睡眼',hint:'让石头和面包打一个盹',path:'M22 54 Q38 72 54 54 M74 54 Q90 72 106 54 M39 65 l-3 8 M89 65 l3 8'},
|
||||
{name:'弯弯箭头',hint:'把目光引向一个小细节',path:'M20 87 Q15 26 92 37 M77 22 L100 37 L80 53'},
|
||||
{name:'一朵小云',hint:'给天空加一点想象',path:'M30 82 C3 83 10 52 31 55 C26 22 71 20 75 47 C100 33 120 64 101 80 M32 82 L99 82'},
|
||||
{name:'一点心动',hint:'给喜欢的东西圈个重点',path:'M65 99 C-4 57 35 15 64 45 C92 11 133 57 65 99'},
|
||||
{name:'闪一下',hint:'把光画成一个小小的惊喜',path:'M64 20 Q61 59 28 64 Q60 67 64 106 Q69 68 102 64 Q69 60 64 20 M103 17 L103 35 M94 26 L112 26'},
|
||||
{name:'草在长',hint:'沿着画面边缘悄悄长出来',path:'M24 101 Q25 73 10 64 M24 101 Q38 71 39 51 M60 101 Q49 74 48 66 M60 101 Q75 65 72 35 M96 101 Q87 71 86 62 M96 101 Q105 76 116 72'},
|
||||
{name:'松松的圈',hint:'不必画得很圆',path:'M93 30 C44 8 11 40 21 79 C32 118 110 110 110 61 C109 26 76 18 45 25'},
|
||||
{name:'波浪下划线',hint:'留给一句想记住的话',path:'M13 67 Q23 48 33 67 T53 67 T73 67 T93 67 T113 67'},
|
||||
];
|
||||
export default function Doodle(){
|
||||
const [selected,setSelected]=useState(0);const [color,setColor]=useState('#fffaf0');const [copied,setCopied]=useState(false);
|
||||
const svg=()=>`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 128 128"><path d="${strokes[selected].path}" fill="none" stroke="${color}" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
function download(){const url=URL.createObjectURL(new Blob([svg()],{type:'image/svg+xml'}));const a=document.createElement('a');a.href=url;a.download=`prompt-doodle-${selected+1}.svg`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
return <Shell><Head><title>线条灵感 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">SMALL LINES, LITTLE STORIES</span><h1>线条工具</h1><p>选择图形、调整颜色,下载后用于照片涂鸦。</p></div><span className="pill"><Sparkles size={14}/>照片涂鸦工具箱</span></header><div className="doodle-layout"><section className="doodle-catalog">{strokes.map((s,i)=><button key={s.name} onClick={()=>{setSelected(i);setCopied(false);}} className={`doodle-card ${i===selected?'selected':''}`}><div><svg viewBox="0 0 128 128"><path d={s.path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><strong>{s.name}</strong><small>{s.hint}</small></button>)}</section><aside className="surface doodle-inspector"><span className="eyebrow">A LITTLE MARK</span><div className="doodle-large"><svg viewBox="0 0 128 128"><path d={strokes[selected].path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><h2>{strokes[selected].name}</h2><p>{strokes[selected].hint}</p><div className="swatches">{['#fffaf0','#332c28','#ef6954','#ecc75b'].map(c=><button aria-label={`线条颜色 ${c}`} key={c} style={{background:c}} className={c===color?'selected':''} onClick={()=>setColor(c)}/>)}</div><button className="primary-button" onClick={download}><Download size={16}/>下载透明底 SVG</button><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(svg());setCopied(true);}}><Copy size={16}/>{copied?'已复制':'复制线条'}</button><p className="form-note">小图用来挑选造型与记录想法。正式画进照片时,位置、比例与笔触仍需要顺着画面调整。</p></aside></div></Shell>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
|
||||
import { mkdtemp,rm } from "node:fs/promises";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { Readable } from "node:stream";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { db,transaction } from "./lib/db";
|
||||
import { getObject,putFile } from "./lib/storage";
|
||||
import { probe,run } from "./lib/probe";
|
||||
let stopping=false;
|
||||
process.on('SIGTERM',()=>{stopping=true;});process.on('SIGINT',()=>{stopping=true;});
|
||||
async function processOne(){
|
||||
const job=await transaction(async client=>(await client.query(`UPDATE media_jobs SET status='processing',attempts=attempts+1,locked_at=now() WHERE id=(SELECT id FROM media_jobs WHERE (status='pending' AND available_at<=now()) OR (status='processing' AND locked_at<now()-interval '15 minutes') ORDER BY available_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)).rows[0]);
|
||||
if(!job)return false;
|
||||
const dir=await mkdtemp(join(tmpdir(),'prompt-video-'));
|
||||
try{
|
||||
await db().query("UPDATE assets SET processing_status='processing',processing_error=NULL WHERE id=$1",[job.asset_id]);
|
||||
const asset=(await db().query('SELECT * FROM assets WHERE id=$1',[job.asset_id])).rows[0];
|
||||
const input=join(dir,'input');const object=await getObject(asset.storage_key);
|
||||
await pipeline(object.Body as Readable,createWriteStream(input));
|
||||
const metadata=await probe(input);const hasAudio=metadata.streams.some(s=>s.codec_type==='audio');
|
||||
const variants=[{role:'silent_video',mime:'video/mp4',file:'silent.mp4',args:['-map','0:v:0','-an','-c:v','libx264','-preset','veryfast','-crf','22','-pix_fmt','yuv420p','-vf','scale=trunc(iw/2)*2:trunc(ih/2)*2','-movflags','+faststart']},
|
||||
{role:'poster',mime:'image/jpeg',file:'poster.jpg',args:['-map','0:v:0','-frames:v','1','-vf','scale=min(1200\\,iw):-2']}];
|
||||
if(hasAudio)variants.push({role:'audio_track',mime:'audio/mp4',file:'audio.m4a',args:['-map','0:a:0','-vn','-c:a','aac','-b:a','192k']});
|
||||
const outputs:{role:string;mime:string;file:string;args:string[];key:string;size:number}[]=[];
|
||||
for(const v of variants){const path=join(dir,v.file);await run(process.env.FFMPEG_PATH||'ffmpeg',['-nostdin','-y','-v','error','-protocol_whitelist','file,pipe','-i',input,...v.args,path],{timeout:10*60*1000,maxBuffer:2*1024*1024});const key=`derived/${asset.id}/${v.file}`;const size=await putFile(key,path,v.mime);outputs.push({...v,key,size});}
|
||||
await transaction(async client=>{
|
||||
for(const v of outputs)await client.query('INSERT INTO asset_variants(asset_id,role,storage_key,mime_type,byte_size) VALUES($1,$2,$3,$4,$5) ON CONFLICT(asset_id,role) DO UPDATE SET storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size',[asset.id,v.role,v.key,v.mime,v.size]);
|
||||
await client.query("UPDATE assets SET has_audio=$2,processing_status='ready',processing_error=NULL WHERE id=$1",[asset.id,hasAudio]);
|
||||
await client.query("UPDATE media_jobs SET status='ready',last_error=NULL WHERE id=$1",[job.id]);
|
||||
});
|
||||
}catch(error){
|
||||
const message=(error instanceof Error?error.message:'Processing failed').slice(0,2000);const final=job.attempts>=3;
|
||||
await transaction(async client=>{
|
||||
await client.query("UPDATE media_jobs SET status=$2,last_error=$3,available_at=now()+interval '30 seconds' WHERE id=$1",[job.id,final?'failed':'pending',message]);
|
||||
await client.query('UPDATE assets SET processing_status=$2,processing_error=$3 WHERE id=$1',[job.asset_id,final?'failed':'pending',message]);
|
||||
});
|
||||
console.error(`Video processing failed for ${job.asset_id}; retry=${!final}`);
|
||||
}finally{await rm(dir,{recursive:true,force:true});}
|
||||
return true;
|
||||
}
|
||||
async function main(){while(!stopping){try{if(!await processOne())await setTimeout(2000);}catch{console.error('Worker connection error; retrying');await setTimeout(5000);}}await db().end();}
|
||||
main().catch(()=>process.exit(1));
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {once} from 'node:events';
|
||||
import {createServer} from 'node:net';
|
||||
import {setTimeout} from 'node:timers/promises';
|
||||
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import sharp from 'sharp';
|
||||
import {db} from '../src/lib/db';
|
||||
import {probe,run} from '../src/lib/probe';
|
||||
|
||||
test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video separation', {timeout:180000}, async()=>{
|
||||
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise<void>(r=>socket.close(()=>r()));
|
||||
const base=`http://127.0.0.1:${port}`;const server=spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1'},stdio:'inherit'});
|
||||
const worker=spawn(process.execPath,['dist/worker.cjs'],{env:process.env,stdio:'inherit'});const dir=await mkdtemp(join(tmpdir(),'prompt-test-'));
|
||||
const request=async(path:string,body?:unknown)=>{const res=await fetch(base+path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined);assert.ok(res.ok,`${path}: ${res.status} ${res.ok?'':await res.text()}`);return res.json();};
|
||||
try{
|
||||
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
|
||||
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/library/assets','/library/tags','/tools/doodle'])assert.equal((await fetch(base+route)).status,200,route);
|
||||
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white',parentId:root.id});
|
||||
await assert.rejects(db().query('UPDATE tags SET parent_id=$1 WHERE id=$2',[child.id,root.id]),/cycle/);
|
||||
const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer();
|
||||
const upload=async(kind:string,files:{data:Uint8Array;name:string;mime:string}[])=>{const form=new FormData();form.set('kind',kind);form.set('title','测试收藏 '+kind);form.set('description','可以复用的独立文字');form.set('tags',JSON.stringify([child.id]));files.forEach(f=>form.append('files',new Blob([new Uint8Array(f.data)],{type:f.mime}),f.name));const res=await fetch(base+'/api/upload',{method:'POST',body:form});assert.equal(res.status,201,await res.clone().text());return res.json();};
|
||||
const first=await upload('image',[{data:a,name:'a.png',mime:'image/png'},{data:a,name:'b.png',mime:'image/png'}]);
|
||||
const reused=await request('/api/posts',{kind:'image',title:'同一张图的另一种用法',assets:[{id:first.assets[0]}],tags:[root.id]});
|
||||
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[first.assets[0]])).rows[0].n,2);
|
||||
const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,2);assert.equal((await request('/api/feed?section=images&path=lines/white')).posts.length,1);
|
||||
const empty=randomUUID();await db().query("INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,'image','空对象')",[empty,empty]);assert.equal((await request('/api/feed')).posts.length,2);
|
||||
assert.equal((await request('/api/feed')).contributions.length,0,'Import does not invent contribution rules');
|
||||
await db().query("INSERT INTO contribution_events(id,kind,subject_type,amount) VALUES($1,'test.import','asset',2),($2,'test.edit','post',3)",[randomUUID(),randomUUID()]);assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5);
|
||||
const img=await fetch(base+'/api/media/'+first.assets[0],{headers:{Range:'bytes=0-9'}});assert.equal(img.status,206);assert.equal((await img.arrayBuffer()).byteLength,10);
|
||||
assert.equal((await fetch(base+'/images/lines/missing')).status,404);assert.equal((await fetch(base+'/p/'+reused.slug)).status,200);
|
||||
for(const sound of [true,false]){
|
||||
const path=join(dir,sound?'sound.mp4':'silent.mp4');const args=['-y','-v','error','-f','lavfi','-i','color=c=blue:s=160x120:r=10'];if(sound)args.push('-f','lavfi','-i','sine=frequency=440:sample_rate=44100');args.push('-t','0.5','-c:v','libx264','-pix_fmt','yuv420p');if(sound)args.push('-c:a','aac');args.push(path);await run('ffmpeg',args);
|
||||
const post=await upload('video',[{data:await readFile(path),name:'video.mp4',mime:'video/mp4'}]);let asset;
|
||||
for(let i=0;i<120;i++){asset=(await db().query('SELECT * FROM assets WHERE id=$1',[post.assets[0]])).rows[0];if(asset.processing_status==='ready'||asset.processing_status==='failed')break;await setTimeout(500);}
|
||||
assert.equal(asset.processing_status,'ready',asset.processing_error);assert.equal(asset.has_audio,sound);
|
||||
const variants:string[]=(await db().query('SELECT role FROM asset_variants WHERE asset_id=$1',[asset.id])).rows.map(r=>r.role);assert.ok(variants.includes('silent_video'));assert.ok(variants.includes('poster'));assert.equal(variants.includes('audio_track'),sound);
|
||||
const clip:Response=await fetch(base+'/api/media/'+asset.id+'?variant=silent_video');assert.equal(clip.status,200);const derived=join(dir,'derived.mp4');await writeFile(derived,Buffer.from(await clip.arrayBuffer()));assert.equal((await probe(derived)).streams.some(s=>s.codec_type==='audio'),false);
|
||||
}
|
||||
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio'")).rows[0].n,0,'Bound soundtracks are variants, not independent audio assets');
|
||||
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
|
||||
}finally{
|
||||
for(const child of [worker,server])if(child.exitCode===null&&child.signalCode===null){const stopped=once(child,'exit');child.kill('SIGTERM');await stopped;}
|
||||
await rm(dir,{recursive:true,force:true});await db().end();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {decodeCursor,encodeCursor,validSection} from '../src/lib/model';
|
||||
test('timeline cursor preserves timestamp and ID, rejects malformed values',()=>{
|
||||
const date='2026-10-22T00:00:00.000Z',id=randomUUID();assert.deepEqual(decodeCursor(encodeCursor(date,id)),[date,id]);assert.throws(()=>decodeCursor('invalid'));assert.equal(decodeCursor(),null);
|
||||
});
|
||||
test('only the five content sections are recognized',()=>{assert.equal(validSection('videos')?.kind,'video');assert.equal(validSection('unknown'),undefined);});
|
||||
Reference in New Issue
Block a user