Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ scripts/dev_server.py

# Local Netlify folder
.netlify
**/supabase/.temp/

# Local Supabase CLI state
supabase/.temp/
Expand Down
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
# bysunling.com

孙玲的中文个人站点,由 Netlify 自动部署。
孙玲的中文个人站点,以及同仓库维护的工作坊子站。两个站点由 Netlify 独立部署。

## 站点结构

| 目录 | 站点 | Netlify 发布目录 |
| --- | --- | --- |
| 仓库根目录 | <https://bysunling.com/> | `.` |
| `workshops/` | <https://workshops.bysunling.com/> | `workshops/public` |

当前是工作坊子站迁移的第一阶段。根目录中的旧工作坊页面会暂时保留,等子域名完成部署和验证后,再改成永久跳转并删除重复文件。

## 本地预览

个人站:

```bash
python -m http.server 8000
```

然后浏览器访问 http://localhost:8000/ 。

## 部署

生产站点:<https://bysunling.com/>
工作坊站:

Netlify 已连接本仓库。推送到 `main` 后会自动触发部署,发布目录为仓库根目录。
```bash
cd workshops
npx netlify dev
```

## URL 结构
## 部署

站点以中文内容为准,首页和各内容页面都直接位于根路径:
`bysunling.com` 继续使用仓库根目录的 `netlify.toml`。

- 首页:`/`
- 工作坊记录:`/workshops.html`
- 输入输出系统工作坊:`/input-output-workshop.html`
- 第二期工作坊参与回看:`/workshop-feedback.html`
- 第二期工作坊回放导航:`/workshop-replay.html`
`workshops.bysunling.com` 使用 `workshops/netlify.toml`。在 Netlify 中把 Base directory 设置为 `workshops`,它会发布 `public/` 并从 `netlify/functions/` 加载 Functions。

旧的 `/zh/*` 和 `/en/*` 地址由 Netlify 永久跳转到新的无语言前缀地址
工作坊子站的迁移与环境变量说明见 [`workshops/README.md`](workshops/README.md)
55 changes: 55 additions & 0 deletions workshops/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# workshops.bysunling.com

孙玲的工作坊子站,与个人站同仓库维护、独立部署。

## 目录

```text
workshops/
├── public/ # 子站页面与静态资源
├── netlify/functions/ # 报名、反馈与二维码 Functions
├── supabase/migrations/ # 工作坊相关数据库迁移
├── planning/ # 不公开发布的策划材料
├── netlify.toml
└── package.json
```

## 本地运行

```bash
npm install
npm test
npx netlify dev
```

## Netlify 设置

从 `sunling/sunling.github.io` 创建第二个 Netlify Site:

- Base directory:`workshops`
- Build command:留空
- Publish directory:`public`
- Functions directory:`netlify/functions`
- Custom domain:`workshops.bysunling.com`

需要从当前个人站复制以下环境变量:

- `SUPABASE_URL`
- `SUPABASE_ANON_KEY`
- `GH_RW_WORKSHOP_ASSETS_TOKEN`
- `GH_REPO_NAME`
- `GH_REPO_BRANCH`
- `ADMIN_PASSCODE`

不要把这些值提交到仓库。

## 迁移顺序

1. 创建新的 Netlify Site,并使用 `workshops` 作为 Base directory。
2. 复制环境变量并完成 Deploy Preview。
3. 运行 `supabase/migrations/20260903023351_allow_workshops_subdomain.sql`。
4. 绑定并验证 `workshops.bysunling.com`。
5. 确认报名、反馈和二维码接口正常。
6. 再从个人站移除旧工作坊文件,并为旧 URL 添加 301 跳转。

第三期的当前策划基线见 [`planning/third-workshop.md`](planning/third-workshop.md)。
22 changes: 22 additions & 0 deletions workshops/netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[build]
publish = "public"
functions = "netlify/functions"

[[redirects]]
from = "/learning-system"
to = "/input-output-workshop.html"
status = 301
force = true

[[redirects]]
from = "/recording-workshop"
to = "/recording-workshop.html"
status = 301
force = true

[[headers]]
for = "/*"

[headers.values]
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
168 changes: 168 additions & 0 deletions workshops/netlify/functions/_shared/workshopFeedback.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
const FEEDBACK_TABLE = "workshop_feedback";
const COHORT = "second-2026-08";
const SOURCE = "workshops.bysunling.com/workshop-feedback.html";

const ATTENDANCE_OPTIONS = new Set([
"live_upper",
"live_lower",
"replay_upper",
"replay_lower",
"none",
]);

