diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f2392a5 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d2c71a5..f2fc319 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index 105dc1a..b563a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ !.env.example *.tsbuildinfo .DS_Store +dist/ diff --git a/README.md b/README.md index f0edbfe..ce414a4 100644 --- a/README.md +++ b/README.md @@ -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/`:素材包的稳定详情地址,不随标签改变。多图切换、多视频及其关联音轨、多声音均可展示。 +- `/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/`,`/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/` | -| 当前发布 | `/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 ``` diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..7558039 --- /dev/null +++ b/compose.yml @@ -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: diff --git a/db/001_library.sql b/db/001_library.sql new file mode 100644 index 0000000..35d7a48 --- /dev/null +++ b/db/001_library.sql @@ -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; diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..cf4f47d --- /dev/null +++ b/docs/data-model.md @@ -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/`,更换分类不改变它的网址。同一份二进制在多个素材包中仍只有一个 asset ID 和对象引用。 + +媒体包必须有对应类型的非空文件引用才进入瀑布流;图文和动态作品必须有已注册的组件。只有标题、空关联或未知组件的对象都被过滤。不存在的分类网址返回 404,不退回全部内容。 + +迁移保存在 `db/` 并在 `schema_migrations` 记录;迁移过程加会话锁。不能通过应用回退删除数据库或对象卷。 diff --git a/package-lock.json b/package-lock.json index 82f706e..7d60d24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index f84df99..6b80e0a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts new file mode 100644 index 0000000..b300bd6 --- /dev/null +++ b/scripts/bootstrap.ts @@ -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);}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 34b9e50..4071eb9 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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 <{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 diff --git a/scripts/infra.sh b/scripts/infra.sh new file mode 100644 index 0000000..f58f142 --- /dev/null +++ b/scripts/infra.sh @@ -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 -- diff --git a/scripts/integration.sh b/scripts/integration.sh new file mode 100644 index 0000000..3253706 --- /dev/null +++ b/scripts/integration.sh @@ -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 diff --git a/scripts/prepare-standalone.mjs b/scripts/prepare-standalone.mjs new file mode 100644 index 0000000..edd5d63 --- /dev/null +++ b/scripts/prepare-standalone.mjs @@ -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;} diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx new file mode 100644 index 0000000..47a339e --- /dev/null +++ b/src/components/Feed.tsx @@ -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
+ {source?{post.title}:post.kind==='audio'?
{Array.from({length:36},(_,i)=>)}
:
{post.kind==='video'?:}{label}
} + {count>1&&{count}}{video&&{video.has_audio?'有声音':'无声音'}} +

{post.title}

