-
-
+
+
-
+
{
- setSearch(e.target.value);
- if (currentPage !== 1) {
- setCurrentPage(1);
- }
- }}
+ value={searchDraft}
+ onChange={(e) => setSearchDraft(e.target.value)}
/>
-
-
-
+
+
+
+
+
+
+
+
+ Add Series
+
+
+ Add Plan
+
+
+
+
+
+
+
+
+
+
-
-
- {planData?.plans.length === 0 ? (
-
-
- {t("studio.dashboard.no_plan_found")}
-
+
+ {filterBar}
+
+
+ {isError && error ? (
+
+ ) : showEmpty ? (
+
-
- {t("studio.dashboard.add_plan")}
+
+ {t("studio.dashboard.add_plan")}
-
+
+
+ Add Series
+
+
+
) : (
-
+
+
+
)}
-
0 ? "visible" : "hidden"}>
+
+
replaceUrlState({ page: nextPage })}
/>
diff --git a/src/components/routes/dashboard/DashboardContentTable.tsx b/src/components/routes/dashboard/DashboardContentTable.tsx
new file mode 100644
index 00000000..967ad146
--- /dev/null
+++ b/src/components/routes/dashboard/DashboardContentTable.tsx
@@ -0,0 +1,190 @@
+import { Pecha } from "@/components/ui/shadimport";
+import type { useTranslate } from "@tolgee/react";
+import { useNavigate } from "react-router-dom";
+import defaultCover from "/default-image.webp";
+import { BsThreeDotsVertical } from "react-icons/bs";
+import { LuLayers } from "react-icons/lu";
+import { DropdownButton } from "@/components/ui/molecules/dropdown-button/DropdownButton";
+import type {
+ DashboardLanguageCode,
+ DashboardTableRow,
+} from "./dashboardTable";
+import {
+ DASHBOARD_TABLE_ICON_BTN,
+ formatRowModified,
+ isMockDashboardId,
+} from "./dashboardTable";
+import { ROUTES } from "@/routes/paths";
+import { FeaturedStar, languageChip, statusChip } from "./dashboardTableUi";
+
+interface DashboardContentTableProps {
+ rows: DashboardTableRow[];
+ isLoading?: boolean;
+ t: ReturnType
["t"];
+ handleFeatured: (
+ id: string,
+ kind: DashboardTableRow["kind"],
+ featured: boolean,
+ ) => void;
+}
+
+export function DashboardContentTable({
+ rows,
+ // isLoading,
+ t,
+ handleFeatured,
+}: DashboardContentTableProps) {
+ const navigate = useNavigate();
+
+ const renderBody = () => {
+ return rows.map((row) => {
+ const daysLabel = `${row.total_days} ${row.total_days === 1 ? "Day" : "Days"}`;
+ const titleHref =
+ row.kind === "plan" ? ROUTES.plan(row.id) : ROUTES.series(row.id);
+ const modifiedDisplay = formatRowModified(row);
+ const canToggleFeatured =
+ row.kind === "series" ||
+ (row.kind === "plan" && !isMockDashboardId(row.id));
+ const featuredDisabled = row.status !== "PUBLISHED";
+
+ return (
+
+
+
+

{
+ e.currentTarget.src = defaultCover;
+ }}
+ alt=""
+ className="h-12 w-28 rounded border object-cover"
+ />
+ {row.kind === "series" ? (
+
+
+ {row.plans_count ?? 0}
+
+ ) : null}
+
+
+
+
+
+
+ {row.enrolled}
+
+
+ {modifiedDisplay}
+
+
+ {canToggleFeatured ? (
+ handleFeatured(row.id, row.kind, row.featured)}
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {row.kind === "plan" && isMockDashboardId(row.id) ? (
+
+
+
+
+
+
+
+
+ Preview (mock)
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+ });
+ };
+
+ return (
+
+
+
+
+ {/* {t("studio.dashboard.cover_image")} */}
+
+
+ {t("studio.dashboard.title")}
+
+
+ {t("studio.dashboard.plan_used")}
+
+
+ Date Modified
+
+
+ Featured
+
+
+ {t("studio.dashboard.actions")}
+
+
+
+ {renderBody()}
+
+ );
+}
diff --git a/src/components/routes/dashboard/dashboardApi.test.ts b/src/components/routes/dashboard/dashboardApi.test.ts
new file mode 100644
index 00000000..6824bf17
--- /dev/null
+++ b/src/components/routes/dashboard/dashboardApi.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import {
+ displayDashboardItemTitle,
+ type DashboardApiItem,
+} from "./dashboardApi";
+import { fetchDashboardItems } from "./dashboardApi";
+import axiosInstance from "@/config/axios-config";
+import { vi } from "vitest";
+
+describe("displayDashboardItemTitle", () => {
+ it("uses plan title for plan rows", () => {
+ const item: DashboardApiItem = {
+ id: "p1",
+ type: "plan",
+ title: "7-Day Mindfulness",
+ status: "PUBLISHED",
+ featured: false,
+ languages: ["EN"],
+ enrolled_count: 0,
+ created_at: "2026-01-01T00:00:00Z",
+ };
+ expect(displayDashboardItemTitle(item)).toBe("7-Day Mindfulness");
+ });
+
+ it("uses EN metadata title for series (no top-level title)", () => {
+ const item: DashboardApiItem = {
+ id: "s1",
+ type: "series",
+ metadata: [
+ {
+ id: "m1",
+ title: "Foundations of Meditation",
+ description: "Intro",
+ language: "EN",
+ },
+ {
+ id: "m2",
+ title: "བོད་སྐད་",
+ language: "BO",
+ },
+ ],
+ status: "DRAFT",
+ featured: true,
+ languages: ["EN", "BO"],
+ enrolled_count: 12,
+ plans_count: 3,
+ created_at: "2026-01-01T00:00:00Z",
+ };
+ expect(displayDashboardItemTitle(item)).toBe("Foundations of Meditation");
+ });
+
+ it("falls back when series has no metadata", () => {
+ const item: DashboardApiItem = {
+ id: "s1",
+ type: "series",
+ status: "DRAFT",
+ featured: false,
+ languages: ["EN"],
+ enrolled_count: 0,
+ created_at: "2026-01-01T00:00:00Z",
+ };
+ expect(displayDashboardItemTitle(item)).toBe("Untitled series");
+ });
+});
+
+describe("fetchDashboardItems", () => {
+ it("maps series title from metadata in table rows", async () => {
+ vi.spyOn(axiosInstance, "get").mockResolvedValue({
+ data: {
+ items: [
+ {
+ id: "series-1",
+ type: "series",
+ metadata: [
+ {
+ id: "meta-1",
+ title: "Test Series",
+ language: "EN",
+ },
+ ],
+ status: "PUBLISHED",
+ featured: false,
+ languages: ["EN"],
+ enrolled_count: 0,
+ plans_count: 2,
+ created_at: "2026-01-01T00:00:00Z",
+ },
+ ],
+ pagination: { page: 1, page_size: 10, total: 1, total_pages: 1 },
+ },
+ });
+
+ const result = await fetchDashboardItems({
+ tab: "series",
+ page: 1,
+ });
+
+ expect(result.rows[0]?.title).toBe("Test Series");
+ vi.restoreAllMocks();
+ });
+});
diff --git a/src/components/routes/dashboard/dashboardApi.ts b/src/components/routes/dashboard/dashboardApi.ts
new file mode 100644
index 00000000..3bc94b50
--- /dev/null
+++ b/src/components/routes/dashboard/dashboardApi.ts
@@ -0,0 +1,127 @@
+import axiosInstance from "@/config/axios-config";
+import {
+ normalizeStatus,
+ parseDashboardLanguages,
+ pickSeriesTitle,
+ type DashboardTableRow,
+} from "./dashboardTable";
+
+/** Series rows omit `title` in JSON; titles live in `metadata`. */
+export function displayDashboardItemTitle(item: DashboardApiItem): string {
+ if (item.type === "plan") {
+ return item.title?.trim() || "Untitled plan";
+ }
+ const fromMeta = pickSeriesTitle(undefined, item.metadata);
+ return fromMeta === "Untitled" ? "Untitled series" : fromMeta;
+}
+
+function mapDashboardItemToTableRow(item: DashboardApiItem): DashboardTableRow {
+ return {
+ kind: item.type,
+ id: String(item.id),
+ title: displayDashboardItemTitle(item),
+ image_url: item.image_url ?? "",
+ languages: parseDashboardLanguages(item.languages),
+ status: normalizeStatus(item.status),
+ total_days: item.total_days ?? 0,
+ enrolled: item.enrolled_count ?? 0,
+ modifiedAt: item.updated_at ?? item.created_at ?? null,
+ featured: !!item.featured,
+ ...(item.type === "series" && {
+ plans_count: item.plans_count ?? 0,
+ }),
+ };
+}
+
+export type DashboardTab = "all" | "plans" | "series";
+
+export interface DashboardSeriesMetadataDTO {
+ id: string;
+ title: string;
+ description?: string;
+ language: string;
+}
+
+export interface DashboardApiItem {
+ id: string;
+ type: "plan" | "series";
+ /** Plans only; omitted from JSON for series. */
+ title?: string;
+ metadata?: DashboardSeriesMetadataDTO[];
+ author_id?: string;
+ image_url?: string | null;
+ image_key?: string | null;
+ status: string;
+ featured: boolean;
+ languages: string[];
+ enrolled_count: number;
+ plans_count?: number | null;
+ total_days?: number | null;
+ updated_at?: string | null;
+ created_at: string;
+}
+
+export interface DashboardPagination {
+ page: number;
+ page_size: number;
+ total: number;
+ total_pages: number;
+}
+
+export interface DashboardItemsResponse {
+ items: DashboardApiItem[];
+ pagination: DashboardPagination;
+}
+
+export interface FetchDashboardItemsParams {
+ tab: DashboardTab;
+ page: number;
+ pageSize?: number;
+ search?: string;
+ status?: string;
+ language?: string;
+ featured?: boolean;
+}
+
+export interface DashboardItemsResult {
+ rows: DashboardTableRow[];
+ pagination: DashboardPagination;
+}
+
+const DASHBOARD_ITEMS_PATH = "/api/v1/cms/dashboard/items";
+
+export const DASHBOARD_PAGE_SIZE = 10;
+
+export async function fetchDashboardItems(
+ params: FetchDashboardItemsParams,
+): Promise {
+ const pageSize = params.pageSize ?? DASHBOARD_PAGE_SIZE;
+
+ const { data } = await axiosInstance.get(
+ DASHBOARD_ITEMS_PATH,
+ {
+ params: {
+ tab: params.tab,
+ page: params.page,
+ page_size: pageSize,
+ ...(params.search?.trim() && { search: params.search.trim() }),
+ ...(params.status && { status: params.status }),
+ ...(params.language && { language: params.language }),
+ ...(params.featured != null && { featured: params.featured }),
+ },
+ },
+ );
+
+ const items = data?.items ?? [];
+ const pagination = data?.pagination ?? {
+ page: params.page,
+ page_size: pageSize,
+ total: items.length,
+ total_pages: items.length > 0 ? 1 : 0,
+ };
+
+ return {
+ rows: items.map(mapDashboardItemToTableRow),
+ pagination,
+ };
+}
diff --git a/src/components/routes/dashboard/dashboardTable.ts b/src/components/routes/dashboard/dashboardTable.ts
new file mode 100644
index 00000000..a4491703
--- /dev/null
+++ b/src/components/routes/dashboard/dashboardTable.ts
@@ -0,0 +1,169 @@
+import { formatDistanceToNow } from "date-fns";
+
+export type DashboardRowKind = "plan" | "series";
+
+/** Bordered square control for Featured and Actions columns. */
+export const DASHBOARD_TABLE_ICON_BTN =
+ "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-[#F3F4F6] shadow-none hover:bg-[#E8E8E8] disabled:cursor-not-allowed disabled:opacity-100 dark:border-[#313132] dark:bg-[#2a2a2a] dark:hover:bg-[#333]";
+
+export type DashboardLanguageCode = "EN" | "ZH" | "BO";
+
+export interface DashboardTableRow {
+ kind: DashboardRowKind;
+ id: string;
+ title: string;
+ image_url: string;
+ languages: DashboardLanguageCode[];
+ status: string;
+ total_days: number;
+ enrolled: number;
+ modifiedAt: string | null;
+ dateModifiedLabel?: string;
+ featured: boolean;
+ plans_count?: number;
+}
+
+function normalizeOneLanguageCode(v: string): DashboardLanguageCode | null {
+ const u = v.trim().toUpperCase();
+ if (u === "EN" || u === "ZH" || u === "BO") return u;
+ return null;
+}
+
+export function parseDashboardLanguages(raw: unknown): DashboardLanguageCode[] {
+ const out: DashboardLanguageCode[] = [];
+ const push = (s: string) => {
+ const c = normalizeOneLanguageCode(s);
+ if (c && !out.includes(c)) out.push(c);
+ };
+ if (Array.isArray(raw)) {
+ for (const item of raw) push(String(item));
+ } else if (raw != null && String(raw).trim() !== "") {
+ push(String(raw));
+ }
+ return out;
+}
+
+export function formatRowModified(row: DashboardTableRow): string {
+ if (row.dateModifiedLabel) return row.dateModifiedLabel;
+ if (!row.modifiedAt) return "—";
+ const d = new Date(row.modifiedAt);
+ if (Number.isNaN(d.getTime())) return "—";
+ return formatDistanceToNow(d, { addSuffix: true });
+}
+
+function titleFromMetadataRow(row: Record): string {
+ const title = row.title;
+ return typeof title === "string" && title.trim() ? title.trim() : "";
+}
+
+export function pickSeriesTitle(
+ nameOrTitle: unknown,
+ metadata?: unknown,
+): string {
+ if (Array.isArray(metadata) && metadata.length > 0) {
+ const rows = metadata as Record[];
+ const order = ["EN", "BO", "ZH"];
+ for (const lang of order) {
+ const row = rows.find(
+ (r) => String(r.language ?? r.lang ?? "").toUpperCase() === lang,
+ );
+ const t = row ? titleFromMetadataRow(row) : "";
+ if (t) return t;
+ }
+ for (const row of rows) {
+ const t = titleFromMetadataRow(row);
+ if (t) return t;
+ }
+ }
+ if (!nameOrTitle) return "Untitled";
+ if (typeof nameOrTitle === "string") return nameOrTitle || "Untitled";
+ if (typeof nameOrTitle === "object") {
+ const o = nameOrTitle as Record;
+ const candidate =
+ o.en ||
+ o.EN ||
+ o.bo ||
+ o.BO ||
+ o.zh ||
+ o.ZH ||
+ Object.values(o).find((v) => typeof v === "string");
+ return (candidate as string) || "Untitled";
+ }
+ return "Untitled";
+}
+
+export function normalizeStatus(raw: unknown): string {
+ let s = String(raw ?? "DRAFT").trim();
+ if (s.includes(".")) s = (s.split(".").pop() ?? s).trim();
+ return s.toUpperCase();
+}
+
+export function mapPlanToTableRow(
+ plan: Record,
+): DashboardTableRow {
+ const td = plan.total_days;
+ const days =
+ typeof td === "number" ? td : parseInt(String(td ?? "0"), 10) || 0;
+ const sub = plan.subscription_count ?? plan.subscriptionCount ?? "0";
+ const enrolled =
+ typeof sub === "number" ? sub : parseInt(String(sub), 10) || 0;
+ return {
+ kind: "plan",
+ id: String(plan.id ?? ""),
+ title: String(plan.title ?? "Untitled"),
+ image_url: String(plan.image_url ?? ""),
+ languages: parseDashboardLanguages(
+ plan.languages ?? plan.language ?? plan.language_codes,
+ ),
+ status: normalizeStatus(plan.status),
+ total_days: days,
+ enrolled,
+ modifiedAt:
+ (plan.updated_at as string) ??
+ (plan.updatedAt as string) ??
+ (plan.modified_at as string) ??
+ null,
+ featured: !!plan.featured,
+ };
+}
+
+export function mapSeriesToTableRow(
+ s: Record,
+): DashboardTableRow {
+ const plans = Array.isArray(s.plans)
+ ? (s.plans as Record[])
+ : [];
+ const firstLang = plans[0]?.language as string | undefined;
+ const title =
+ typeof s.title === "string" && s.title
+ ? s.title
+ : pickSeriesTitle(s.name, s.metadata);
+ const td = s.total_days ?? 0;
+ const days =
+ typeof td === "number" ? td : parseInt(String(td ?? "0"), 10) || 0;
+ const enrolledRaw = s.enrolled ?? 0;
+ const enrolled =
+ typeof enrolledRaw === "number"
+ ? enrolledRaw
+ : parseInt(String(enrolledRaw), 10) || 0;
+ return {
+ kind: "series",
+ id: String(s.id ?? ""),
+ title,
+ image_url: String(s.image_url ?? s.image ?? ""),
+ languages: parseDashboardLanguages(
+ s.languages ?? s.language ?? s.language_codes ?? firstLang,
+ ),
+ status: normalizeStatus(s.status),
+ total_days: days,
+ enrolled,
+ modifiedAt: (s.updated_at as string) ?? (s.updatedAt as string) ?? null,
+ featured: !!s.featured,
+ plans_count:
+ typeof s.plans_count === "number" ? s.plans_count : plans.length,
+ };
+}
+
+export function isMockDashboardId(id: string) {
+ return id.startsWith("mock-");
+}
diff --git a/src/components/routes/dashboard/dashboardTableUi.tsx b/src/components/routes/dashboard/dashboardTableUi.tsx
new file mode 100644
index 00000000..3546da9c
--- /dev/null
+++ b/src/components/routes/dashboard/dashboardTableUi.tsx
@@ -0,0 +1,85 @@
+import { FaStar } from "react-icons/fa";
+import type { DashboardLanguageCode } from "./dashboardTable";
+
+export function FeaturedStar({
+ featured,
+ disabled,
+}: {
+ featured: boolean;
+ disabled?: boolean;
+}) {
+ if (disabled) {
+ return (
+
+ );
+ }
+ if (featured) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+export function statusChip(status: string) {
+ switch (status) {
+ case "PUBLISHED":
+ return (
+
+ Published
+
+ );
+ case "UNPUBLISHED":
+ return (
+
+ Unpublished
+
+ );
+ case "ARCHIVED":
+ return (
+
+ Archived
+
+ );
+ default:
+ return (
+
+ Draft
+
+ );
+ }
+}
+
+export function languageChip(code: DashboardLanguageCode) {
+ switch (code) {
+ case "BO":
+ return (
+
+ བོད་སྐད།
+
+ );
+ case "ZH":
+ return (
+
+ 中国人
+
+ );
+ default:
+ return (
+
+ English
+
+ );
+ }
+}
diff --git a/src/components/routes/dashboard/dashboardUrlState.ts b/src/components/routes/dashboard/dashboardUrlState.ts
new file mode 100644
index 00000000..8039c854
--- /dev/null
+++ b/src/components/routes/dashboard/dashboardUrlState.ts
@@ -0,0 +1,154 @@
+import type { DashboardTab } from "./dashboardApi";
+import { DASHBOARD_PAGE_SIZE } from "./dashboardApi";
+import type { FetchDashboardItemsParams } from "./dashboardApi";
+
+export type DashboardPlanStatus =
+ | "DRAFT"
+ | "PUBLISHED"
+ | "UNPUBLISHED"
+ | "ARCHIVED"
+ | "DELETED";
+
+/** UI-only until backend supports dashboard sort query params. */
+export type DashboardSort = "recent";
+
+export interface DashboardUrlState {
+ tab: DashboardTab;
+ page: number;
+ pageSize: number;
+ search: string | null;
+ status: DashboardPlanStatus | null;
+ language: string | null;
+ featured: boolean | null;
+ sort: DashboardSort | null;
+}
+
+export const DASHBOARD_URL_DEFAULTS: DashboardUrlState = {
+ tab: "all",
+ page: 1,
+ pageSize: DASHBOARD_PAGE_SIZE,
+ search: null,
+ status: null,
+ language: null,
+ featured: null,
+ sort: null,
+};
+
+const VALID_TABS = new Set(["all", "series", "plans"]);
+
+const VALID_STATUS = new Set([
+ "DRAFT",
+ "PUBLISHED",
+ "UNPUBLISHED",
+ "ARCHIVED",
+ "DELETED",
+]);
+
+const VALID_LANGUAGE = new Set(["EN", "BO", "ZH"]);
+
+const VALID_SORT = new Set(["recent"]);
+
+function parseTab(raw: string | null): DashboardTab {
+ if (raw && VALID_TABS.has(raw as DashboardTab)) {
+ return raw as DashboardTab;
+ }
+ return DASHBOARD_URL_DEFAULTS.tab;
+}
+
+function parseStatus(raw: string | null): DashboardPlanStatus | null {
+ if (!raw) return null;
+ const u = raw.trim().toUpperCase();
+ return VALID_STATUS.has(u as DashboardPlanStatus)
+ ? (u as DashboardPlanStatus)
+ : null;
+}
+
+function parseLanguage(raw: string | null): string | null {
+ if (!raw) return null;
+ const u = raw.trim().toUpperCase();
+ return VALID_LANGUAGE.has(u) ? u : null;
+}
+
+function parseSort(raw: string | null): DashboardSort | null {
+ if (!raw) return null;
+ return VALID_SORT.has(raw as DashboardSort) ? (raw as DashboardSort) : null;
+}
+
+function parsePositiveInt(
+ raw: string | null,
+ fallback: number,
+ min: number,
+ max?: number,
+): number {
+ const n = Number(raw ?? fallback);
+ if (!Number.isFinite(n)) return fallback;
+ const clamped = Math.max(min, Math.floor(n));
+ if (max != null) return Math.min(max, clamped);
+ return clamped;
+}
+
+export function parseDashboardSearchParams(
+ params: URLSearchParams,
+): DashboardUrlState {
+ const featuredRaw = params.get("featured");
+ const featured =
+ featuredRaw === "true" ? true : featuredRaw === "false" ? false : null;
+
+ const search = params.get("search")?.trim() || null;
+
+ return {
+ tab: parseTab(params.get("tab")),
+ page: parsePositiveInt(params.get("page"), DASHBOARD_URL_DEFAULTS.page, 1),
+ pageSize: parsePositiveInt(
+ params.get("page_size"),
+ DASHBOARD_URL_DEFAULTS.pageSize,
+ 1,
+ 100,
+ ),
+ search,
+ status: parseStatus(params.get("status")),
+ language: parseLanguage(params.get("language")),
+ featured,
+ sort: parseSort(params.get("sort")),
+ };
+}
+
+export function buildDashboardSearchParams(
+ state: DashboardUrlState,
+): URLSearchParams {
+ const p = new URLSearchParams();
+ const d = DASHBOARD_URL_DEFAULTS;
+
+ if (state.tab !== d.tab) p.set("tab", state.tab);
+ if (state.page !== d.page) p.set("page", String(state.page));
+ if (state.pageSize !== d.pageSize) p.set("page_size", String(state.pageSize));
+ if (state.search) p.set("search", state.search);
+ if (state.status) p.set("status", state.status);
+ if (state.language) p.set("language", state.language);
+ if (state.featured !== null) p.set("featured", String(state.featured));
+ if (state.sort) p.set("sort", state.sort);
+
+ return p;
+}
+
+export function dashboardUrlStateToFetchParams(
+ state: DashboardUrlState,
+): FetchDashboardItemsParams {
+ return {
+ tab: state.tab,
+ page: state.page,
+ pageSize: state.pageSize,
+ ...(state.search && { search: state.search }),
+ ...(state.status && { status: state.status }),
+ ...(state.language && { language: state.language }),
+ ...(state.featured != null && { featured: state.featured }),
+ };
+}
+
+/** Merge partial state; pass `page: 1` when filters change. */
+export function mergeDashboardUrlState(
+ current: DashboardUrlState,
+ patch: Partial,
+): DashboardUrlState {
+ return { ...current, ...patch };
+}
diff --git a/src/components/routes/series-details/SeriesDetailsPage.test.tsx b/src/components/routes/series-details/SeriesDetailsPage.test.tsx
new file mode 100644
index 00000000..7862c3a1
--- /dev/null
+++ b/src/components/routes/series-details/SeriesDetailsPage.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi } from "vitest";
+import SeriesDetailsPage from "./SeriesDetailsPage";
+import * as seriesApi from "@/components/routes/create-series/api/seriesApi";
+
+vi.mock(
+ "@/components/routes/create-series/components/PlanSearchSelector",
+ () => ({
+ default: () => ,
+ }),
+);
+
+const seriesFixture = {
+ id: "series-1",
+ metadata: [{ id: "m1", title: "Abhidhamma in a year", language: "EN" }],
+ featured: false,
+ status: "DRAFT",
+ plans: [
+ {
+ id: "plan-zh-1",
+ title: "示例计划",
+ language: "ZH",
+ status: "DRAFT",
+ total_days: 7,
+ featured: false,
+ },
+ {
+ id: "plan-en-1",
+ title: "English Plan",
+ language: "EN",
+ status: "PUBLISHED",
+ total_days: 5,
+ featured: true,
+ },
+ ],
+};
+
+function renderPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+
+ } />
+
+
+ ,
+ );
+}
+
+describe("SeriesDetailsPage", () => {
+ beforeEach(() => {
+ vi.spyOn(seriesApi, "getSeries").mockResolvedValue(seriesFixture as never);
+ vi.spyOn(seriesApi, "putSeriesPlans").mockResolvedValue(
+ seriesFixture as never,
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders series title and plans for selected language tab", async () => {
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Abhidhamma in a year")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("English Plan")).toBeInTheDocument();
+
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: /中文/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("示例计划")).toBeInTheDocument();
+ });
+ expect(screen.queryByText("English Plan")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/routes/series-details/SeriesDetailsPage.tsx b/src/components/routes/series-details/SeriesDetailsPage.tsx
new file mode 100644
index 00000000..a59a131b
--- /dev/null
+++ b/src/components/routes/series-details/SeriesDetailsPage.tsx
@@ -0,0 +1,240 @@
+import { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { IoMdAdd, IoMdArrowBack } from "react-icons/io";
+import { toast } from "sonner";
+import { Pecha } from "@/components/ui/shadimport";
+import axiosInstance from "@/config/axios-config";
+import { PLAN_LANGUAGE } from "@/lib/constant";
+import type { LanguageCode } from "@/schema/SeriesSchema";
+import { ROUTES } from "@/routes/paths";
+import {
+ getSeries,
+ putSeriesPlans,
+} from "@/components/routes/create-series/api/seriesApi";
+import PlanSearchSelector from "@/components/routes/create-series/components/PlanSearchSelector";
+import type { SeriesPlan } from "@/schema/SeriesSchema";
+import { SeriesPlansTable } from "./SeriesPlansTable";
+import {
+ getLanguageTabCounts,
+ getSeriesTitleForLanguage,
+ groupPlansByLanguage,
+ plansByLanguageToIdMap,
+ removePlanFromLanguage,
+ reorderPlansInLanguage,
+ seriesPlanRowsToSeriesPlans,
+ seriesPlansToRows,
+} from "./seriesDetailsMappers";
+import type { PlansByLanguage } from "./seriesDetailsTypes";
+
+const togglePlanFeatured = async (planId: string) => {
+ const { data } = await axiosInstance.patch(
+ `/api/v1/cms/plans/${planId}/featured`,
+ );
+ return data;
+};
+
+const SeriesDetailsPage = () => {
+ const { seriesId } = useParams<{ seriesId: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [activeLanguage, setActiveLanguage] = useState("EN");
+ const [plansByLang, setPlansByLang] = useState({});
+
+ const {
+ data: seriesData,
+ isLoading,
+ isError,
+ error,
+ } = useQuery({
+ queryKey: ["series", seriesId],
+ queryFn: () => getSeries(seriesId!),
+ enabled: Boolean(seriesId),
+ refetchOnWindowFocus: false,
+ });
+
+ useEffect(() => {
+ if (!seriesData) return;
+ setPlansByLang(groupPlansByLanguage(seriesData.plans ?? []));
+ }, [seriesData]);
+
+ const tabCounts = useMemo(
+ () => getLanguageTabCounts(plansByLang),
+ [plansByLang],
+ );
+
+ useEffect(() => {
+ setActiveLanguage("EN");
+ }, [seriesId]);
+
+ const headerTitle = getSeriesTitleForLanguage(
+ seriesData?.metadata,
+ activeLanguage,
+ );
+
+ const activePlans = plansByLang[activeLanguage] ?? [];
+ const activeSeriesPlans = useMemo(
+ () => seriesPlanRowsToSeriesPlans(activePlans),
+ [activePlans],
+ );
+
+ const persistPlansMutation = useMutation({
+ mutationFn: (grouped: PlansByLanguage) =>
+ putSeriesPlans(seriesId!, plansByLanguageToIdMap(grouped)),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["series", seriesId] });
+ queryClient.invalidateQueries({ queryKey: ["dashboard-items"] });
+ },
+ onError: (err: unknown) => {
+ const message =
+ (err as { response?: { data?: { detail?: { message?: string } } } })
+ ?.response?.data?.detail?.message ?? "Could not update series plans";
+ toast.error(message);
+ },
+ });
+
+ const featuredMutation = useMutation({
+ mutationFn: togglePlanFeatured,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["series", seriesId] });
+ queryClient.invalidateQueries({ queryKey: ["dashboard-items"] });
+ },
+ onError: () => toast.error("Could not update featured"),
+ });
+
+ const applyGrouped = (next: PlansByLanguage) => {
+ setPlansByLang(next);
+ persistPlansMutation.mutate(next);
+ };
+
+ const handleActivePlansChange = (next: SeriesPlan[]) => {
+ const nextRows = seriesPlansToRows(next, activeLanguage, activePlans);
+ applyGrouped({ ...plansByLang, [activeLanguage]: nextRows });
+ };
+
+ const handleRemove = (planId: string) => {
+ applyGrouped(removePlanFromLanguage(plansByLang, activeLanguage, planId));
+ };
+
+ const handleReorder = (activeId: string, overId: string) => {
+ applyGrouped(
+ reorderPlansInLanguage(plansByLang, activeLanguage, activeId, overId),
+ );
+ };
+
+ const tabClass = (active: boolean) =>
+ `border-b-2 px-1 pb-2 text-sm font-semibold transition-colors ${
+ active
+ ? "border-[#A51C21] text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`;
+
+ if (!seriesId) {
+ return null;
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
{String(error?.message ?? "Error")}
+
navigate(ROUTES.dashboard)}
+ >
+ Back to dashboard
+
+
+ );
+ }
+
+ return (
+
+
+
navigate("-1")}
+ >
+
+
+
{headerTitle}
+
+ Edit series
+
+
+
+
+
+ {PLAN_LANGUAGE.map(({ label, value }) => {
+ const code = value as LanguageCode;
+ const count = tabCounts[code] ?? 0;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {activePlans.length > 0 ? (
+
+ featuredMutation.mutate(planId)}
+ onRemoveFromSeries={handleRemove}
+ />
+
+ ) : null}
+
+
0 ? "py-8" : "py-16"
+ }`}
+ >
+
+
+
+ Add plan
+
+
+
+
+
+ );
+};
+
+export default SeriesDetailsPage;
diff --git a/src/components/routes/series-details/SeriesPlanRowActions.tsx b/src/components/routes/series-details/SeriesPlanRowActions.tsx
new file mode 100644
index 00000000..3bb3677a
--- /dev/null
+++ b/src/components/routes/series-details/SeriesPlanRowActions.tsx
@@ -0,0 +1,30 @@
+import { DropdownButton } from "@/components/ui/molecules/dropdown-button/DropdownButton";
+import { DASHBOARD_TABLE_ICON_BTN } from "@/components/routes/dashboard/dashboardTable";
+
+type SeriesPlanRowActionsProps = {
+ planId: string;
+ status: string;
+ seriesId: string;
+ onRemoveFromSeries: () => void;
+};
+
+export function SeriesPlanRowActions({
+ planId,
+ status,
+ seriesId,
+ onRemoveFromSeries,
+}: SeriesPlanRowActionsProps) {
+ return (
+
+ );
+}
diff --git a/src/components/routes/series-details/SeriesPlansTable.tsx b/src/components/routes/series-details/SeriesPlansTable.tsx
new file mode 100644
index 00000000..36456002
--- /dev/null
+++ b/src/components/routes/series-details/SeriesPlansTable.tsx
@@ -0,0 +1,217 @@
+import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
+import { CSS } from "@dnd-kit/utilities";
+import { Pecha } from "@/components/ui/shadimport";
+import { useNavigate } from "react-router-dom";
+import defaultCover from "/default-image.webp";
+import { PiDotsSixVertical } from "react-icons/pi";
+import { formatDistanceToNow } from "date-fns";
+import { ROUTES } from "@/routes/paths";
+import { DASHBOARD_TABLE_ICON_BTN } from "@/components/routes/dashboard/dashboardTable";
+import {
+ FeaturedStar,
+ languageChip,
+ statusChip,
+} from "@/components/routes/dashboard/dashboardTableUi";
+import type { SeriesPlanRow } from "./seriesDetailsTypes";
+import { SeriesPlanRowActions } from "./SeriesPlanRowActions";
+
+function formatPlanModified(modifiedAt: string | null): string {
+ if (!modifiedAt) return "—";
+ const d = new Date(modifiedAt);
+ if (Number.isNaN(d.getTime())) return "—";
+ return formatDistanceToNow(d, { addSuffix: true });
+}
+
+function SortablePlanRow({
+ plan,
+ seriesId,
+ onToggleFeatured,
+ onRemoveFromSeries,
+ canReorder,
+}: {
+ plan: SeriesPlanRow;
+ seriesId: string;
+ onToggleFeatured: (planId: string) => void;
+ onRemoveFromSeries: (planId: string) => void;
+ canReorder: boolean;
+}) {
+ const navigate = useNavigate();
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: plan.id, disabled: !canReorder });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ const daysLabel = `${plan.total_days} ${plan.total_days === 1 ? "Day" : "Days"}`;
+ const featuredDisabled = plan.status !== "PUBLISHED";
+
+ return (
+
+
+
+
+
+
+
+
+ {plan.enrolled}
+
+
+ {formatPlanModified(plan.modifiedAt)}
+
+
+ onToggleFeatured(plan.id)}
+ >
+
+
+
+
+ onRemoveFromSeries(plan.id)}
+ />
+
+
+ );
+}
+
+type SeriesPlansTableProps = {
+ plans: SeriesPlanRow[];
+ seriesId: string;
+ onReorder: (activeId: string, overId: string) => void;
+ onToggleFeatured: (planId: string) => void;
+ onRemoveFromSeries: (planId: string) => void;
+};
+
+export function SeriesPlansTable({
+ plans,
+ seriesId,
+ onReorder,
+ onToggleFeatured,
+ onRemoveFromSeries,
+}: SeriesPlansTableProps) {
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
+ );
+ const canReorder = plans.length > 1;
+ const ids = plans.map((p) => p.id);
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ if (!canReorder) return;
+ const { active, over } = event;
+ if (over && active.id !== over.id) {
+ onReorder(String(active.id), String(over.id));
+ }
+ };
+
+ return (
+
+
+
+
+
+ Title
+
+ Enrolled
+
+
+ Date Modified
+
+
+ Featured
+
+
+ Actions
+
+
+
+
+
+ {plans.map((plan) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/routes/series-details/seriesDetailsMappers.test.ts b/src/components/routes/series-details/seriesDetailsMappers.test.ts
new file mode 100644
index 00000000..f29da13b
--- /dev/null
+++ b/src/components/routes/series-details/seriesDetailsMappers.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import {
+ groupPlansByLanguage,
+ plansByLanguageToIdMap,
+ reorderPlansInLanguage,
+} from "./seriesDetailsMappers";
+import type { SeriesPlanDTO } from "@/components/routes/create-series/api/seriesApi";
+
+const plans: SeriesPlanDTO[] = [
+ {
+ id: "p1",
+ title: "Plan 1",
+ language: "EN",
+ display_order: 2,
+ },
+ {
+ id: "p2",
+ title: "Plan 2",
+ language: "EN",
+ display_order: 1,
+ },
+ {
+ id: "p3",
+ title: "Plan ZH",
+ language: "ZH",
+ },
+];
+
+describe("seriesDetailsMappers", () => {
+ it("groups and sorts plans by display_order", () => {
+ const grouped = groupPlansByLanguage(plans);
+ expect(grouped.EN?.map((p) => p.id)).toEqual(["p2", "p1"]);
+ expect(grouped.ZH?.map((p) => p.id)).toEqual(["p3"]);
+ });
+
+ it("reorderPlansInLanguage updates id order", () => {
+ const grouped = groupPlansByLanguage(plans);
+ const next = reorderPlansInLanguage(grouped, "EN", "p1", "p2");
+ expect(next.EN?.map((p) => p.id)).toEqual(["p1", "p2"]);
+ });
+
+ it("plansByLanguageToIdMap preserves order", () => {
+ const grouped = groupPlansByLanguage(plans);
+ const reordered = reorderPlansInLanguage(grouped, "EN", "p1", "p2");
+ expect(plansByLanguageToIdMap(reordered)).toEqual({
+ EN: ["p1", "p2"],
+ ZH: ["p3"],
+ });
+ });
+});
diff --git a/src/components/routes/series-details/seriesDetailsMappers.ts b/src/components/routes/series-details/seriesDetailsMappers.ts
new file mode 100644
index 00000000..62c7fc8f
--- /dev/null
+++ b/src/components/routes/series-details/seriesDetailsMappers.ts
@@ -0,0 +1,185 @@
+import type {
+ SeriesMetadataDTO,
+ SeriesPlanDTO,
+} from "@/components/routes/create-series/api/seriesApi";
+import type { LanguageCode } from "@/schema/SeriesSchema";
+import { normalizeStatus } from "@/components/routes/dashboard/dashboardTable";
+import type { Plan } from "@/components/routes/create-series/api/planSearchApi";
+import type { SeriesPlan } from "@/schema/SeriesSchema";
+import type { PlansByLanguage, SeriesPlanRow } from "./seriesDetailsTypes";
+
+const LANG_ORDER: LanguageCode[] = ["EN", "BO", "ZH"];
+
+function normalizeLang(raw: string): LanguageCode | null {
+ const u = raw.trim().toUpperCase();
+ if (u === "EN" || u === "BO" || u === "ZH") return u;
+ return null;
+}
+
+export function mapPlanDtoToRow(plan: SeriesPlanDTO): SeriesPlanRow | null {
+ const language = normalizeLang(plan.language);
+ if (!language) return null;
+ const enrolledRaw = plan.enrolled_count ?? plan.subscription_count ?? 0;
+ const enrolled =
+ typeof enrolledRaw === "number"
+ ? enrolledRaw
+ : parseInt(String(enrolledRaw), 10) || 0;
+ const td = plan.total_days ?? 0;
+ const total_days =
+ typeof td === "number" ? td : parseInt(String(td ?? "0"), 10) || 0;
+ return {
+ id: String(plan.id),
+ title: plan.title,
+ image_url: plan.image_url ?? "",
+ language,
+ status: normalizeStatus(plan.status),
+ total_days,
+ enrolled,
+ modifiedAt: plan.updated_at ?? null,
+ featured: !!plan.featured,
+ };
+}
+
+export function mapSearchPlanToRow(plan: Plan): SeriesPlanRow | null {
+ const language = normalizeLang(plan.language);
+ if (!language) return null;
+ return {
+ id: plan.id,
+ title: plan.title,
+ image_url: plan.image_url ?? "",
+ language,
+ status: normalizeStatus(plan.status),
+ total_days: plan.total_days ?? 0,
+ enrolled: plan.subscription_count ?? 0,
+ modifiedAt: null,
+ featured: !!plan.featured,
+ };
+}
+
+export function groupPlansByLanguage(plans: SeriesPlanDTO[]): PlansByLanguage {
+ const buckets: Partial<
+ Record
+ > = {};
+ for (const p of plans) {
+ const row = mapPlanDtoToRow(p);
+ if (!row) continue;
+ const ord =
+ typeof p.display_order === "number" && !Number.isNaN(p.display_order)
+ ? p.display_order
+ : 1_000_000;
+ if (!buckets[row.language]) buckets[row.language] = [];
+ buckets[row.language]!.push({ row, ord });
+ }
+ const out: PlansByLanguage = {};
+ for (const code of LANG_ORDER) {
+ const list = buckets[code];
+ if (!list?.length) continue;
+ out[code] = [...list].sort((a, b) => a.ord - b.ord).map((x) => x.row);
+ }
+ return out;
+}
+
+export function plansByLanguageToIdMap(
+ grouped: PlansByLanguage,
+): Partial> {
+ const out: Partial> = {};
+ for (const code of LANG_ORDER) {
+ const rows = grouped[code];
+ if (rows?.length) out[code] = rows.map((r) => r.id);
+ }
+ return out;
+}
+
+export function getLanguageTabCounts(
+ grouped: PlansByLanguage,
+): Record {
+ return {
+ EN: grouped.EN?.length ?? 0,
+ BO: grouped.BO?.length ?? 0,
+ ZH: grouped.ZH?.length ?? 0,
+ };
+}
+
+export function getSeriesTitleForLanguage(
+ metadata: SeriesMetadataDTO[] | undefined,
+ language: LanguageCode,
+ fallback = "Untitled series",
+): string {
+ if (!metadata?.length) return fallback;
+ const row = metadata.find(
+ (m) => normalizeLang(String(m.language ?? m.lang ?? "")) === language,
+ );
+ const title = row?.title?.trim();
+ if (title) return title;
+ const en = metadata.find(
+ (m) => normalizeLang(String(m.language ?? m.lang ?? "")) === "EN",
+ );
+ if (en?.title?.trim()) return en.title.trim();
+ return metadata[0]?.title?.trim() || fallback;
+}
+
+export function reorderPlansInLanguage(
+ grouped: PlansByLanguage,
+ language: LanguageCode,
+ activeId: string,
+ overId: string,
+): PlansByLanguage {
+ const list = [...(grouped[language] ?? [])];
+ const from = list.findIndex((p) => p.id === activeId);
+ const to = list.findIndex((p) => p.id === overId);
+ if (from < 0 || to < 0 || from === to) return grouped;
+ const [moved] = list.splice(from, 1);
+ list.splice(to, 0, moved);
+ return { ...grouped, [language]: list };
+}
+
+export function attachPlanToLanguage(
+ grouped: PlansByLanguage,
+ language: LanguageCode,
+ plan: SeriesPlanRow,
+): PlansByLanguage {
+ const list = grouped[language] ?? [];
+ if (list.some((p) => p.id === plan.id)) return grouped;
+ return { ...grouped, [language]: [...list, plan] };
+}
+
+export function seriesPlanRowsToSeriesPlans(
+ rows: SeriesPlanRow[],
+): SeriesPlan[] {
+ return rows.map(({ id, title, image_url }) => ({
+ id,
+ title,
+ image_url: image_url || undefined,
+ }));
+}
+
+export function seriesPlansToRows(
+ plans: SeriesPlan[],
+ language: LanguageCode,
+ existing: SeriesPlanRow[],
+): SeriesPlanRow[] {
+ return plans.map((sp) => {
+ const prev = existing.find((p) => p.id === sp.id);
+ if (prev) return prev;
+ return {
+ id: sp.id,
+ title: sp.title,
+ image_url: sp.image_url ?? "",
+ language,
+ status: "DRAFT",
+ total_days: 0,
+ enrolled: 0,
+ modifiedAt: null,
+ featured: false,
+ };
+ });
+}
+
+export function removePlanFromLanguage(
+ grouped: PlansByLanguage,
+ language: LanguageCode,
+ planId: string,
+): PlansByLanguage {
+ const list = grouped[language] ?? [];
+ return { ...grouped, [language]: list.filter((p) => p.id !== planId) };
+}
diff --git a/src/components/routes/series-details/seriesDetailsTypes.ts b/src/components/routes/series-details/seriesDetailsTypes.ts
new file mode 100644
index 00000000..b4143fb2
--- /dev/null
+++ b/src/components/routes/series-details/seriesDetailsTypes.ts
@@ -0,0 +1,15 @@
+import type { LanguageCode } from "@/schema/SeriesSchema";
+
+export type SeriesPlanRow = {
+ id: string;
+ title: string;
+ image_url: string;
+ language: LanguageCode;
+ status: string;
+ total_days: number;
+ enrolled: number;
+ modifiedAt: string | null;
+ featured: boolean;
+};
+
+export type PlansByLanguage = Partial>;
diff --git a/src/components/routes/task/PlanDetailsPage.tsx b/src/components/routes/task/PlanDetailsPage.tsx
index 0eedb9d7..2d305077 100644
--- a/src/components/routes/task/PlanDetailsPage.tsx
+++ b/src/components/routes/task/PlanDetailsPage.tsx
@@ -18,8 +18,12 @@ const PlanDetailsPage = () => {
enabled: !!planId,
});
- const status = planDetails?.status || "DRAFT";
- const isEditable = status === "DRAFT" || status === "ARCHIVED";
+ // const status = planDetails?.status || "DRAFT";
+ // const isEditable = status === "DRAFT" || status === "ARCHIVED";
+ const isEditable = true;
+ const currentDayData = planDetails?.days?.find(
+ (day: { day_number: number }) => day.day_number === selectedDay,
+ );
const handleDaySelect = (dayNumber: number) => {
setSelectedDay(dayNumber);
@@ -68,6 +72,7 @@ const PlanDetailsPage = () => {
taskId={selectedTaskId}
onEditTask={handleEditTask}
isEditable={isEditable}
+ dayAudioUrl={currentDayData?.audio_url}
/>
) : (
{
+ const formData = new FormData();
+ formData.append("file", file);
+ if (duration_ms != null) {
+ formData.append("duration_ms", String(duration_ms));
+ }
+ const { data } = await axiosInstance.post(
+ `/api/v1/cms/media/upload/day-audio`,
+ formData,
+ {
+ params: { day_id },
+ headers: getAuthHeaders(),
+ },
+ );
+ return data;
+};
+
+export const deleteDayAudio = async (day_id: string) => {
+ await axiosInstance.delete(`/api/v1/cms/plans/days/${day_id}/audio`, {
+ headers: getAuthHeaders(),
+ });
+};
diff --git a/src/components/routes/task/api/taskApi.ts b/src/components/routes/task/api/taskApi.ts
index c4f5ce69..62da62e0 100644
--- a/src/components/routes/task/api/taskApi.ts
+++ b/src/components/routes/task/api/taskApi.ts
@@ -37,17 +37,21 @@ export const uploadImageToS3 = async (file: File, plan_id: string) => {
return data;
};
+export interface SubTaskPayload {
+ content: string | null;
+ content_type: string;
+ display_order: number;
+ duration?: string;
+ source_text_id?: string | null;
+ pecha_segment_id?: string | null;
+ segment_ids?: string[] | null;
+ start_ms?: number | null;
+ end_ms?: number | null;
+}
+
export const createSubTasks = async (
task_id: string,
- subTasksData: {
- content: string | null;
- content_type: string;
- display_order: number;
- duration?: string;
- source_text_id?: string | null;
- pecha_segment_id?: string | null;
- segment_ids?: string[] | null;
- }[],
+ subTasksData: SubTaskPayload[],
) => {
const { data } = await axiosInstance.post(
`/api/v1/cms/sub-tasks`,
@@ -62,18 +66,13 @@ export const createSubTasks = async (
return data;
};
+export interface SubTaskUpdatePayload extends SubTaskPayload {
+ id: string | null;
+}
+
export const updateSubTasks = async (
task_id: string,
- subTasksData: {
- id: string | null;
- content: string | null;
- content_type: string;
- display_order: number;
- duration?: string;
- source_text_id?: string | null;
- pecha_segment_id?: string | null;
- segment_ids?: string[] | null;
- }[],
+ subTasksData: SubTaskUpdatePayload[],
) => {
await axiosInstance.put(
`/api/v1/cms/sub-tasks`,
diff --git a/src/components/routes/task/components/view/TaskForm.tsx b/src/components/routes/task/components/view/TaskForm.tsx
index e243ee08..92b298f0 100644
--- a/src/components/routes/task/components/view/TaskForm.tsx
+++ b/src/components/routes/task/components/view/TaskForm.tsx
@@ -22,6 +22,12 @@ import {
type SubTask,
} from "@/components/ui/molecules/subtask-card/SubTaskCard";
import DaySelector from "@/components/ui/molecules/day-selector/DaySelector";
+import DayAudioSection from "@/components/ui/molecules/day-audio-section/DayAudioSection";
+import {
+ buildSubTaskTimestampFields,
+ mapApiSubtaskTimestamps,
+ validateSubTaskTimestamps,
+} from "@/components/ui/molecules/subtask-card/subtaskTimestamps";
interface TaskFormProps {
selectedDay: number;
@@ -86,6 +92,7 @@ const TaskForm = ({
pecha_segment_id: subTask.pecha_segment_id || null,
segment_ids: subTask.segment_ids || null,
}),
+ ...buildSubTaskTimestampFields(subTask, false),
}));
await createSubTasks(taskResponse.id, subTasksPayload);
}
@@ -119,6 +126,7 @@ const TaskForm = ({
pecha_segment_id: subTask.pecha_segment_id || null,
segment_ids: subTask.segment_ids || null,
}),
+ ...buildSubTaskTimestampFields(subTask, true),
}));
await updateSubTasks(editingTask.id, subTasksPayload);
},
@@ -163,6 +171,11 @@ const TaskForm = ({
(a: { display_order: number }, b: { display_order: number }) =>
a.display_order - b.display_order,
);
+ const timestamps = (data: {
+ start_ms?: number | null;
+ end_ms?: number | null;
+ }) => mapApiSubtaskTimestamps(data);
+
const subTasksData: SubTask[] = sorted.map((data: any) => {
switch (data.content_type) {
case "VIDEO":
@@ -171,18 +184,21 @@ const TaskForm = ({
content_type: "VIDEO" as const,
content: data.content,
duration: data.duration,
+ ...timestamps(data),
};
case "TEXT":
return {
id: data.id,
content_type: "TEXT" as const,
content: data.content,
+ ...timestamps(data),
};
case "AUDIO":
return {
id: data.id,
content_type: "AUDIO" as const,
content: data.content,
+ ...timestamps(data),
};
case "IMAGE":
return {
@@ -190,6 +206,7 @@ const TaskForm = ({
content_type: "IMAGE" as const,
imagePreview: data.content,
content: data.image_url,
+ ...timestamps(data),
};
case "SOURCE_REFERENCE":
return {
@@ -199,12 +216,14 @@ const TaskForm = ({
source_text_id: data.source_text_id || null,
pecha_segment_id: data.pecha_segment_id || null,
segment_ids: data.segment_ids || null,
+ ...timestamps(data),
};
default:
return {
id: data.id,
content_type: "TEXT" as const,
content: data.content,
+ ...timestamps(data),
};
}
});
@@ -322,6 +341,15 @@ const TaskForm = ({
};
const onSubmit = async (data: TaskFormData) => {
+ const timestampError = validateSubTaskTimestamps(
+ subTasks,
+ currentDayData?.audio_duration_ms,
+ );
+ if (timestampError) {
+ toast.error(timestampError);
+ return;
+ }
+
const taskData: any = {
plan_id: planId!,
day_id: currentDayData!.id,
@@ -340,6 +368,18 @@ const TaskForm = ({
{isEditMode ? "Edit Task" : "Add Task"}
+ {currentDayData && planId && (
+
+ )}
+
{renderContent()}
+ {showTimestamps && (
+
+ )}
);
};
diff --git a/src/components/ui/molecules/subtask-card/subtaskTimestamps.ts b/src/components/ui/molecules/subtask-card/subtaskTimestamps.ts
new file mode 100644
index 00000000..8f678dcc
--- /dev/null
+++ b/src/components/ui/molecules/subtask-card/subtaskTimestamps.ts
@@ -0,0 +1,59 @@
+import type { SubTask } from "./SubTaskCard";
+import { formatMs } from "@/lib/utils";
+
+export function buildSubTaskTimestampFields(
+ subTask: SubTask,
+ isUpdate: boolean,
+): { start_ms?: number | null; end_ms?: number | null } {
+ const { start_ms, end_ms } = subTask;
+ if (start_ms != null && end_ms != null) {
+ return { start_ms, end_ms };
+ }
+ if (isUpdate && start_ms === null && end_ms === null) {
+ return { start_ms: null, end_ms: null };
+ }
+ return {};
+}
+
+export function validateSubTaskTimestamps(
+ subTasks: SubTask[],
+ maxDurationMs?: number | null,
+): string | null {
+ for (let i = 0; i < subTasks.length; i++) {
+ const st = subTasks[i];
+ const hasStart = st.start_ms != null;
+ const hasEnd = st.end_ms != null;
+ if (hasStart !== hasEnd) {
+ return `Subtask ${i + 1}: provide both start and end times, or clear both.`;
+ }
+ if (
+ hasStart &&
+ hasEnd &&
+ (st.start_ms as number) >= (st.end_ms as number)
+ ) {
+ return `Subtask ${i + 1}: start must be before end.`;
+ }
+ if (
+ hasStart &&
+ hasEnd &&
+ maxDurationMs != null &&
+ (st.end_ms as number) > maxDurationMs
+ ) {
+ return `Subtask ${i + 1}: end time must be ≤ day audio (${formatMs(maxDurationMs)}).`;
+ }
+ }
+ return null;
+}
+
+export function mapApiSubtaskTimestamps(data: {
+ start_ms?: number | null;
+ end_ms?: number | null;
+}) {
+ const hasStart = data.start_ms != null;
+ const hasEnd = data.end_ms != null;
+ if (!hasStart && !hasEnd) return {};
+ return {
+ start_ms: data.start_ms ?? null,
+ end_ms: data.end_ms ?? null,
+ };
+}
diff --git a/src/components/ui/molecules/tag-input/TagInput.tsx b/src/components/ui/molecules/tag-input/TagInput.tsx
index 5c860010..7297ac4b 100644
--- a/src/components/ui/molecules/tag-input/TagInput.tsx
+++ b/src/components/ui/molecules/tag-input/TagInput.tsx
@@ -5,9 +5,10 @@ import { Input } from "../../atoms/input";
interface TagInputProps {
value?: string[];
onChange?: (tags: string[]) => void;
+ size?: "sm" | "md";
}
-const TagInput = ({ value = [], onChange }: TagInputProps) => {
+const TagInput = ({ value = [], onChange, size = "md" }: TagInputProps) => {
const [inputValue, setInputValue] = useState("");
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -31,7 +32,11 @@ const TagInput = ({ value = [], onChange }: TagInputProps) => {
return (
Tags
-
+
{
today.setHours(0, 0, 0, 0);
return d < today;
};
+
+/** Format milliseconds as m:ss for audio timestamps */
+export const formatMs = (ms: number): string => {
+ const totalSec = Math.floor(ms / 1000);
+ const m = Math.floor(totalSec / 60);
+ const s = totalSec % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+};
+
+/** Read duration from a local audio file before upload */
+export const getAudioDurationMs = (file: File): Promise
=>
+ new Promise((resolve, reject) => {
+ const audio = document.createElement("audio");
+ audio.preload = "metadata";
+ const url = URL.createObjectURL(file);
+ audio.onloadedmetadata = () => {
+ URL.revokeObjectURL(url);
+ resolve(Math.round(audio.duration * 1000));
+ };
+ audio.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error("Failed to read audio duration"));
+ };
+ audio.src = url;
+ });
diff --git a/src/main.tsx b/src/main.tsx
index 7f0414f5..851b027b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -23,6 +23,8 @@ import Signup from "./components/auth/signup/Signup";
import Dashboard from "./components/routes/dashboard/Dashboard";
import Analytics from "./components/routes/analytics/Analytics";
import CreatePlan from "./components/routes/create-plan/CreatePlan";
+import CreateSeries from "./components/routes/create-series/CreateSeries";
+import SeriesDetailsPage from "./components/routes/series-details/SeriesDetailsPage";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import ResetPassword from "./components/auth/reset-password/ResetPassword.tsx";
import PlanDetailsPage from "./components/routes/task/PlanDetailsPage.tsx";
@@ -34,9 +36,9 @@ import { ROUTES } from "./routes/paths.ts";
const queryClient = new QueryClient();
const defaultLanguage = import.meta.env.VITE_DEFAULT_LANGUAGE || "en";
-const tolgee = new (Tolgee as any)()
- .use(DevTools)
- .use(FormatSimple)
+const tolgee = Tolgee()
+ .use(DevTools())
+ .use(FormatSimple())
.use(
BackendFetch({
prefix:
@@ -114,6 +116,30 @@ const router = createBrowserRouter([
),
},
+ {
+ path: ROUTES.seriesNew,
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "/series/:seriesId/edit",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "/series/:seriesId",
+ element: (
+
+
+
+ ),
+ },
{
path: "/analytics",
element: (
diff --git a/src/routes/paths.ts b/src/routes/paths.ts
index 794f4c6a..ee2d68c9 100644
--- a/src/routes/paths.ts
+++ b/src/routes/paths.ts
@@ -9,6 +9,9 @@ export const ROUTES = {
planNew: "/plan/new",
plan: (planId: string) => `/plan/${planId}`,
planEdit: (planId: string) => `/plan/${planId}/edit`,
+ seriesNew: "/series/new",
+ series: (seriesId: string) => `/series/${seriesId}`,
+ seriesEdit: (seriesId: string) => `/series/${seriesId}/edit`,
analytics: "/analytics",
profile: "/profile",
tags: "/tags",
diff --git a/src/schema/SeriesSchema.ts b/src/schema/SeriesSchema.ts
new file mode 100644
index 00000000..4f24125a
--- /dev/null
+++ b/src/schema/SeriesSchema.ts
@@ -0,0 +1,45 @@
+import { z } from "zod";
+import { PLAN_LANGUAGE } from "@/lib/constant";
+
+const LANGUAGE_CODES = PLAN_LANGUAGE.map((l) => l.value) as ["EN", "BO", "ZH"];
+export type LanguageCode = (typeof LANGUAGE_CODES)[number];
+
+export const languageBlockSchema = z.object({
+ title: z.string().trim(),
+ description: z.string().trim(),
+});
+
+export const planItemSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ image_url: z.string().optional(),
+});
+
+export type SeriesPlan = z.infer;
+
+export const seriesSchema = z
+ .object({
+ languages: z.object({
+ EN: languageBlockSchema.optional(),
+ BO: languageBlockSchema.optional(),
+ ZH: languageBlockSchema.optional(),
+ }),
+ plans: z.object({
+ EN: z.array(planItemSchema).optional(),
+ BO: z.array(planItemSchema).optional(),
+ ZH: z.array(planItemSchema).optional(),
+ }),
+ image_url: z.string().trim().min(1, "Cover image is required"),
+ })
+ .superRefine((data, ctx) => {
+ const present = LANGUAGE_CODES.filter((c) => data.languages[c] != null);
+ if (present.length < 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Add at least one language",
+ path: ["languages"],
+ });
+ }
+ });
+
+export type SeriesFormData = z.infer;