const INCOMPLETE_REASON_OPTIONS = new Set([
"time_conflict",
"forgot_or_interrupted",
"tool_threshold",
"setup_incomplete",
"planned_replay",
"motivation_dropped",
"other",
]);

const OUTCOME_OPTIONS = new Set([
"started_recording",
"connected_chatgpt_github",
"connected_doubao_feishu",
"completed_review",
"created_schedule",
"tried_bubble_breaker",
"tried_personal_expression",
"understood_only",
"no_change",
]);

const MAX_LENGTHS = {
incomplete_other: 1000,
expectation_detail: 1500,
learning: 2000,
friction: 2000,
next_iteration: 2000,
contact: 200,
};

function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}

function cleanText(value, fieldName) {
if (value === undefined || value === null) return null;

const normalized = String(value)
.replace(/\r\n/g, "\n")
.replace(/\n{4,}/g, "\n\n\n")
.trim();

if (!normalized) return null;
return normalized.slice(0, MAX_LENGTHS[fieldName] || 2000);
}

function cleanChoices(value, allowed) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map((item) => String(item).trim()))]
.filter((item) => allowed.has(item));
}

function validateFeedback(input = {}) {
const errors = [];
const attendanceModes = cleanChoices(input.attendance_modes, ATTENDANCE_OPTIONS);
const incompleteReasons = cleanChoices(input.incomplete_reasons, INCOMPLETE_REASON_OPTIONS);
const outcomes = cleanChoices(input.outcomes, OUTCOME_OPTIONS);
const score = Number(input.expectation_score);
const expectationScore = Number.isInteger(score) && score >= 1 && score <= 5 ? score : null;

const payload = {
cohort: COHORT,
feedback_version: 1,
attendance_modes: attendanceModes,
incomplete_reasons: incompleteReasons,
incomplete_other: cleanText(input.incomplete_other, "incomplete_other"),
expectation_score: expectationScore,
expectation_detail: cleanText(input.expectation_detail, "expectation_detail"),
outcomes,
learning: cleanText(input.learning, "learning"),
friction: cleanText(input.friction, "friction"),
next_iteration: cleanText(input.next_iteration, "next_iteration"),
follow_up: input.follow_up === true,
contact: cleanText(input.contact, "contact"),
quote_permission: input.quote_permission === "anonymous" ? "anonymous" : "no",
source: SOURCE,
};

if (input.website) errors.push("提交没有成功,请稍后再试。");
if (attendanceModes.length === 0) errors.push("请选择你实际参加过哪些部分。");
if (attendanceModes.includes("none") && attendanceModes.length > 1) {
errors.push("“没有参加或看回放”不能与其他参与方式同时选择。");
}
if (input.expectation_score !== "not_applicable" && expectationScore === null) {
errors.push("请选择这次经历达到期待的程度。");
}
if (outcomes.length === 0) errors.push("请选择工作坊之后实际发生了什么。");
if (outcomes.includes("no_change") && outcomes.length > 1) {
errors.push("“暂时没有变化”不能与其他结果同时选择。");
}
if (!payload.learning && !payload.friction && !payload.next_iteration) {
errors.push("请至少留下一条真实反馈:收获、困惑或下一期建议都可以。");
}
if (payload.follow_up && !payload.contact) {
errors.push("如果愿意进一步聊,请留下一个联系方式。");
}

return { payload, errors };
}

async function insertFeedback(payload, env) {
const supabaseUrl = env.SUPABASE_URL;
const supabaseKey = env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) throw new Error("Missing Supabase environment variables.");

const response = await fetch(`${supabaseUrl}/rest/v1/${FEEDBACK_TABLE}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
Prefer: "return=minimal",
},
body: JSON.stringify(payload),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Supabase insert failed: ${response.status} ${errorText}`);
}
}

async function handleWorkshopFeedback(request, env) {
if (request.method !== "POST") return jsonResponse(405, { error: "Method not allowed." });

let input;
try {
input = await request.json();
} catch {
return jsonResponse(400, { error: "请求格式不正确,请刷新页面后重试。" });
}

const { payload, errors } = validateFeedback(input);
if (errors.length > 0) return jsonResponse(400, { error: errors[0], errors });

try {
await insertFeedback(payload, env);
return jsonResponse(200, { ok: true });
} catch (error) {
console.error(error);
return jsonResponse(500, { error: "提交没有成功,请稍后再试。" });
}
}

export {
COHORT,
SOURCE,
cleanChoices,
validateFeedback,
insertFeedback,
handleWorkshopFeedback,
};
Loading