{label}
{post.tags.length>0&&
{post.tags.slice(0,2).map(t=>#{t.name})}
}; +} +function Masonry({posts}:{posts:Post[]}){ + const element=useRef(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
{columns.map((items,i)=>
{items.map(p=>)}
)}
; +} +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 {target?.name||current?.name||'灵感首页'} · PROMPT + {current&&

{target?.name||current.name}

{current.hint}

} + {!section&&} + {tagPath&&
全部{current?.name}{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return / {initial.tags.find(t=>t.path===path)?.name};})}
} +
{!section?<>全部{sections.map(s=>{s.name})}:<>全部{children.map(t=>{t.name})}}
最近收录
+ {initial.unavailable?

素材库暂时没有连接上

请稍后刷新,已保存的内容不会因此丢失。

:posts.length?:
A LITTLE SPACE FOR SOMETHING GOOD

{router.query.q?'还没有找到这份灵感':'这里还没有内容'}

{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}

导入素材图文和动态作品通过代码加入,自由决定每一个像素。
} + {cursor&&
} +
PROMPT · PROBABLY RENDERING ONE MORE PIXEL TONIGHT
+
; +} diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx new file mode 100644 index 0000000..0897eab --- /dev/null +++ b/src/components/Heatmap.tsx @@ -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(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
CREATIVE RHYTHM

创作记录{total} 次贡献

过去一年 ↗
+
{Array.from({length:53},(_,week)=>{const d=days[week*7];const changed=week===0||d.getUTCMonth()!==days[(week-1)*7].getUTCMonth();return {changed?`${d.getUTCMonth()+1}月`:''};})}
一三五
{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
+
{hover||'每日贡献汇总'}
少{[0,1,2,3,4].map(n=>)}多
+
; +} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx new file mode 100644 index 0000000..b7e0235 --- /dev/null +++ b/src/components/Shell.tsx @@ -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
+
{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}:{}});}}>setQuery(e.target.value)}/>{query&&}P
{children}
{open&&
; +} diff --git a/src/content/registry.tsx b/src/content/registry.tsx new file mode 100644 index 0000000..caaa782 --- /dev/null +++ b/src/content/registry.tsx @@ -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 = {}; diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..03bc607 --- /dev/null +++ b/src/lib/api.ts @@ -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={ + '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:"操作没有完成,请稍后重试"}); +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..81a42bf --- /dev/null +++ b/src/lib/db.ts @@ -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(run: (client: PoolClient) => Promise): Promise { + 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(); } +} diff --git a/src/lib/library.ts b/src/lib/library.ts new file mode 100644 index 0000000..8b44dd2 --- /dev/null +++ b/src/lib/library.ts @@ -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 { 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 { + 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 { + 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}; +} diff --git a/src/lib/model.ts b/src/lib/model.ts new file mode 100644 index 0000000..6c5fb90 --- /dev/null +++ b/src/lib/model.ts @@ -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"); +} diff --git a/src/lib/probe.ts b/src/lib/probe.ts new file mode 100644 index 0000000..03ea3a8 --- /dev/null +++ b/src/lib/probe.ts @@ -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}}; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..ef6ea10 --- /dev/null +++ b/src/lib/storage.ts @@ -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 })); diff --git a/src/lib/write.ts b/src/lib/write.ts new file mode 100644 index 0000000..93dcf16 --- /dev/null +++ b/src/lib/write.ts @@ -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) { + 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}; + }); +} diff --git a/src/pages/[section]/[[...taxonomy]].tsx b/src/pages/[section]/[[...taxonomy]].tsx new file mode 100644 index 0000000..2da067d --- /dev/null +++ b/src/pages/[section]/[[...taxonomy]].tsx @@ -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}};} +}; diff --git a/src/pages/api/feed.ts b/src/pages/api/feed.ts new file mode 100644 index 0000000..115a3d1 --- /dev/null +++ b/src/pages/api/feed.ts @@ -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);} +} diff --git a/src/pages/api/media/[id].ts b/src/pages/api/media/[id].ts new file mode 100644 index 0000000..f6b6b15 --- /dev/null +++ b/src/pages/api/media/[id].ts @@ -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}}; diff --git a/src/pages/api/posts/index.ts b/src/pages/api/posts/index.ts new file mode 100644 index 0000000..e2e14f3 --- /dev/null +++ b/src/pages/api/posts/index.ts @@ -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);} +} diff --git a/src/pages/api/ready.ts b/src/pages/api/ready.ts new file mode 100644 index 0000000..6385d5e --- /dev/null +++ b/src/pages/api/ready.ts @@ -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'});} +} diff --git a/src/pages/api/tags.ts b/src/pages/api/tags.ts new file mode 100644 index 0000000..3d56511 --- /dev/null +++ b/src/pages/api/tags.ts @@ -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);} +} diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts new file mode 100644 index 0000000..1b8f9cd --- /dev/null +++ b/src/pages/api/upload.ts @@ -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={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));} +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..463c7b5 --- /dev/null +++ b/src/pages/index.tsx @@ -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}}};} +}; diff --git a/src/pages/library/assets.tsx b/src/pages/library/assets.tsx new file mode 100644 index 0000000..e698ff8 --- /dev/null +++ b/src/pages/library/assets.tsx @@ -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 原始素材 · PROMPT
ONE ASSET, MANY POSSIBILITIES

素材库

查看全部原始文件和文字,复用已收录的素材。

导入素材
+ {assets.length?
{assets.map(a=>
{a.kind==='image'?{a.filename||''}:a.kind==='audio'?

{a.filename||'文字片段'}

{a.uses} 个素材包正在引用
)}
:

素材架还是空的

导入的图片、视频、声音,以及素材包中的文字都会保存在这里。

} + {next&&下一页素材} +
; +} +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}}; +}; diff --git a/src/pages/library/import.tsx b/src/pages/library/import.tsx new file mode 100644 index 0000000..35caf3a --- /dev/null +++ b/src/pages/library/import.tsx @@ -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([]);const [title,setTitle]=useState('');const [description,setDescription]=useState('');const [selected,setSelected]=useState([]);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 素材入库 · PROMPT
MAKE ROOM FOR INSPIRATION

导入素材

支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。

+
{[{id:'image',name:'图片',Icon:Image},{id:'video',name:'视频',Icon:Film},{id:'audio',name:'声音',Icon:AudioLines}].map(t=>)}
+ + {files.length>0&&
    {files.map((f,i)=>
  • {f.name}{(f.size/1024/1024).toFixed(1)} MB
  • )}
} +
setTitle(e.target.value)} placeholder="添加标题"/>
+