Skip to content

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:

testimonialslearner_stories
Độ dàimột đoạn tríchmột bài đọc, có markdown
Có trang riêngkhôngcó, /learner-stories/:slug
Trước / saukhôngcó (role_before, role_after)
Gắn khoá họcqua course_idqua course_slug + course_name
Hiện ở đâuwidget rải trên trangchuyê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, migration 20260904d_learner_stories.sql. Bảng đi theo họ legacy_ vì đọc qua getD1Client (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ản is_public = 1. Danh sách không trả cột body, 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:

bash
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.

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.

Tài liệu nội bộ nền tảng Conan School.