Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75bdc8a68 | ||
|
|
6b8b29f7c2 |
No files matched your search
@@ -1,90 +0,0 @@
|
|||||||
name: Build
|
|
||||||
|
|
||||||
# 触发:组织仓库的两个主分支 push + 手动(fork/PR 运行只做质检不上传,令牌只读)
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [develop, codex/editor-backend-integration]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
GH_PROXY: https://v4.gh-proxy.org
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# npm ci(依赖树庞大)+ webpack 4 全量构建,runner 较慢,放宽超时
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
|
||||||
|
|
||||||
# 1. 手动安装 Node(runner 没有 hostedtoolcache,与 Backend/Frontend CI 同款)
|
|
||||||
- name: Setup Node.js manually
|
|
||||||
run: |
|
|
||||||
NODE_VERSION="22.23.3"
|
|
||||||
NODE_HASH="35947643734"
|
|
||||||
echo "Downloading Node.js v${NODE_VERSION} via proxy..."
|
|
||||||
curl -fL "${GH_PROXY}/https://github.com/actions/node-versions/releases/download/${NODE_VERSION}-${NODE_HASH}/node-${NODE_VERSION}-linux-x64.tar.gz" -o node.tar.gz
|
|
||||||
sudo mkdir -p /opt/hostedtoolcache/node/${NODE_VERSION}/x64
|
|
||||||
sudo tar -zxvf node.tar.gz -C /opt/hostedtoolcache/node/${NODE_VERSION}/x64 --strip-components=1
|
|
||||||
echo "/opt/hostedtoolcache/node/${NODE_VERSION}/x64/bin" >> "$GITHUB_PATH"
|
|
||||||
/opt/hostedtoolcache/node/${NODE_VERSION}/x64/bin/node -v
|
|
||||||
/opt/hostedtoolcache/node/${NODE_VERSION}/x64/bin/npm -v
|
|
||||||
|
|
||||||
# 2. npm 镜像
|
|
||||||
- name: Configure npm mirror
|
|
||||||
run: npm config set registry https://registry.npmmirror.com
|
|
||||||
|
|
||||||
# git 依赖(TurboWarp 的 scratch-* 是 git+ssh github 引用)在 runner 上
|
|
||||||
# 无法直连 github 且无 ssh 密钥 → 用 insteadOf 整体重写到代理。
|
|
||||||
# lock 保持 npm 的规范 ssh 形式(与 package.json 亲和,解析阶段可离线),
|
|
||||||
# 真正的 clone/ls-remote 由 git 配置在运行时透明替换。
|
|
||||||
- name: Proxy git remotes (insteadOf)
|
|
||||||
run: |
|
|
||||||
git config --global url."https://v4.gh-proxy.org/https://github.com/".insteadOf "ssh://git@github.com/"
|
|
||||||
git config --global url."https://v4.gh-proxy.org/https://github.com/".insteadOf "https://github.com/"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
# 3. micro:bit 包载入(生成物已提交入库则跳过;回退到脚本在线下载走 cross-fetch)
|
|
||||||
- name: Get micro:bit payload
|
|
||||||
run: |
|
|
||||||
if [ -f src/generated/microbit-hex-url.cjs ] && [ -f static/microbit/scratch-microbit-1.2.0.hex ]; then
|
|
||||||
echo "生成物已入库,跳过下载"
|
|
||||||
else
|
|
||||||
npm run prepublish
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. 编辑器单元测试(Lint 跳过:menu-bar 存在既有的 24 项 lint 问题)
|
|
||||||
- name: Unit tests (sccn)
|
|
||||||
run: npx jest --runInBand test/unit/sccn test/unit/reducers/project-state-reducer.test.js
|
|
||||||
|
|
||||||
# 5. 生产构建:Webpack 4 需要 legacy OpenSSL;ROOT 决定部署子路径
|
|
||||||
- name: Build editor (ROOT=/scratch/)
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
ROOT: /scratch/
|
|
||||||
NODE_OPTIONS: --openssl-legacy-provider
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
# 6. 打包 build/(顶层无子目录,解压后直接是一组站点文件:editor.html、getter.js 等)
|
|
||||||
- name: Package build
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
test -f build/editor.html
|
|
||||||
TARBALL="scratch-gui-build-${GITHUB_SHA::7}.tar.gz"
|
|
||||||
tar -czf "$TARBALL" -C build .
|
|
||||||
echo "--- 包内清单(前 20 行)---"
|
|
||||||
tar -tzf "$TARBALL" | head -20
|
|
||||||
echo "--- 大小 ---"
|
|
||||||
ls -lh "$TARBALL"
|
|
||||||
|
|
||||||
# 7. 上传为 Gitea Actions Artifact(push / 手动触发才上传)
|
|
||||||
- name: Upload build artifact
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: https://gitea.com/actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: scratch-gui-build-${{ github.sha }}
|
|
||||||
path: scratch-gui-build-*.tar.gz
|
|
||||||
retention-days: 30
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 中社后端接入
|
|
||||||
|
|
||||||
编辑器、播放器和嵌入页使用本站作品 ID。社区主站 `Frontend` 不在本次改动范围。
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
1. 在 Backend 配置数据库并执行 `npm ci`、`npm run migrate`,确认迁移 008 已应用。
|
|
||||||
2. Backend 的 `ALLOWED_ORIGINS` 加上 `http://localhost:8601`;保留已有主站 Origin。
|
|
||||||
3. 在 Backend 执行 `npm run assets:import -- --gui ../scratch-gui`,再加 `--check` 校验素材完整。
|
|
||||||
4. 启动 Backend(默认 3000),在本仓库执行 `npm ci` 和 `npm start`。
|
|
||||||
如果安装时跳过了 lifecycle scripts,先执行 `npm run prepublish` 下载并校验 micro:bit 资源。
|
|
||||||
5. 打开 `http://localhost:8601/editor.html`,点右上角“登录以保存”,使用后端已注册账号。
|
|
||||||
本页面不创建账号,注册沿用主站。
|
|
||||||
|
|
||||||
开发构建默认 API 为 `http://localhost:3000/api`。可在启动或构建时用环境变量指定,例如:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
SCCN_API_BASE=https://api.example.com/api npm start
|
|
||||||
SCCN_API_BASE=https://api.example.com/api NODE_ENV=production npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
生产未指定时默认同源 `/api`,需反向代理到 Backend。API 地址不能由 URL 参数覆写。
|
|
||||||
Cookie 请求始终携带 credentials;跨站部署需要后端启用 HTTPS、Secure、SameSite=None,
|
|
||||||
并允许编辑器 Origin。浏览器限制第三方 Cookie 时优先部署同源反向代理。
|
|
||||||
|
|
||||||
## 使用流程
|
|
||||||
|
|
||||||
- 登录恢复后,编辑中的新作品自动建立私有草稿;修改后 30 秒自动保存。
|
|
||||||
- 文件菜单“立即保存”或 Ctrl/Cmd+S 保存到服务器;未登录时仍可下载到本地。
|
|
||||||
- “保存副本”把当前编辑状态保存为新 ID,不修改原作;版本冲突后也可用它保留修改。
|
|
||||||
- 右上角“我的作品”分页列出自己的作品;切换时对未保存修改提示确认。
|
|
||||||
- `editor.html#123` 打开作品 123;刷新后读取后端最新版本、标题和素材。
|
|
||||||
- 保存本地导入或 `project_url` 导入的项目后,清除导入参数,刷新时使用本站副本。
|
|
||||||
- 本地 `.sb3` 导入/导出、打包功能保留。默认角色、背景、库素材和字体也会首次上传到本站,
|
|
||||||
不会误用 Scratch 的 `asset.clean` 标记跳过上传;当前页面成功上传过的素材不重复上传。
|
|
||||||
- 素材库缩略图、角色全部造型、声音预览与添加统一读取本站;不再回退到 Scratch 官方素材站。
|
|
||||||
- 后端未提供云变量服务,因此不接入 TurboWarp 公共云变量服务器,避免本站 ID 与官方 ID 混用。
|
|
||||||
|
|
||||||
## 保存与错误处理
|
|
||||||
|
|
||||||
保存使用与实际加载内容绑定的 revision。恢复登录、窗口重新获得焦点时刷新权限,
|
|
||||||
不会替换本地 revision,因此不会把旧作品当作最新版本覆盖服务器。401 提示重新登录;
|
|
||||||
409 保留编辑内容,提示下载备份或保存副本。私有作品未登录时显示登录与重新加载入口。
|
|
||||||
上传或保存失败不会显示保存成功;保存期间继续修改的内容保持未保存状态。
|
|
||||||
|
|
||||||
后端保存后的缩略图由 VM 截图上传。标题修改同样标记作品已变更并参与保存。
|
|
||||||
复制/改编的新作品默认私有;本次未增加发布/审核、删除、社区页面或注册流程。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx jest --runInBand test/unit/sccn test/unit/reducers/project-state-reducer.test.js
|
|
||||||
npm run test:unit
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
接入与状态机 53 项测试、既有 addon 33 项测试通过。浏览器联调使用隔离 MySQL 9.7 数据库,
|
|
||||||
验证登录/退出、新建、标题与快捷键保存、刷新恢复角色与背景、副本、我的作品、私有访问限制和
|
|
||||||
并发冲突后另存副本。未使用业务库,未部署生产。
|
|
||||||
|
|
||||||
旧的 `test/unit/util/project-saver-hoc.test.jsx` 与 `project-fetcher-hoc.test.jsx` 不在默认测试脚本中:
|
|
||||||
当前 Jest 配置缺 DOM 环境及 webpack inline-loader 映射,直接执行会失败;本轮未扩展该旧测试环境。
|
|
||||||
|
|
||||||
本次新增及其余改动源码 lint 通过;菜单文件仍有 24 项既存 lint 错误(HEAD 为 25 项),未做无关清理。
|
|
||||||
|
|
||||||
## 素材与生产部署
|
|
||||||
|
|
||||||
完整的素材导入、离线迁移、Nginx/systemd 配置、升级和备份步骤见 [Backend 部署说明](../../Backend/docs/07-editor-deployment.md)。
|
|
||||||
首次部署必须先导入素材,再发布 GUI;本轮已导入 1,305 个素材并完成完整性校验。
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Windows 单机部署 Runbook(编辑器同域子路径)
|
|
||||||
|
|
||||||
对应 `docs/07-editor-deployment.md`(Linux 独立域名方案)。本仓库主站部署形态是
|
|
||||||
**Windows + 单个 nginx**(`D:\Program Files\nginx`),所以编辑器采用**同域子路径 `/scratch/`**,
|
|
||||||
构建产物从 Gitea Actions Artifact 下载,人工落地。
|
|
||||||
|
|
||||||
## 1. 下载编辑器构建产物
|
|
||||||
|
|
||||||
Backend / Frontend CI 同款流程:Gitea → `ScCN/scratch-gui` → Actions → 选中目标运行
|
|
||||||
→ 底部 Artifacts 下载 `scratch-gui-build-<sha>.zip`,内含 `scratch-gui-build-<sha>.tar.gz`。
|
|
||||||
|
|
||||||
构建时已固定 `ROOT=/scratch/`、`SCCN_API_BASE=/api`:所有 JS chunk 引用都带 `/scratch/` 前缀,
|
|
||||||
必须放在 `sccn/Frontend/public/scratch/` 下;API 走同域 `/api`。
|
|
||||||
|
|
||||||
## 2. 落地到主站静态目录
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 解出的 build/ 内容 = editor.html、static/、js/ 等
|
|
||||||
# 目标目录:nginx 站点根下的 scratch(与 Frontend 的 public 同级结构)
|
|
||||||
tar -xzf scratch-gui-build-<sha>.tar.gz -C "D:\Program Files\nginx\sccn\Frontend\public\scratch"
|
|
||||||
```
|
|
||||||
|
|
||||||
`editor.html` 即入口:`https://<主站>/scratch/editor.html`,`#<作品ID>` 参数直接加载作品。
|
|
||||||
|
|
||||||
## 3. 首次部署前:导入内置素材(服务器上执行)
|
|
||||||
|
|
||||||
素材走后端存储,**必须先导入再发编辑器**(编辑器不再回退 Scratch 官方素材站):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 在 /srv 或服务器上的 Backend 目录
|
|
||||||
npm run assets:import -- --gui ../scratch-gui # 需要 scratch-gui 源码目录在旁边
|
|
||||||
npm run assets:import -- --gui ../scratch-gui --check # 全部通过才发布
|
|
||||||
```
|
|
||||||
|
|
||||||
约 1,305 个去重素材(53.1 MiB),写入 `.env` 的 `PROJECT_STORAGE_DIR`。
|
|
||||||
服务器无法访问外网时,按 `docs/07-editor-deployment.md` 第 3 节用素材种子包离线导入。
|
|
||||||
|
|
||||||
## 4. nginx 配置(主站 server 块内)
|
|
||||||
|
|
||||||
主站已有 `/api/` 反向代理(sccn-api.js 在用),编辑器同域复用即可,无需新 location:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
# 主站 server 块(sccn/Frontend/public 为 root)里只确认这两点:
|
|
||||||
# 1) /api/ 反代到 Backend 127.0.0.1:3000(既有)
|
|
||||||
# 2) client_max_body_size 至少 50m(编辑器上传作品/素材,与后端限制一致)
|
|
||||||
client_max_body_size 50m;
|
|
||||||
|
|
||||||
# /scratch/ 是纯静态:root 覆盖策略默认 try_files 即可,无需单独 location
|
|
||||||
```
|
|
||||||
|
|
||||||
修改后 `nginx -t` 通过再 reload。编辑器页面把 `$request_uri` 含访问凭证的作品 GET
|
|
||||||
计入日志无害,但注意不要在 `/api/` 全路径上开共享缓存(会话与私有作品接口)。
|
|
||||||
|
|
||||||
## 5. 验收清单
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 1. 编辑器可达
|
|
||||||
curl --fail -I https://<主站>/scratch/editor.html
|
|
||||||
# 2. API 健康(同域后端)
|
|
||||||
curl --fail https://<主站>/api/health
|
|
||||||
# 3. 内置素材直读(一个示例 MD5;换成 --check 清单里的即可)
|
|
||||||
curl --fail -I "https://<主站>/api/editor/assets/internalapi/asset/809d9b47347a6af2860e7a3a35bce057.svg/get/"
|
|
||||||
```
|
|
||||||
|
|
||||||
浏览器:
|
|
||||||
1. 打开 `/scratch/editor.html`,新作品默认角色/背景正常(素材全部来自 `/api/`,无外站请求)。
|
|
||||||
2. 主站登录后(cookie 同域直接共享),编辑器右上角应显示已登录用户,可「我的作品」/保存。
|
|
||||||
3. `editor.html#<主站社区的公开作品ID>` 能加载作品;闭源作品仅作者可载入源码。
|
|
||||||
4. 修改后 Ctrl/S 保存,刷新主站社区页可见新修订。
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# ScCN 编辑器:从构建到上线(操作手册)
|
|
||||||
|
|
||||||
> 面向运维/发布执行者。开发视角见 [sccn-backend.md](sccn-backend.md)。
|
|
||||||
> 本仓库默认分支即 `codex/editor-backend-integration`(ScCN 的自定义分支)。
|
|
||||||
|
|
||||||
## 0. 速查
|
|
||||||
|
|
||||||
| 事项 | 值 |
|
|
||||||
| --- | --- |
|
|
||||||
| 部署路径 | `<nginx 站点根>/scratch/` |
|
|
||||||
| 入口 | `/scratch/editor.html` |
|
|
||||||
| 构建时 `ROOT` | `/scratch/` |
|
|
||||||
| API 基址 | `/api`(同域) |
|
|
||||||
| 产物 | `scratch-gui-build-<sha>.tar.gz`(约 8 MB,解压约 400 个文件) |
|
|
||||||
| 当前已验证产物 | `ca6ba48` → artifact id 9 |
|
|
||||||
|
|
||||||
## 1. 前置条件(缺一个都会失败)
|
|
||||||
|
|
||||||
1. **后端已发布**,且 `/api/health` 返回 `db:"up"`
|
|
||||||
2. **内置素材已导入**(`Backend: npm run assets:import -- --gui ../scratch-gui`)
|
|
||||||
—— 编辑器不回退 Scratch 官方素材站,素材不在后端就加载不出来
|
|
||||||
3. nginx 站点根 = Frontend 的 `public/` 内容
|
|
||||||
|
|
||||||
## 2. 取构建产物
|
|
||||||
|
|
||||||
### 方式 A:使用已验证的产物(推荐)
|
|
||||||
|
|
||||||
若代码没有新提交,直接用已验证的那个:
|
|
||||||
|
|
||||||
- Actions → 找到 head 为 `ca6ba48` 的 run → Artifacts
|
|
||||||
- `scratch-gui-build-ca6ba48….tar.gz`
|
|
||||||
|
|
||||||
### 方式 B:自己触发构建
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 在 fork 上推任意提交,或直接触发工作流
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: token <你的 Gitea token>" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"ref":"codex/editor-backend-integration"}' \
|
|
||||||
https://git.shuiankongjian.top/api/v1/repos/ScCN/scratch-gui/actions/workflows/ci.yml/dispatches
|
|
||||||
```
|
|
||||||
|
|
||||||
工作流共 9 步:checkout → 装 Node 22 → npm 镜像 → **git insteadOf 代理** → `npm ci`
|
|
||||||
→ micro:bit 载荷 → sccn 单测 → 构建(`ROOT=/scratch/`)→ 打包上传。
|
|
||||||
|
|
||||||
> ⚠️ `package-lock.json` 里的 GitHub 依赖**必须保持 `git+ssh://git@github.com/...` 规范形式**,
|
|
||||||
> 靠 CI 的 `url.insteadOf` 把流量导到镜像。直接改 lock 里的 URL 会让 npm 解析失败
|
|
||||||
> (见提交 `24152b4`)。
|
|
||||||
|
|
||||||
## 3. 落地到站点
|
|
||||||
|
|
||||||
产物是 **tar.gz 内含构建后的 `build/` 内容**(不是 `<sha>/build/` 两级)。
|
|
||||||
|
|
||||||
### Windows(当前生产形态)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$dst = "D:\Program Files\nginx\sccn\Frontend\public\scratch"
|
|
||||||
New-Item -ItemType Directory -Force -Path $dst | Out-Null
|
|
||||||
tar -xzf scratch-gui-build-<sha>.tar.gz -C $dst
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
```sh
|
|
||||||
tar -xzf scratch-gui-build-<sha>.tar.gz -C /srv/sccn/frontend-public/scratch
|
|
||||||
```
|
|
||||||
|
|
||||||
解压后目录应当是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
scratch/
|
|
||||||
editor.html ← 入口
|
|
||||||
index.html
|
|
||||||
fullscreen.html
|
|
||||||
embed.html
|
|
||||||
js/ ← webpack 产物,publicPath = /scratch/
|
|
||||||
static/
|
|
||||||
```
|
|
||||||
|
|
||||||
> **不要**把产物放进站点根目录(`/editor.html`),否则 chunk 会去找 `/js/...` 而 404。
|
|
||||||
|
|
||||||
## 4. 发布后自检(逐条做,30 秒能完成)
|
|
||||||
|
|
||||||
| # | 操作 | 期望 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | 打开 `/scratch/editor.html` | 编辑器加载,无白屏 |
|
|
||||||
| 2 | F12 → Network,筛选 `js/` | 全部 200,路径都是 `/scratch/js/...` |
|
|
||||||
| 3 | 点「素材库」 | 有内置角色/背景/声音(说明素材已导入) |
|
|
||||||
| 4 | 新建作品 → 修改 → 保存 | 提示保存成功 |
|
|
||||||
| 5 | 刷新页面 / 重新打开 | 作品内容还在(说明后端写入正常) |
|
|
||||||
| 6 | 打开 `/scratch/editor.html#<作品ID>` | 直接加载该作品 |
|
|
||||||
| 7 | 未登录状态下保存 | 提示登录,而不是静默失败 |
|
|
||||||
|
|
||||||
第 2 条最关键:**任何 `/js/`(不带 `/scratch/` 前缀)的 404 都说明放错目录了**。
|
|
||||||
|
|
||||||
## 5. 常见故障
|
|
||||||
|
|
||||||
| 现象 | 原因 | 处理 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 白屏,Network 里 `/js/*.js` 404 | 产物放在站点根而不是 `/scratch/` | 挪到 `/scratch/` |
|
|
||||||
| 素材库为空 / 加载失败 | 后端未导入素材 | 跑 `assets:import`,再用 `--check` 复核 |
|
|
||||||
| 保存报 401 | 未登录,或 Cookie 跨站被拦 | 确认同域访问、`COOKIE_SECURE` 与协议匹配 |
|
|
||||||
| 保存报 409「作品已更新」 | 乐观锁:别处已保存 | 预期行为,重新加载后编辑 |
|
|
||||||
| 保存报 403「已设为闭源」 | 作品为 closed,非作者不能取源码 | 预期行为 |
|
|
||||||
| 构建卡在 `npm ci` | lock 被改成了非 ssh 形式 | 恢复 `git+ssh://git@github.com/...`,见第 2 节 |
|
|
||||||
|
|
||||||
## 6. 回滚
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 保留上一份产物即可秒回滚
|
|
||||||
mv scratch scratch.bak-<日期>
|
|
||||||
tar -xzf <上一版>.tar.gz -C scratch
|
|
||||||
```
|
|
||||||
|
|
||||||
编辑器是纯静态产物,回滚不影响后端数据(作品保存在后端 `storage/`)。
|
|
||||||
|
|
||||||
## 7. 与后端的版本兼容
|
|
||||||
|
|
||||||
| 后端要求 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `/api/editor/config` | 编辑器读取上传上限、会话地址 |
|
|
||||||
| `/api/editor/session` | 判断登录态与可保存性 |
|
|
||||||
| `/api/projects/:id/editor` | 下发 `projectToken`(5 分钟,绑定作品+修订号) |
|
|
||||||
| `/api/editor/assets/internalapi/asset/:name/get` | 素材读取(公开、不可变) |
|
|
||||||
|
|
||||||
> **修订号绑定**:作品一旦保存,revision 递增,旧的 `projectToken` 立即失效。
|
|
||||||
> 这是有意的安全设计;如果遇到「加载凭证失效」,重新打开作品即可。
|
|
||||||
Generated
+1
-1
@@ -21043,7 +21043,7 @@
|
|||||||
"node_modules/uglify-es": {
|
"node_modules/uglify-es": {
|
||||||
"version": "3.3.10",
|
"version": "3.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.10.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.10.tgz",
|
||||||
"integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==",
|
"integrity": "sha512-rPzPisCzW68Okj1zNrfa2dR9uEm43SevDmpR6FChoZABFk9dANGnzzBMgHYUXI3609//63fnVkyQ1SQmAMyjww==",
|
||||||
"deprecated": "support for ECMAScript is superseded by `uglify-js` as of v3.13.0",
|
"deprecated": "support for ECMAScript is superseded by `uglify-js` as of v3.13.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import FramerateChanger from '../../containers/tw-framerate-changer.jsx';
|
|||||||
import ChangeUsername from '../../containers/tw-change-username.jsx';
|
import ChangeUsername from '../../containers/tw-change-username.jsx';
|
||||||
import CloudVariablesToggler from '../../containers/tw-cloud-toggler.jsx';
|
import CloudVariablesToggler from '../../containers/tw-cloud-toggler.jsx';
|
||||||
import TWSaveStatus from './tw-save-status.jsx';
|
import TWSaveStatus from './tw-save-status.jsx';
|
||||||
import Account from '../sccn-account/account.jsx';
|
|
||||||
import TWNews from './tw-news.jsx';
|
import TWNews from './tw-news.jsx';
|
||||||
|
|
||||||
import {openTipsLibrary, openSettingsModal, openRestorePointModal} from '../../reducers/modals';
|
import {openTipsLibrary, openSettingsModal, openRestorePointModal} from '../../reducers/modals';
|
||||||
@@ -341,11 +340,7 @@ class MenuBar extends React.Component {
|
|||||||
const modifier = bowser.mac ? event.metaKey : event.ctrlKey;
|
const modifier = bowser.mac ? event.metaKey : event.ctrlKey;
|
||||||
if (modifier) {
|
if (modifier) {
|
||||||
if (event.key.toLowerCase() === 's') {
|
if (event.key.toLowerCase() === 's') {
|
||||||
if (this.props.canSave) {
|
this.props.handleSaveProject();
|
||||||
this.props.onClickSave();
|
|
||||||
} else {
|
|
||||||
this.props.handleSaveProject();
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key.toLowerCase() === 'o') {
|
} else if (event.key.toLowerCase() === 'o') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1040,10 +1035,9 @@ class MenuBar extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.accountInfoGroup}>
|
<div className={styles.accountInfoGroup}>
|
||||||
{this.props.canSave ? <SaveStatus /> : (
|
<TWSaveStatus
|
||||||
<TWSaveStatus showSaveFilePicker={this.props.showSaveFilePicker} />
|
showSaveFilePicker={this.props.showSaveFilePicker}
|
||||||
)}
|
/>
|
||||||
<Account />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aboutButton}
|
{aboutButton}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {defineMessages, intlShape, injectIntl} from 'react-intl';
|
import {defineMessages, intlShape, injectIntl} from 'react-intl';
|
||||||
import {setProjectTitle} from '../../reducers/project-title';
|
import {setProjectTitle} from '../../reducers/project-title';
|
||||||
import {setProjectChanged} from '../../reducers/project-changed';
|
|
||||||
|
|
||||||
import BufferedInputHOC from '../forms/buffered-input-hoc.jsx';
|
import BufferedInputHOC from '../forms/buffered-input-hoc.jsx';
|
||||||
import Input from '../forms/input.jsx';
|
import Input from '../forms/input.jsx';
|
||||||
@@ -49,10 +48,7 @@ const mapStateToProps = state => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
onSubmit: title => {
|
onSubmit: title => dispatch(setProjectTitle(title))
|
||||||
dispatch(setProjectTitle(title));
|
|
||||||
dispatch(setProjectChanged());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default injectIntl(connect(
|
export default injectIntl(connect(
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
@import "../../css/colors.css";
|
|
||||||
.account { display: flex; align-items: center; gap: .5rem; }
|
|
||||||
.account button { cursor: pointer; border: 1px solid rgba(255,255,255,.5); border-radius: .35rem; padding: .45rem .65rem; color: inherit; background: transparent; font: inherit; }
|
|
||||||
.account button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
||||||
.modal { width: min(30rem, 95vw); margin: 6rem auto; }
|
|
||||||
.content { padding: 1.25rem; color: $text-primary; background: $ui-primary; }
|
|
||||||
.content form, .content label { display: flex; flex-direction: column; gap: .75rem; }
|
|
||||||
.content label { margin-bottom: 1rem; }
|
|
||||||
.content input { padding: .65rem; border: 1px solid $ui-black-transparent; border-radius: .3rem; }
|
|
||||||
.content button { padding: .5rem .75rem; cursor: pointer; }
|
|
||||||
.projects { list-style: none; padding: 0; max-height: 45vh; overflow: auto; }
|
|
||||||
.projects button { width: 100%; text-align: left; margin-bottom: .5rem; overflow-wrap: anywhere; }
|
|
||||||
.pagination { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
|
|
||||||
.notice { position: fixed; top: 3.5rem; right: 1rem; max-width: min(34rem, 90vw); padding: 1rem; color: $text-primary; background: $ui-primary; border: 1px solid $ui-black-transparent; border-radius: .5rem; box-shadow: 0 4px 20px #0003; z-index: 10000; }
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {connect} from 'react-redux';
|
|
||||||
import backend from '../../lib/sccn/api';
|
|
||||||
import {setProjectId} from '../../reducers/project-state';
|
|
||||||
import Modal from '../modal/modal.jsx';
|
|
||||||
import styles from './account.css';
|
|
||||||
|
|
||||||
class Account extends React.Component {
|
|
||||||
constructor (props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {session: backend.state, panel: null, busy: false, items: [], page: 1, total: 0, error: ''};
|
|
||||||
this.handleClose = () => this.setState({panel: null, error: ''});
|
|
||||||
this.handleOpenLogin = () => this.setState({panel: 'login', error: ''});
|
|
||||||
this.handleOpenProjects = () => this.loadProjects(1);
|
|
||||||
this.handleLogin = this.handleLogin.bind(this);
|
|
||||||
this.handleLogout = this.handleLogout.bind(this);
|
|
||||||
}
|
|
||||||
componentDidMount () {
|
|
||||||
this.unsubscribe = backend.subscribe(session => this.setState({session}));
|
|
||||||
}
|
|
||||||
componentWillUnmount () {
|
|
||||||
this.unsubscribe();
|
|
||||||
}
|
|
||||||
async handleLogin (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const fields = new FormData(event.currentTarget);
|
|
||||||
this.setState({busy: true, error: ''});
|
|
||||||
try {
|
|
||||||
await backend.login(fields.get('email'), fields.get('password'));
|
|
||||||
this.handleClose();
|
|
||||||
} catch (error) {
|
|
||||||
this.setState({error: error.message});
|
|
||||||
} finally {
|
|
||||||
this.setState({busy: false});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async handleLogout () {
|
|
||||||
this.setState({busy: true});
|
|
||||||
try {
|
|
||||||
await backend.logout();
|
|
||||||
this.handleClose();
|
|
||||||
} catch (error) {
|
|
||||||
this.setState({error: error.message});
|
|
||||||
} finally {
|
|
||||||
this.setState({busy: false});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async loadProjects (page) {
|
|
||||||
this.setState({panel: 'projects', busy: true, error: ''});
|
|
||||||
try {
|
|
||||||
const data = await backend.request(`/projects?page=${page}&pageSize=10`);
|
|
||||||
this.setState({items: data.items, total: data.total, page});
|
|
||||||
} catch (error) {
|
|
||||||
this.setState({error: error.message});
|
|
||||||
} finally {
|
|
||||||
this.setState({busy: false});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
openProject (id) {
|
|
||||||
// Do not silently discard unsaved work when navigating from the project list.
|
|
||||||
if (this.props.projectChanged &&
|
|
||||||
!window.confirm('当前修改尚未保存,确定打开其他作品吗?')) return; // eslint-disable-line no-alert
|
|
||||||
// An imported URL must not keep overriding the selected server project.
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete('project_url');
|
|
||||||
url.searchParams.delete('project_title');
|
|
||||||
history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
|
||||||
this.props.openProject(String(id));
|
|
||||||
this.handleClose();
|
|
||||||
}
|
|
||||||
/* eslint-disable react/jsx-no-bind */
|
|
||||||
render () {
|
|
||||||
const {session, panel, busy, items, page, total, error} = this.state;
|
|
||||||
return (
|
|
||||||
<div className={styles.account}>
|
|
||||||
{session.user ? (
|
|
||||||
<React.Fragment>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={this.handleOpenProjects}
|
|
||||||
>{session.user.username}{' · 我的作品'}</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={this.handleLogout}
|
|
||||||
>{'退出'}</button>
|
|
||||||
</React.Fragment>
|
|
||||||
) : <button
|
|
||||||
type="button"
|
|
||||||
onClick={this.handleOpenLogin}
|
|
||||||
>{'登录以保存'}</button>}
|
|
||||||
{session.error && !panel ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className={styles.notice}
|
|
||||||
>
|
|
||||||
{session.error.message}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => backend.update({error: null})}
|
|
||||||
>{'关闭'}</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{panel ? (
|
|
||||||
<Modal
|
|
||||||
className={styles.modal}
|
|
||||||
contentLabel={panel === 'login' ? '登录中社' : '我的作品'}
|
|
||||||
id="sccn-account"
|
|
||||||
onRequestClose={this.handleClose}
|
|
||||||
>
|
|
||||||
<div className={styles.content}>
|
|
||||||
{error ? <p role="alert">{error}</p> : null}
|
|
||||||
{panel === 'login' ? (
|
|
||||||
<form onSubmit={this.handleLogin}>
|
|
||||||
<label>{'邮箱'}<input
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="username"
|
|
||||||
required
|
|
||||||
/></label>
|
|
||||||
<label>{'密码'}<input
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/></label>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={busy}
|
|
||||||
>{busy ? '正在登录…' : '登录'}</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<React.Fragment>
|
|
||||||
{busy ? <p>{'正在读取作品…'}</p> : null}
|
|
||||||
{!busy && !items.length ? <p>{'还没有保存的作品。'}</p> : null}
|
|
||||||
<ul className={styles.projects}>
|
|
||||||
{items.map(project => (
|
|
||||||
<li key={project.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => this.openProject(project.id)}
|
|
||||||
>
|
|
||||||
{project.title}{' · '}
|
|
||||||
{project.visibility === 'public' ? '公开' : '私有'}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className={styles.pagination}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || page === 1}
|
|
||||||
onClick={() => this.loadProjects(page - 1)}
|
|
||||||
>{'上一页'}</button>
|
|
||||||
<span>{`第 ${page} 页 · 共 ${total} 个作品`}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || page * 10 >= total}
|
|
||||||
onClick={() => this.loadProjects(page + 1)}
|
|
||||||
>{'下一页'}</button>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Account.propTypes = {projectChanged: PropTypes.bool, openProject: PropTypes.func};
|
|
||||||
export default connect(state => ({projectChanged: state.scratchGui.projectChanged}), dispatch => ({
|
|
||||||
openProject: id => dispatch(setProjectId(id))
|
|
||||||
}))(Account);
|
|
||||||
@@ -8,13 +8,13 @@ import {defaultProjectId} from '../../reducers/project-state';
|
|||||||
import styles from './project-input.css';
|
import styles from './project-input.css';
|
||||||
import {setProjectId} from '../../lib/tw-navigation-utils';
|
import {setProjectId} from '../../lib/tw-navigation-utils';
|
||||||
|
|
||||||
const PROJECT_BASE = '';
|
const PROJECT_BASE = 'https://scratch.mit.edu/projects/';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
tooltip: {
|
tooltip: {
|
||||||
defaultMessage: '输入中社作品编号',
|
defaultMessage: 'Copy and paste a Scratch project link here!',
|
||||||
description: 'Project ID input tooltip',
|
description: 'Project ID input tooltip',
|
||||||
id: 'sccn.input.tooltip'
|
id: 'tw.input.tooltip'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class ProjectInput extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
extractProjectId (text) {
|
extractProjectId (text) {
|
||||||
const numberMatch = text.trim().match(/^(\d+)$/);
|
const numberMatch = text.match(/\d+/);
|
||||||
return numberMatch ? numberMatch[0] : null;
|
return numberMatch ? numberMatch[0] : null;
|
||||||
}
|
}
|
||||||
readProjectId (e) {
|
readProjectId (e) {
|
||||||
@@ -96,7 +96,6 @@ class ProjectInput extends React.Component {
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
ref={this.inputRef}
|
ref={this.inputRef}
|
||||||
placeholder="中社作品编号"
|
|
||||||
spellCheck="false"
|
spellCheck="false"
|
||||||
type="text"
|
type="text"
|
||||||
value={`${PROJECT_BASE}${projectId}`}
|
value={`${PROJECT_BASE}${projectId}`}
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import PropTypes from 'prop-types';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {FormattedMessage} from 'react-intl';
|
import {FormattedMessage} from 'react-intl';
|
||||||
import Box from '../box/box.jsx';
|
import Box from '../box/box.jsx';
|
||||||
import {
|
import {ProjectUnavailableLegalReasons, ProjectUnsharedError} from '../../lib/tw-load-project-error';
|
||||||
BackendProjectError, ProjectUnavailableLegalReasons, ProjectUnsharedError
|
|
||||||
} from '../../lib/tw-load-project-error';
|
|
||||||
|
|
||||||
import styles from './project-load-error.css';
|
import styles from './project-load-error.css';
|
||||||
import Account from '../sccn-account/account.jsx';
|
|
||||||
|
|
||||||
const UNSHARED_DOCS = 'https://docs.turbowarp.org/unshared-projects';
|
const UNSHARED_DOCS = 'https://docs.turbowarp.org/unshared-projects';
|
||||||
|
|
||||||
@@ -128,7 +125,6 @@ UnavailableLegalReasonsMessage.propTypes = {
|
|||||||
* @returns {boolean} true if ProjectLoadError has a friendly message for this error.
|
* @returns {boolean} true if ProjectLoadError has a friendly message for this error.
|
||||||
*/
|
*/
|
||||||
const isKnownProjectLoadError = error => (
|
const isKnownProjectLoadError = error => (
|
||||||
error instanceof BackendProjectError ||
|
|
||||||
error instanceof ProjectUnsharedError ||
|
error instanceof ProjectUnsharedError ||
|
||||||
error instanceof ProjectUnavailableLegalReasons
|
error instanceof ProjectUnavailableLegalReasons
|
||||||
);
|
);
|
||||||
@@ -140,18 +136,7 @@ const ProjectLoadError = ({error, isFullScreen}) => (
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Box className={styles.body}>
|
<Box className={styles.body}>
|
||||||
{error instanceof BackendProjectError ? (
|
{error instanceof ProjectUnavailableLegalReasons ? (
|
||||||
<React.Fragment>
|
|
||||||
<p className={styles.header}>{'无法打开作品'}</p>
|
|
||||||
<p>{error.message}</p>
|
|
||||||
<p>{'私有作品需要使用作者账号登录。登录后请重新加载。'}</p>
|
|
||||||
<Account />
|
|
||||||
<button
|
|
||||||
className={styles.button}
|
|
||||||
onClick={handleReload}
|
|
||||||
>{'重新加载'}</button>
|
|
||||||
</React.Fragment>
|
|
||||||
) : error instanceof ProjectUnavailableLegalReasons ? (
|
|
||||||
<UnavailableLegalReasonsMessage moreUrl={error.moreUrl} />
|
<UnavailableLegalReasonsMessage moreUrl={error.moreUrl} />
|
||||||
) : (
|
) : (
|
||||||
<UnsharedMessage />
|
<UnsharedMessage />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import React from 'react';
|
|||||||
import {injectIntl, intlShape, defineMessages} from 'react-intl';
|
import {injectIntl, intlShape, defineMessages} from 'react-intl';
|
||||||
|
|
||||||
import LibraryItemComponent from '../components/library-item/library-item.jsx';
|
import LibraryItemComponent from '../components/library-item/library-item.jsx';
|
||||||
import {libraryAssetURL} from '../lib/sccn/library-assets';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
incompatible: {
|
incompatible: {
|
||||||
@@ -140,7 +139,7 @@ class LibraryItem extends React.PureComponent {
|
|||||||
render () {
|
render () {
|
||||||
const iconMd5 = this.curIconMd5();
|
const iconMd5 = this.curIconMd5();
|
||||||
const iconURL = iconMd5 ?
|
const iconURL = iconMd5 ?
|
||||||
libraryAssetURL(iconMd5) :
|
`https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` :
|
||||||
this.props.iconRawURL;
|
this.props.iconRawURL;
|
||||||
return (
|
return (
|
||||||
<LibraryItemComponent
|
<LibraryItemComponent
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// This file is generated by scripts/prepublish.mjs
|
|
||||||
// Do not edit this file directly
|
|
||||||
// This file relies on a loader to turn this `require` into a URL
|
|
||||||
module.exports = require('../static/microbit/scratch-microbit-1.2.0.hex');
|
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ const alerts = [
|
|||||||
alertId: 'createSuccess',
|
alertId: 'createSuccess',
|
||||||
alertType: AlertTypes.STANDARD,
|
alertType: AlertTypes.STANDARD,
|
||||||
clearList: ['createSuccess', 'creating', 'createCopySuccess', 'creatingCopy',
|
clearList: ['createSuccess', 'creating', 'createCopySuccess', 'creatingCopy',
|
||||||
'createRemixSuccess', 'creatingRemix', 'saveSuccess', 'saving', 'savingError', 'creatingError'],
|
'createRemixSuccess', 'creatingRemix', 'saveSuccess', 'saving'],
|
||||||
content: (
|
content: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
defaultMessage="New project created."
|
defaultMessage="New project created."
|
||||||
@@ -37,7 +37,7 @@ const alerts = [
|
|||||||
alertId: 'createCopySuccess',
|
alertId: 'createCopySuccess',
|
||||||
alertType: AlertTypes.STANDARD,
|
alertType: AlertTypes.STANDARD,
|
||||||
clearList: ['createSuccess', 'creating', 'createCopySuccess', 'creatingCopy',
|
clearList: ['createSuccess', 'creating', 'createCopySuccess', 'creatingCopy',
|
||||||
'createRemixSuccess', 'creatingRemix', 'saveSuccess', 'saving', 'savingError', 'creatingError'],
|
'createRemixSuccess', 'creatingRemix', 'saveSuccess', 'saving'],
|
||||||
content: (
|
content: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
defaultMessage="Project saved as a copy."
|
defaultMessage="Project saved as a copy."
|
||||||
@@ -53,7 +53,7 @@ const alerts = [
|
|||||||
alertId: 'createRemixSuccess',
|
alertId: 'createRemixSuccess',
|
||||||
alertType: AlertTypes.STANDARD,
|
alertType: AlertTypes.STANDARD,
|
||||||
clearList: ['createSuccess', 'creating', 'createCopySuccess', 'creatingCopy',
|
clearList: ['createSuccess', 'creating', 'createCopySuccess', 'creatingCopy',
|
||||||
'createRemixSuccess', 'creatingRemix', 'saveSuccess', 'saving', 'savingError', 'creatingError'],
|
'createRemixSuccess', 'creatingRemix', 'saveSuccess', 'saving'],
|
||||||
content: (
|
content: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
defaultMessage="Project saved as a remix."
|
defaultMessage="Project saved as a remix."
|
||||||
|
|||||||
@@ -22,12 +22,37 @@ import {
|
|||||||
|
|
||||||
import log from './log';
|
import log from './log';
|
||||||
import storage from './storage';
|
import storage from './storage';
|
||||||
import {BackendProjectError, ProjectFetchError} from './tw-load-project-error';
|
import {ProjectUnsharedError, ProjectFetchError, ProjectUnavailableLegalReasons} from './tw-load-project-error';
|
||||||
import backend from './sccn/api';
|
|
||||||
|
|
||||||
import VM from 'scratch-vm';
|
import VM from 'scratch-vm';
|
||||||
const fetchProjectToken = projectId => (projectId === '0' ?
|
import {fetchProjectMeta} from './tw-project-meta-fetcher-hoc.jsx';
|
||||||
Promise.resolve(null) : backend.project(projectId).then(config => config.projectToken));
|
|
||||||
|
// TW: Temporary hack for project tokens
|
||||||
|
const fetchProjectToken = async projectId => {
|
||||||
|
if (projectId === '0') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Parse ?token=abcdef
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
if (searchParams.has('token')) {
|
||||||
|
return searchParams.get('token');
|
||||||
|
}
|
||||||
|
// Parse #1?token=abcdef
|
||||||
|
const hashParams = new URLSearchParams(location.hash.split('?')[1]);
|
||||||
|
if (hashParams.has('token')) {
|
||||||
|
return hashParams.get('token');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const metadata = await fetchProjectMeta(projectId);
|
||||||
|
return metadata.project_token;
|
||||||
|
} catch (e) {
|
||||||
|
log.error(e);
|
||||||
|
if (e instanceof ProjectUnavailableLegalReasons) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw new ProjectUnsharedError('Cannot access project token. Project is probably unshared. See https://docs.turbowarp.org/unshared-projects');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* Higher Order Component to provide behavior for loading projects by id. If
|
/* Higher Order Component to provide behavior for loading projects by id. If
|
||||||
* there's no id, the default project is loaded.
|
* there's no id, the default project is loaded.
|
||||||
@@ -133,7 +158,7 @@ const ProjectFetcherHOC = function (WrappedComponent) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
this.props.onError(new BackendProjectError(err.message || '无法加载作品'));
|
this.props.onError(err);
|
||||||
log.error(err);
|
log.error(err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -185,8 +210,8 @@ const ProjectFetcherHOC = function (WrappedComponent) {
|
|||||||
vm: PropTypes.instanceOf(VM)
|
vm: PropTypes.instanceOf(VM)
|
||||||
};
|
};
|
||||||
ProjectFetcherComponent.defaultProps = {
|
ProjectFetcherComponent.defaultProps = {
|
||||||
assetHost: backend.url('/editor/assets'),
|
assetHost: 'https://assets.scratch.mit.edu',
|
||||||
projectHost: backend.url('/editor/projects')
|
projectHost: 'https://projects.scratch.mit.edu'
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import VM from 'scratch-vm';
|
|||||||
|
|
||||||
import collectMetadata from '../lib/collect-metadata';
|
import collectMetadata from '../lib/collect-metadata';
|
||||||
import log from '../lib/log';
|
import log from '../lib/log';
|
||||||
import backend from './sccn/api';
|
import storage from '../lib/storage';
|
||||||
import dataURItoBlob from '../lib/data-uri-to-blob';
|
import dataURItoBlob from '../lib/data-uri-to-blob';
|
||||||
import saveProjectToServer from '../lib/save-project-to-server';
|
import saveProjectToServer from '../lib/save-project-to-server';
|
||||||
|
|
||||||
@@ -67,10 +67,6 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
|||||||
this.props.onSetProjectSaver(this.tryToAutoSave);
|
this.props.onSetProjectSaver(this.tryToAutoSave);
|
||||||
}
|
}
|
||||||
componentDidUpdate (prevProps) {
|
componentDidUpdate (prevProps) {
|
||||||
if (this.rescheduleAfterSave && this.props.isShowingSaveable) {
|
|
||||||
this.rescheduleAfterSave = false;
|
|
||||||
this.scheduleAutoSave();
|
|
||||||
}
|
|
||||||
if (!this.props.isAnyCreatingNewState && prevProps.isAnyCreatingNewState) {
|
if (!this.props.isAnyCreatingNewState && prevProps.isAnyCreatingNewState) {
|
||||||
this.reportTelemetryEvent('projectWasCreated');
|
this.reportTelemetryEvent('projectWasCreated');
|
||||||
}
|
}
|
||||||
@@ -78,8 +74,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
|||||||
this.reportTelemetryEvent('projectDidLoad');
|
this.reportTelemetryEvent('projectDidLoad');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.props.projectChanged && !prevProps.projectChanged) ||
|
if (this.props.projectChanged && !prevProps.projectChanged) {
|
||||||
this.props.reduxProjectTitle !== prevProps.reduxProjectTitle) {
|
|
||||||
this.scheduleAutoSave();
|
this.scheduleAutoSave();
|
||||||
}
|
}
|
||||||
if (this.props.isUpdating && !prevProps.isUpdating) {
|
if (this.props.isUpdating && !prevProps.isUpdating) {
|
||||||
@@ -222,7 +217,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
|||||||
* @param {?object} requestParams - object of params to add to request body
|
* @param {?object} requestParams - object of params to add to request body
|
||||||
*/
|
*/
|
||||||
storeProject (projectId, requestParams) {
|
storeProject (projectId, requestParams) {
|
||||||
requestParams = {title: this.props.reduxProjectTitle, ...requestParams};
|
requestParams = requestParams || {};
|
||||||
this.clearAutoSaveTimeout();
|
this.clearAutoSaveTimeout();
|
||||||
// Serialize VM state now before embarking on
|
// Serialize VM state now before embarking on
|
||||||
// the asynchronous journey of storing assets to
|
// the asynchronous journey of storing assets to
|
||||||
@@ -232,8 +227,14 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
|||||||
// we just finished saving).
|
// we just finished saving).
|
||||||
const savedVMState = this.props.vm.toJSON();
|
const savedVMState = this.props.vm.toJSON();
|
||||||
return Promise.all(this.props.vm.assets
|
return Promise.all(this.props.vm.assets
|
||||||
|
.filter(asset => !asset.clean)
|
||||||
.map(
|
.map(
|
||||||
asset => backend.upload(asset).then(response => {
|
asset => storage.store(
|
||||||
|
asset.assetType,
|
||||||
|
asset.dataFormat,
|
||||||
|
asset.data,
|
||||||
|
asset.assetId
|
||||||
|
).then(response => {
|
||||||
// Asset servers respond with {status: ok} for successful POSTs
|
// Asset servers respond with {status: ok} for successful POSTs
|
||||||
if (response.status !== 'ok') {
|
if (response.status !== 'ok') {
|
||||||
// Errors include a `code` property, e.g. "Forbidden"
|
// Errors include a `code` property, e.g. "Forbidden"
|
||||||
@@ -245,13 +246,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
|
|||||||
)
|
)
|
||||||
.then(() => this.props.onUpdateProjectData(projectId, savedVMState, requestParams))
|
.then(() => this.props.onUpdateProjectData(projectId, savedVMState, requestParams))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (this.props.vm.toJSON() === savedVMState &&
|
this.props.onSetProjectUnchanged();
|
||||||
this.props.reduxProjectTitle === requestParams.title) {
|
|
||||||
this.props.onSetProjectUnchanged();
|
|
||||||
} else {
|
|
||||||
// The reducer is still in a saving state here; schedule once it returns to the editor.
|
|
||||||
this.rescheduleAfterSave = true;
|
|
||||||
}
|
|
||||||
const id = response.id.toString();
|
const id = response.id.toString();
|
||||||
if (id && this.props.onUpdateProjectThumbnail) {
|
if (id && this.props.onUpdateProjectThumbnail) {
|
||||||
this.storeProjectThumbnail(id);
|
this.storeProjectThumbnail(id);
|
||||||
|
|||||||
@@ -1,10 +1,63 @@
|
|||||||
import backend from './sccn/api';
|
import queryString from 'query-string';
|
||||||
|
import xhr from 'xhr';
|
||||||
|
import storage from '../lib/storage';
|
||||||
|
|
||||||
export default async (projectId, vmState, params) => {
|
/**
|
||||||
const result = await backend.save(projectId, vmState, params);
|
* Save a project JSON to the project server.
|
||||||
// Once an imported URL has been saved, reloading must open the server copy.
|
* This should eventually live in scratch-www.
|
||||||
const url = new URL(window.location.href);
|
* @param {number} projectId the ID of the project, null if a new project.
|
||||||
for (const key of ['project_url', 'project_title', 'token']) url.searchParams.delete(key);
|
* @param {object} vmState the JSON project representation.
|
||||||
history.replaceState(null, '', `${url.pathname}${url.search}${url.hash.split('?')[0]}`);
|
* @param {object} params the request params.
|
||||||
return result;
|
* @property {?number} params.originalId the original project ID if a copy/remix.
|
||||||
};
|
* @property {?boolean} params.isCopy a flag indicating if this save is creating a copy.
|
||||||
|
* @property {?boolean} params.isRemix a flag indicating if this save is creating a remix.
|
||||||
|
* @property {?string} params.title the title of the project.
|
||||||
|
* @return {Promise} A promise that resolves when the network request resolves.
|
||||||
|
*/
|
||||||
|
export default function (projectId, vmState, params) {
|
||||||
|
const opts = {
|
||||||
|
body: vmState,
|
||||||
|
// If we set json:true then the body is double-stringified, so don't
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
withCredentials: true
|
||||||
|
};
|
||||||
|
const creatingProject = projectId === null || typeof projectId === 'undefined';
|
||||||
|
const queryParams = {};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(params, 'originalId')) queryParams.original_id = params.originalId;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(params, 'isCopy')) queryParams.is_copy = params.isCopy;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(params, 'isRemix')) queryParams.is_remix = params.isRemix;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(params, 'title')) queryParams.title = params.title;
|
||||||
|
let qs = queryString.stringify(queryParams);
|
||||||
|
if (qs) qs = `?${qs}`;
|
||||||
|
if (creatingProject) {
|
||||||
|
Object.assign(opts, {
|
||||||
|
method: 'post',
|
||||||
|
url: `${storage.projectHost}/${qs}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(opts, {
|
||||||
|
method: 'put',
|
||||||
|
url: `${storage.projectHost}/${projectId}${qs}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
xhr(opts, (err, response) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (response.statusCode !== 200) return reject(response.statusCode);
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
// Since we didn't set json: true, we have to parse manually
|
||||||
|
body = JSON.parse(response.body);
|
||||||
|
} catch (e) {
|
||||||
|
return reject(e);
|
||||||
|
}
|
||||||
|
body.id = projectId;
|
||||||
|
if (creatingProject) {
|
||||||
|
body.id = body['content-name'];
|
||||||
|
}
|
||||||
|
resolve(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
// The destination is deployment configuration, never a URL parameter supplied by a project.
|
|
||||||
const apiBase = (process.env.SCCN_API_BASE || '/api').replace(/\/$/, '');
|
|
||||||
|
|
||||||
class BackendClient {
|
|
||||||
constructor () {
|
|
||||||
this.state = {user: null, ready: false, error: null, projects: {}};
|
|
||||||
this.listeners = new Set();
|
|
||||||
this.uploaded = new Set();
|
|
||||||
this.revisions = new Map();
|
|
||||||
}
|
|
||||||
url (path) {
|
|
||||||
return `${apiBase}${path}`;
|
|
||||||
}
|
|
||||||
subscribe (listener) {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
listener(this.state);
|
|
||||||
return () => this.listeners.delete(listener);
|
|
||||||
}
|
|
||||||
update (changes) {
|
|
||||||
this.state = {...this.state, ...changes};
|
|
||||||
this.listeners.forEach(listener => listener(this.state));
|
|
||||||
}
|
|
||||||
async request (path, options = {}) {
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await fetch(this.url(path), {credentials: 'include', ...options});
|
|
||||||
} catch (cause) {
|
|
||||||
const error = new Error('无法连接服务器,请检查网络后重试。作品仍保留在编辑器中。');
|
|
||||||
this.update({error});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await response.json();
|
|
||||||
} catch (cause) {
|
|
||||||
const error = new Error(`服务器返回了无效响应(${response.status}),请稍后重试。`);
|
|
||||||
this.update({error});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (!response.ok || (body.code && body.code !== 0)) {
|
|
||||||
const error = new Error(response.status === 409 ?
|
|
||||||
'作品已在其他窗口更新。请先下载备份或另存副本,再重新打开作品。' :
|
|
||||||
response.status === 401 ? '登录已失效,请重新登录后保存。' :
|
|
||||||
body.message || `请求失败(${response.status})`);
|
|
||||||
error.status = response.status;
|
|
||||||
error.code = body.code;
|
|
||||||
this.update({error, ...(response.status === 401 ? {user: null} : {})});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
|
|
||||||
}
|
|
||||||
async session () {
|
|
||||||
try {
|
|
||||||
const session = await this.request('/editor/session');
|
|
||||||
const changedUser = (this.state.user && this.state.user.id) !== (session.user && session.user.id);
|
|
||||||
this.update({user: session.user, ready: true, ...(changedUser ? {projects: {}} : {})});
|
|
||||||
} catch (error) {
|
|
||||||
this.update({user: null, ready: true});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async login (email, password) {
|
|
||||||
await this.request('/auth/login', {
|
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({email, password})
|
|
||||||
});
|
|
||||||
await this.session();
|
|
||||||
if (!this.state.user) throw new Error('无法恢复登录会话,请检查浏览器 Cookie 设置。');
|
|
||||||
this.update({error: null});
|
|
||||||
}
|
|
||||||
async logout () {
|
|
||||||
await this.request('/auth/logout', {method: 'POST'});
|
|
||||||
this.update({user: null, projects: {}});
|
|
||||||
}
|
|
||||||
async project (id) {
|
|
||||||
const config = await this.request(`/projects/${encodeURIComponent(id)}/editor`);
|
|
||||||
this.revisions.set(String(id), config.revision);
|
|
||||||
this.update({projects: {...this.state.projects, [id]: config}});
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
async permissions (id) {
|
|
||||||
// Refresh permissions after login without replacing the loaded revision (lost-update protection).
|
|
||||||
const config = await this.request(`/projects/${encodeURIComponent(id)}/editor`);
|
|
||||||
this.update({projects: {...this.state.projects, [id]: config}});
|
|
||||||
}
|
|
||||||
async upload (asset) {
|
|
||||||
const name = `${asset.assetId}.${asset.dataFormat}`;
|
|
||||||
if (this.uploaded.has(name)) return {status: 'ok'};
|
|
||||||
const result = await this.request(`/editor/assets/${name}`, {
|
|
||||||
method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body: asset.data
|
|
||||||
});
|
|
||||||
this.uploaded.add(name);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
async save (id, json, params = {}) {
|
|
||||||
const creating = id === null || typeof id === 'undefined' || String(id) === '0';
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params.title) query.set('title', params.title);
|
|
||||||
if (params.originalId) query.set('original_id', params.originalId);
|
|
||||||
if (params.isCopy) query.set('is_copy', '1');
|
|
||||||
if (params.isRemix) query.set('is_remix', '1');
|
|
||||||
if (!creating) {
|
|
||||||
const revision = this.revisions.get(String(id));
|
|
||||||
if (!revision) throw new Error('作品版本信息缺失,请先下载备份并重新打开作品。');
|
|
||||||
query.set('revision', revision);
|
|
||||||
}
|
|
||||||
const result = await this.request(`/editor/projects/${creating ? '' : id}?${query}`, {
|
|
||||||
method: creating ? 'POST' : 'PUT', headers: {'Content-Type': 'application/json'}, body: json
|
|
||||||
});
|
|
||||||
this.revisions.set(String(result.id), result.revision);
|
|
||||||
this.update({error: null,
|
|
||||||
projects: {...this.state.projects,
|
|
||||||
[result.id]: {
|
|
||||||
canSave: true, canCreateCopy: true, canRemix: false, revision: result.revision
|
|
||||||
}}});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
thumbnail (id, blob) {
|
|
||||||
return this.request(`/projects/${id}/thumbnail`, {method: 'POST', body: blob});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const backend = new BackendClient();
|
|
||||||
export {BackendClient};
|
|
||||||
export default backend;
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {connect} from 'react-redux';
|
|
||||||
import backend from './api';
|
|
||||||
|
|
||||||
const BackendHOC = WrappedComponent => {
|
|
||||||
class BackendGUI extends React.Component {
|
|
||||||
constructor (props) {
|
|
||||||
super(props);
|
|
||||||
this.state = backend.state;
|
|
||||||
this.refresh = () => backend.session();
|
|
||||||
this.handleThumbnail = (id, blob) => backend.thumbnail(id, blob).catch(() => {});
|
|
||||||
}
|
|
||||||
componentDidMount () {
|
|
||||||
this.unsubscribe = backend.subscribe(state => this.setState(state));
|
|
||||||
this.refresh();
|
|
||||||
window.addEventListener('focus', this.refresh);
|
|
||||||
}
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
|
||||||
if (this.state.user && this.state.user !== prevState.user &&
|
|
||||||
this.props.backendProjectId && this.props.backendProjectId !== '0') {
|
|
||||||
backend.permissions(this.props.backendProjectId).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
componentWillUnmount () {
|
|
||||||
this.unsubscribe();
|
|
||||||
window.removeEventListener('focus', this.refresh);
|
|
||||||
}
|
|
||||||
render () {
|
|
||||||
const {backendProjectId, backendPlayerOnly, ...props} = this.props;
|
|
||||||
const project = this.state.projects[backendProjectId];
|
|
||||||
const ownsProject = this.state.user && project && project.canSave;
|
|
||||||
return (
|
|
||||||
<WrappedComponent
|
|
||||||
{...props}
|
|
||||||
assetHost={backend.url('/editor/assets')}
|
|
||||||
projectHost={backend.url('/editor/projects')}
|
|
||||||
canSave={Boolean(ownsProject && !backendPlayerOnly)}
|
|
||||||
canCreateNew={Boolean(this.state.user && !backendPlayerOnly)}
|
|
||||||
canCreateCopy={Boolean(ownsProject && !backendPlayerOnly)}
|
|
||||||
canRemix={Boolean(this.state.user && project && project.canRemix && !backendPlayerOnly)}
|
|
||||||
onUpdateProjectThumbnail={this.handleThumbnail}
|
|
||||||
autoSaveIntervalSecs={30}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackendGUI.propTypes = {
|
|
||||||
backendProjectId: PropTypes.string,
|
|
||||||
backendPlayerOnly: PropTypes.bool
|
|
||||||
};
|
|
||||||
return connect(state => ({
|
|
||||||
backendProjectId: state.scratchGui.projectState.projectId,
|
|
||||||
backendPlayerOnly: state.scratchGui.mode.isPlayerOnly
|
|
||||||
}))(BackendGUI);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BackendHOC;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import backend from './api';
|
|
||||||
|
|
||||||
// Thumbnails (including rotating sprite costumes) use the same backend as VM assets.
|
|
||||||
export const libraryAssetURL = name => backend.url(`/editor/assets/internalapi/asset/${name}/get/`);
|
|
||||||
@@ -43,15 +43,7 @@ class ProjectUnavailableLegalReasons extends LoadProjectError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BackendProjectError extends LoadProjectError {
|
|
||||||
constructor (message) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'BackendProjectError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
BackendProjectError,
|
|
||||||
LoadProjectError,
|
LoadProjectError,
|
||||||
ProjectUnsharedError,
|
ProjectUnsharedError,
|
||||||
ProjectFetchError,
|
ProjectFetchError,
|
||||||
|
|||||||
@@ -5,16 +5,42 @@ import log from './log';
|
|||||||
|
|
||||||
import {setProjectTitle} from '../reducers/project-title';
|
import {setProjectTitle} from '../reducers/project-title';
|
||||||
import {setAuthor, setDescription} from '../reducers/tw';
|
import {setAuthor, setDescription} from '../reducers/tw';
|
||||||
import backend from './sccn/api';
|
import {ProjectUnavailableLegalReasons} from './tw-load-project-error';
|
||||||
|
|
||||||
export const fetchProjectMeta = async projectId => {
|
export const fetchProjectMeta = async projectId => {
|
||||||
const metadata = await backend.request(`/projects/${projectId}`);
|
// When people reopen tabs, sometimes the browser is *very* aggressive about caching even when the
|
||||||
return {
|
// trampoline says not to, so we're going to try putting a cache buster in here.
|
||||||
...metadata,
|
const cacheBuster = `?rudebuster=${Math.random()}`;
|
||||||
author: {id: metadata.owner_id,
|
const urls = [
|
||||||
username: backend.state.user &&
|
`https://trampoline.turbowarp.org/api/projects/${projectId}${cacheBuster}`,
|
||||||
backend.state.user.id === metadata.owner_id ? backend.state.user.username : `用户 ${metadata.owner_id}`}
|
`https://trampoline.turbowarp.xyz/api/projects/${projectId}${cacheBuster}`
|
||||||
};
|
];
|
||||||
|
let firstError;
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (res.status === 451) {
|
||||||
|
throw new ProjectUnavailableLegalReasons('Project is unavailable for legal reasons', data.url);
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new Error('Project is probably unshared');
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected status code: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Mirror would get the same error so don't try again
|
||||||
|
if (err instanceof ProjectUnavailableLegalReasons) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!firstError) {
|
||||||
|
firstError = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw firstError;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNoIndexTag = () => document.querySelector('meta[name="robots"][content="noindex"]');
|
const getNoIndexTag = () => document.querySelector('meta[name="robots"][content="noindex"]');
|
||||||
@@ -55,7 +81,7 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) {
|
|||||||
this.props.onSetProjectTitle(title);
|
this.props.onSetProjectTitle(title);
|
||||||
}
|
}
|
||||||
const authorName = data.author.username;
|
const authorName = data.author.username;
|
||||||
const authorThumbnail = '';
|
const authorThumbnail = `https://trampoline.turbowarp.org/avatars/${data.author.id}`;
|
||||||
this.props.onSetAuthor(authorName, authorThumbnail);
|
this.props.onSetAuthor(authorName, authorThumbnail);
|
||||||
const instructions = data.instructions || '';
|
const instructions = data.instructions || '';
|
||||||
const credits = data.description || '';
|
const credits = data.description || '';
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import GUI from '../containers/gui.jsx';
|
import GUI from '../containers/gui.jsx';
|
||||||
import BackendHOC from '../lib/sccn/gui-hoc.jsx';
|
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
const initializeStorage = storage => {
|
const cloudHost = searchParams.get('cloud_host') || 'wss://clouddata.turbowarp.org';
|
||||||
storage.addOfficialScratchWebStores();
|
|
||||||
};
|
|
||||||
|
|
||||||
const RenderGUI = props => (
|
const RenderGUI = props => (
|
||||||
<GUI
|
<GUI
|
||||||
canUseCloud={false}
|
cloudHost={cloudHost}
|
||||||
hasCloudPermission={false}
|
canUseCloud
|
||||||
|
hasCloudPermission
|
||||||
canSave={false}
|
canSave={false}
|
||||||
onStorageInit={initializeStorage}
|
|
||||||
basePath={process.env.ROOT}
|
basePath={process.env.ROOT}
|
||||||
canEditTitle
|
canEditTitle
|
||||||
enableCommunity
|
enableCommunity
|
||||||
@@ -20,4 +17,4 @@ const RenderGUI = props => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default BackendHOC(RenderGUI);
|
export default RenderGUI;
|
||||||
@@ -32,6 +32,7 @@ import '../lib/tw-fix-history-api';
|
|||||||
import GUI from './render-gui.jsx';
|
import GUI from './render-gui.jsx';
|
||||||
import MenuBar from '../components/menu-bar/menu-bar.jsx';
|
import MenuBar from '../components/menu-bar/menu-bar.jsx';
|
||||||
import ProjectInput from '../components/tw-project-input/project-input.jsx';
|
import ProjectInput from '../components/tw-project-input/project-input.jsx';
|
||||||
|
import FeaturedProjects from '../components/tw-featured-projects/featured-projects.jsx';
|
||||||
import Description from '../components/tw-description/description.jsx';
|
import Description from '../components/tw-description/description.jsx';
|
||||||
import BrowserModal from '../components/browser-modal/browser-modal.jsx';
|
import BrowserModal from '../components/browser-modal/browser-modal.jsx';
|
||||||
import CloudVariableBadge from '../containers/tw-cloud-variable-badge.jsx';
|
import CloudVariableBadge from '../containers/tw-cloud-variable-badge.jsx';
|
||||||
@@ -278,6 +279,22 @@ class Interface extends React.Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
defaultMessage="{APP_NAME} is a Scratch mod that compiles projects to JavaScript to make them run really fast. Try it out by inputting a project ID or URL above or choosing a featured project below."
|
||||||
|
description="Description of TurboWarp on the homepage"
|
||||||
|
id="tw.home.description"
|
||||||
|
values={{
|
||||||
|
APP_NAME
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.section}>
|
||||||
|
<FeaturedProjects studio="27205657" />
|
||||||
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const START_FETCHING_NEW = 'scratch-gui/project-state/START_FETCHING_NEW';
|
|||||||
const START_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_VM_FILE_UPLOAD';
|
const START_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_VM_FILE_UPLOAD';
|
||||||
const START_MANUAL_UPDATING = 'scratch-gui/project-state/START_MANUAL_UPDATING';
|
const START_MANUAL_UPDATING = 'scratch-gui/project-state/START_MANUAL_UPDATING';
|
||||||
const START_REMIXING = 'scratch-gui/project-state/START_REMIXING';
|
const START_REMIXING = 'scratch-gui/project-state/START_REMIXING';
|
||||||
const START_CREATING_COPY = 'scratch-gui/project-state/START_CREATING_COPY';
|
const START_UPDATING_BEFORE_CREATING_COPY = 'scratch-gui/project-state/START_UPDATING_BEFORE_CREATING_COPY';
|
||||||
const START_UPDATING_BEFORE_CREATING_NEW = 'scratch-gui/project-state/START_UPDATING_BEFORE_CREATING_NEW';
|
const START_UPDATING_BEFORE_CREATING_NEW = 'scratch-gui/project-state/START_UPDATING_BEFORE_CREATING_NEW';
|
||||||
|
|
||||||
const defaultProjectId = '0'; // hardcoded id of default project
|
const defaultProjectId = '0'; // hardcoded id of default project
|
||||||
@@ -308,11 +308,10 @@ const reducer = function (state, action) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
case START_CREATING_COPY:
|
case START_UPDATING_BEFORE_CREATING_COPY:
|
||||||
if (state.loadingState === LoadingState.SHOWING_WITH_ID) {
|
if (state.loadingState === LoadingState.SHOWING_WITH_ID) {
|
||||||
// Save the current snapshot as a new project, even when the original has a version conflict.
|
|
||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
loadingState: LoadingState.CREATING_COPY
|
loadingState: LoadingState.UPDATING_BEFORE_COPY
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
@@ -498,7 +497,7 @@ const manualUpdateProject = () => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saveProjectAsCopy = () => ({
|
const saveProjectAsCopy = () => ({
|
||||||
type: START_CREATING_COPY
|
type: START_UPDATING_BEFORE_CREATING_COPY
|
||||||
});
|
});
|
||||||
|
|
||||||
const remixProject = () => ({
|
const remixProject = () => ({
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -378,14 +378,14 @@ test('autoUpdateProject should prepare to update', () => {
|
|||||||
expect(resultState.projectId).toBe('100');
|
expect(resultState.projectId).toBe('100');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('saveProjectAsCopy creates a copy without overwriting the original', () => {
|
test('saveProjectAsCopy should save, before preparing to save as a copy', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
projectId: '100',
|
projectId: '100',
|
||||||
loadingState: LoadingState.SHOWING_WITH_ID
|
loadingState: LoadingState.SHOWING_WITH_ID
|
||||||
};
|
};
|
||||||
const action = saveProjectAsCopy();
|
const action = saveProjectAsCopy();
|
||||||
const resultState = projectStateReducer(initialState, action);
|
const resultState = projectStateReducer(initialState, action);
|
||||||
expect(resultState.loadingState).toBe(LoadingState.CREATING_COPY);
|
expect(resultState.loadingState).toBe(LoadingState.UPDATING_BEFORE_COPY);
|
||||||
expect(resultState.projectId).toBe('100');
|
expect(resultState.projectId).toBe('100');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import {BackendClient} from '../../../src/lib/sccn/api';
|
|
||||||
|
|
||||||
const response = (data, status = 200) => ({ok: status < 400, status, json: async () => data});
|
|
||||||
|
|
||||||
describe('ScCN backend adapter', () => {
|
|
||||||
let client;
|
|
||||||
beforeEach(() => {
|
|
||||||
client = new BackendClient();
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
});
|
|
||||||
afterEach(() => { delete global.fetch; });
|
|
||||||
|
|
||||||
test('uploads even clean builtin assets once per server session', async () => {
|
|
||||||
fetch.mockResolvedValue(response({status: 'ok'}));
|
|
||||||
const asset = {assetId: 'abc', dataFormat: 'svg', data: new Uint8Array([1, 2]), clean: true};
|
|
||||||
await client.upload(asset);
|
|
||||||
await client.upload(asset);
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fetch.mock.calls[0][1]).toMatchObject({credentials: 'include', method: 'POST', body: asset.data});
|
|
||||||
});
|
|
||||||
test('failed uploads remain retryable', async () => {
|
|
||||||
fetch.mockResolvedValueOnce(response({code: 40101}, 401)).mockResolvedValue(response({status: 'ok'}));
|
|
||||||
const asset = {assetId: 'abc', dataFormat: 'svg', data: new Uint8Array([1])};
|
|
||||||
await expect(client.upload(asset)).rejects.toThrow('登录');
|
|
||||||
await client.upload(asset);
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
test('creates a project with raw JSON and updates with the loaded revision', async () => {
|
|
||||||
fetch.mockResolvedValueOnce(response({'content-name': '12', id: 12, revision: 1}))
|
|
||||||
.mockResolvedValueOnce(response({id: 12, revision: 2}));
|
|
||||||
await client.save(null, '{"targets":[]}', {title: '标题'});
|
|
||||||
await client.save('12', '{"targets":[]}', {title: '新标题'});
|
|
||||||
expect(fetch.mock.calls[0][0]).toContain('/editor/projects/?title=');
|
|
||||||
expect(fetch.mock.calls[0][1].body).toBe('{"targets":[]}');
|
|
||||||
expect(fetch.mock.calls[1][0]).toContain('revision=1');
|
|
||||||
expect(client.revisions.get('12')).toBe(2);
|
|
||||||
});
|
|
||||||
test('does not overwrite the loaded revision when refreshing permissions', async () => {
|
|
||||||
fetch.mockResolvedValueOnce(response({data: {revision: 4, canSave: true}}))
|
|
||||||
.mockResolvedValueOnce(response({data: {revision: 9, canSave: true}}))
|
|
||||||
.mockResolvedValueOnce(response({code: 40902}, 409));
|
|
||||||
await client.project('12');
|
|
||||||
await client.permissions('12');
|
|
||||||
expect(client.revisions.get('12')).toBe(4);
|
|
||||||
await expect(client.save('12', '{}')).rejects.toThrow('其他窗口');
|
|
||||||
expect(client.revisions.get('12')).toBe(4);
|
|
||||||
expect(fetch.mock.calls[2][0]).toContain('revision=4');
|
|
||||||
});
|
|
||||||
test('copy and remix create new IDs without a revision precondition', async () => {
|
|
||||||
fetch.mockResolvedValue(response({id: 13, revision: 1}));
|
|
||||||
await client.save(null, '{}', {originalId: 12, isCopy: 1});
|
|
||||||
expect(fetch.mock.calls[0][0]).toContain('original_id=12&is_copy=1');
|
|
||||||
expect(fetch.mock.calls[0][0]).not.toContain('revision=');
|
|
||||||
await client.save(null, '{}', {originalId: 12, isRemix: 1});
|
|
||||||
expect(fetch.mock.calls[1][0]).toContain('original_id=12&is_remix=1');
|
|
||||||
});
|
|
||||||
test('refuses updates without a loaded version', async () => {
|
|
||||||
await expect(client.save('12', '{}')).rejects.toThrow('版本');
|
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
test('switching accounts clears cached ownership', async () => {
|
|
||||||
client.state = {...client.state, user: {id: 1}, projects: {12: {canSave: true}}};
|
|
||||||
fetch.mockResolvedValue(response({data: {user: {id: 2}}}));
|
|
||||||
await client.session();
|
|
||||||
expect(client.state.projects).toEqual({});
|
|
||||||
expect(client.state.user.id).toBe(2);
|
|
||||||
});
|
|
||||||
test('logout clears ownership and uses the HttpOnly cookie', async () => {
|
|
||||||
client.state = {...client.state, user: {id: 1}, projects: {12: {canSave: true}}};
|
|
||||||
fetch.mockResolvedValue(response({code: 0}));
|
|
||||||
await client.logout();
|
|
||||||
expect(client.state.user).toBe(null);
|
|
||||||
expect(client.state.projects).toEqual({});
|
|
||||||
expect(fetch.mock.calls[0][1]).toMatchObject({credentials: 'include', method: 'POST'});
|
|
||||||
});
|
|
||||||
test('handles network and non-JSON responses without claiming a successful save', async () => {
|
|
||||||
fetch.mockRejectedValueOnce(new Error('network'));
|
|
||||||
await expect(client.save(null, '{}')).rejects.toThrow('无法连接服务器');
|
|
||||||
fetch.mockResolvedValueOnce({ok: true, status: 200, json: async () => { throw new Error('html'); }});
|
|
||||||
await expect(client.save(null, '{}')).rejects.toThrow('无效响应');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import projectSaverHOC from '../../../src/lib/project-saver-hoc.jsx';
|
|
||||||
|
|
||||||
jest.mock('react-redux', () => ({connect: () => Component => Component}));
|
|
||||||
jest.mock('scratch-vm', () => class VM {});
|
|
||||||
jest.mock('../../../src/lib/collect-metadata', () => jest.fn());
|
|
||||||
jest.mock('../../../src/lib/log', () => ({error: jest.fn()}));
|
|
||||||
jest.mock('../../../src/reducers/alerts', () => ({}));
|
|
||||||
jest.mock('../../../src/lib/sccn/api', () => ({upload: jest.fn()}));
|
|
||||||
|
|
||||||
const makeSaver = () => {
|
|
||||||
const Component = projectSaverHOC(() => null);
|
|
||||||
const props = {
|
|
||||||
reduxProjectTitle: 'Original title',
|
|
||||||
vm: {toJSON: jest.fn(() => '{"targets":[]}'), assets: []},
|
|
||||||
autoSaveTimeoutId: null,
|
|
||||||
autoSaveIntervalSecs: 30,
|
|
||||||
isShowingSaveable: false,
|
|
||||||
isUpdating: true,
|
|
||||||
projectChanged: true,
|
|
||||||
setAutoSaveTimeoutId: jest.fn(),
|
|
||||||
onSetProjectUnchanged: jest.fn()
|
|
||||||
};
|
|
||||||
const instance = new Component(props);
|
|
||||||
instance.reportTelemetryEvent = jest.fn();
|
|
||||||
return {instance, props};
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => jest.useRealTimers());
|
|
||||||
|
|
||||||
test('edits during a save remain dirty and schedule another save after the saving state ends', async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
const {instance, props} = makeSaver();
|
|
||||||
let finish;
|
|
||||||
props.onUpdateProjectData = jest.fn(() => new Promise(resolve => { finish = resolve; }));
|
|
||||||
const saving = instance.storeProject('1');
|
|
||||||
await Promise.resolve();
|
|
||||||
instance.props = {...props, reduxProjectTitle: 'Edited while saving'};
|
|
||||||
finish({id: 1});
|
|
||||||
await saving;
|
|
||||||
expect(props.onSetProjectUnchanged).not.toHaveBeenCalled();
|
|
||||||
expect(props.setAutoSaveTimeoutId).not.toHaveBeenCalled();
|
|
||||||
const previous = instance.props;
|
|
||||||
instance.props = {...previous, isUpdating: false, isShowingSaveable: true};
|
|
||||||
instance.componentDidUpdate(previous);
|
|
||||||
expect(props.setAutoSaveTimeoutId).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an unchanged snapshot becomes clean without scheduling another save', async () => {
|
|
||||||
const {instance, props} = makeSaver();
|
|
||||||
props.onUpdateProjectData = jest.fn().mockResolvedValue({id: 1});
|
|
||||||
await instance.storeProject('1');
|
|
||||||
expect(props.onSetProjectUnchanged).toHaveBeenCalledTimes(1);
|
|
||||||
expect(props.setAutoSaveTimeoutId).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import {libraryAssetURL} from '../../../src/lib/sccn/library-assets';
|
|
||||||
|
|
||||||
jest.mock('../../../src/lib/sccn/api', () => ({url: path => `https://backend.example/api${path}`}));
|
|
||||||
|
|
||||||
test('library previews use the configured backend including its API prefix', () => {
|
|
||||||
expect(libraryAssetURL('809d9b47347a6af2860e7a3a35bce057.svg')).toBe(
|
|
||||||
'https://backend.example/api/editor/assets/internalapi/asset/809d9b47347a6af2860e7a3a35bce057.svg/get/'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import saveProject from '../../../src/lib/save-project-to-server';
|
|
||||||
import backend from '../../../src/lib/sccn/api';
|
|
||||||
|
|
||||||
jest.mock('../../../src/lib/sccn/api', () => ({save: jest.fn()}));
|
|
||||||
|
|
||||||
describe('saved import navigation', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
global.window = {location: {href: 'http://localhost/editor.html?project_url=https%3A%2F%2Fexample.test%2Fa.sb3&project_title=old&token=read-token&locale=zh-cn#0?token=old'}};
|
|
||||||
global.history = {replaceState: jest.fn()};
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
delete global.window;
|
|
||||||
delete global.history;
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
test('successful save removes stale import overrides but preserves editor options', async () => {
|
|
||||||
backend.save.mockResolvedValue({id: 8, revision: 1});
|
|
||||||
expect(await saveProject(null, '{}', {title: '作品'})).toEqual({id: 8, revision: 1});
|
|
||||||
expect(history.replaceState).toHaveBeenCalledWith(null, '', '/editor.html?locale=zh-cn#0');
|
|
||||||
});
|
|
||||||
test('failed saves leave the import URL intact', async () => {
|
|
||||||
backend.save.mockRejectedValue(new Error('offline'));
|
|
||||||
await expect(saveProject(null, '{}')).rejects.toThrow('offline');
|
|
||||||
expect(history.replaceState).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -179,8 +179,6 @@ module.exports = [
|
|||||||
'process.env.DEBUG': Boolean(process.env.DEBUG),
|
'process.env.DEBUG': Boolean(process.env.DEBUG),
|
||||||
'process.env.ENABLE_SERVICE_WORKER': JSON.stringify(process.env.ENABLE_SERVICE_WORKER || ''),
|
'process.env.ENABLE_SERVICE_WORKER': JSON.stringify(process.env.ENABLE_SERVICE_WORKER || ''),
|
||||||
'process.env.ROOT': JSON.stringify(root),
|
'process.env.ROOT': JSON.stringify(root),
|
||||||
'process.env.SCCN_API_BASE': JSON.stringify(process.env.SCCN_API_BASE ||
|
|
||||||
(process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:3000/api')),
|
|
||||||
'process.env.ROUTING_STYLE': JSON.stringify(process.env.ROUTING_STYLE || 'filehash'),
|
'process.env.ROUTING_STYLE': JSON.stringify(process.env.ROUTING_STYLE || 'filehash'),
|
||||||
'process.env.ENABLE_WINDCHIMES': JSON.stringify(process.env.ENABLE_WINDCHIMES || '')
|
'process.env.ENABLE_WINDCHIMES': JSON.stringify(process.env.ENABLE_WINDCHIMES || '')
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in new issue
Block a user