Learner Stories
Câu chuyện dài của người học, hiện ở www.conan.school/learner-stories. Tham chiếu hình thức: chuyên mục Learner Stories của Codecademy, một bài đọc, không phải một lời khen.
Vì sao nó KHÔNG phải là testimonials
Hệ đã có testimonials từ trước, và hai thứ này dễ bị gộp nhầm:
testimonials | learner_stories | |
|---|---|---|
| Độ dài | một đoạn trích | một bài đọc, có markdown |
| Có trang riêng | không | có, /learner-stories/:slug |
| Trước / sau | không | có (role_before, role_after) |
| Gắn khoá học | qua course_id | qua course_slug + course_name |
| Hiện ở đâu | widget rải trên trang | chuyên mục riêng |
Gộp hai thứ này lại là cách nhanh nhất để mất cả hai: trích dẫn ngắn thì lọt thỏm trong trang danh sách, còn bài dài thì bị cắt cụt trong widget.
/reviews cũng khác nữa, đó là Facebook reviews kéo về, không phải nội dung tự biên tập.
Ba nơi
- Bảng
legacy_learner_stories, migration20260904d_learner_stories.sql. Bảng đi theo họlegacy_vì đọc quagetD1Client(client dán tiền tốlegacy_). Migration chỉ tạo bảng, không chèn nội dung. - API công khai
GET /api/public/learner-stories(danh sách, lọc?community=) vàGET /api/public/learner-stories/:slug(chi tiết + 3 bài đọc tiếp). Cả hai chỉ trả bảnis_public = 1. Danh sách không trả cộtbody, markdown có thể rất dài. - API quản trị
GET/POST/PUT/DELETE /api/admin/learner-stories, giao diện ở admin.conan.school → Marketing & Operations → Marketing → Learner Stories.
Trang web
www.conan.school/app/routes/learner-stories.tsx (danh sách) và learner-stories.$slug.tsx (chi tiết). Theo đúng bộ ba bắt buộc: shadcn/ui (Card, Badge trong app/components/ui/), Framer Motion (gói motion, import motion/react), Tailwind v4 với token từ packages/design-system/theme.css. Không một mã màu tự chế nào trong hai file đó.
Danh sách có bộ lọc theo cộng đồng, nút lọc ghi vào query string (?community=) chứ không giữ trong state, để một bộ lọc là một đường dẫn chia sẻ được.
Chỗ dễ hỏng mà hệ vẫn trông như đang chạy
Bảng chưa có nhưng trang vẫn xanh. Loader của trang bắt lỗi và trả mảng rỗng, nên nếu migration 20260904d chưa được CI áp, /learner-stories hiện "Chưa có câu chuyện nào được đăng", trông hệt như trạng thái chưa ai viết bài. Trước khi kết luận "chưa ai viết", kiểm:
wrangler d1 execute conan-platform-production --remote \
--command "SELECT COUNT(*) FROM legacy_learner_stories"Viết xong nhưng quên bật công khai. is_public mặc định 0. Bài mới tạo trong admin không lên web cho tới khi bấm "Công khai".
Ảnh là link Google Photos, trang vẫn xanh, chỉ ảnh vỡ. Đã xảy ra thật (07.09.2026): câu chuyện đầu tiên có cover_url và avatar_url là https://photos.app.goo.gl/…. Link đó trỏ tới một trang HTML, không phải file ảnh, nên <img src> không hiển thị được và trang chỉ hiện chữ alt. Google Photos còn chặn hotlink, nên không có cách nào dùng thẳng loại link này. Ô nhập lúc đó là một ô text trơn, không kiểm gì, dán nhầm loại link là hỏng lặng lẽ. Nay đã có nút tải ảnh lên và gác ở API.
Trình soạn thảo (từ 07.09.2026)
Trang admin trước đây chỉ tạo mới được, không có nút sửa, nên một câu chuyện gõ sai là phải xoá đi viết lại. Nay mỗi dòng trong danh sách có nút Sửa, mở đúng biểu mẫu đó với nội dung đã điền sẵn.
Thân bài soạn bằng Tiptap (legacy/admin-marcom/src/components/editor/StoryEditor.tsx): tiêu đề, đậm/nghiêng, liên kết, danh sách, trích dẫn, đường ngăn, ảnh, video.
Vẫn là markdown, không đổi sang JSON khối
Cột body giữ nguyên kiểu markdown. Đây là lựa chọn có chủ ý: mọi câu chuyện viết trước khi có editor vẫn mở ra đúng, không cần migration, và www vẫn render bằng react-markdown.
Editor làm việc trên HTML nội bộ của Tiptap, nên có hai bộ chuyển đổi ở story-markdown.ts, markdownToHtml() khi mở bài ra, docToMarkdown() khi gõ. Bộ thứ hai tự viết (không dùng thư viện) vì phải phát ra đúng cú pháp video bên dưới.
Ảnh: tải lên, không dán link
POST /api/admin/learner-stories/images nhận một tệp, nén sang webp dưới 300 KB (dùng Cloudflare Images binding giống media_library), lưu vào R2 conan-cms theo khoá learner-stories/<uuid>.webp, và trả về URL https://image.conan.school/….
Trong editor: bấm nút ảnh, kéo tệp thả vào bài, hoặc dán ảnh từ clipboard, cả ba đều đi qua endpoint này. Ảnh bìa và ảnh đại diện cũng có nút "Tải ảnh lên" riêng.
Video: chỉ YouTube, chỉ ID
Editor lưu video xuống markdown thành một dòng directive:
::youtube[dQw4w9WgXcQ]www render nó thành iframe youtube-nocookie.com tỉ lệ 16:9 (www.conan.school/app/components/story-body.tsx). Chỉ nhận ID YouTube 11 ký tự, người soạn dán được cả youtu.be/…, watch?v=…, /shorts/… và hệ tự rút ID ra, nhưng HTML tự do thì không bao giờ được nhận. Thân bài đi thẳng ra web, cho dán iframe bất kỳ là mở một lỗ chèn mã.
Cú pháp này do hai file cùng định nghĩa, story-markdown.ts ở admin phát ra, story-body.tsx ở www đọc vào. Sửa một bên mà quên bên kia thì video biến thành chữ trần trên trang web, và không có gác nào báo.
Gác URL ảnh
POST và PUT từ chối avatar_url / cover_url trỏ tới trang chia sẻ, photos.app.goo.gl, photos.google.com, drive.google.com, Dropbox, Facebook, imgur, kèm câu giải thích. Lý do ở mục dưới đây.