Archived
feat: US #32 Section-based Reading Recommendations
Backend: - Add ReadingSchedule and MeetingSection models for ebook reading schedules - Implement recommendation algorithm: greedy partition minimizing per-meeting time variance - Algorithm respects chapter boundaries, estimates time based on chapter weight - Add serializers for schedules (list/detail/generate/confirm/update) - Add ScheduleViewSet with endpoints: list, create (generate), retrieve, regenerate, confirm, update_meeting, reorder_meetings - Add URL routes for /api/books/schedules/ - Create migration 0004 for new models Frontend: - Add types: MeetingSection, ReadingSchedule, GenerateRecommendationsRequest - Add schedulesApi with methods: getSchedules, generateRecommendations, confirmSchedule, updateMeeting, reorderMeetings, deleteSchedule - Build RecommendationPage with 4-column meeting layout, drag-and-drop chapter reassignment, generate/regenerate/confirm flow - Add 'Schedule Reading' menu item to BookContextMenu - Add route: /books/:ebookId/schedule
This commit is contained in:
@@ -12,6 +12,7 @@ const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default:
|
||||
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
|
||||
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
|
||||
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
|
||||
const RecommendationPage = lazy(() => import("./pages/RecommendationPage").then((m) => ({ default: m.RecommendationPage })));
|
||||
|
||||
const AuthPage = lazy(() => import("./pages/AuthPage"));
|
||||
|
||||
@@ -40,6 +41,7 @@ function AppRoutes() {
|
||||
<Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
|
||||
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/books/:ebookId/schedule" element={<ProtectedRoute><RecommendationPage /></ProtectedRoute>} />
|
||||
<Route path="/read/:id" element={<ProtectedRoute><ReadingPage /></ProtectedRoute>} />
|
||||
<Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} />
|
||||
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
|
||||
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
ContentResponse,
|
||||
EBookDetail,
|
||||
EBookListItem,
|
||||
GenerateRecommendationsRequest,
|
||||
ReadingProgress,
|
||||
ReadingSchedule,
|
||||
ReadingSettings,
|
||||
TocResponse,
|
||||
} from "../types/book";
|
||||
@@ -131,4 +133,65 @@ export const booksApi = {
|
||||
const { data } = await api.patch<ReadingSettings>("/books/settings/", settingsData);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const schedulesApi = {
|
||||
async getSchedules(): Promise<ReadingSchedule[]> {
|
||||
const { data } = await api.get<ReadingSchedule[]>("/books/schedules/");
|
||||
return data;
|
||||
},
|
||||
|
||||
async getSchedule(id: number): Promise<ReadingSchedule> {
|
||||
const { data } = await api.get<ReadingSchedule>(`/books/schedules/${id}/`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generateRecommendations(request: GenerateRecommendationsRequest): Promise<ReadingSchedule> {
|
||||
const { data } = await api.post<ReadingSchedule>("/books/schedules/", {
|
||||
ebook_id: request.ebook_id,
|
||||
meeting_count: request.meeting_count ?? 4,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async regenerateRecommendations(scheduleId: number, meetingCount?: number): Promise<ReadingSchedule> {
|
||||
const { data } = await api.post<ReadingSchedule>(`/books/schedules/${scheduleId}/generate/`, {
|
||||
meeting_count: meetingCount ?? 4,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async confirmSchedule(scheduleId: number): Promise<ReadingSchedule> {
|
||||
const { data } = await api.post<ReadingSchedule>(`/books/schedules/${scheduleId}/confirm/`, {
|
||||
confirm: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateMeeting(
|
||||
scheduleId: number,
|
||||
meetingId: number,
|
||||
update: { chapter_ids?: number[]; title?: string; meeting_index?: number },
|
||||
): Promise<unknown> {
|
||||
const { data } = await api.patch(
|
||||
`/books/schedules/${scheduleId}/meetings/${meetingId}/`,
|
||||
update,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async reorderMeetings(
|
||||
scheduleId: number,
|
||||
meetings: { id: number; meeting_index: number }[],
|
||||
): Promise<ReadingSchedule> {
|
||||
const { data } = await api.patch<ReadingSchedule>(
|
||||
`/books/schedules/${scheduleId}/reorder/`,
|
||||
{ meetings },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteSchedule(id: number): Promise<void> {
|
||||
await api.delete(`/books/schedules/${id}/`);
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18n-lite";
|
||||
import { booksApi } from "../api/books";
|
||||
import { getApiErrorMessage } from "../api/errors";
|
||||
@@ -46,6 +47,7 @@ export function BookContextMenu({
|
||||
const { t, language } = useTranslation();
|
||||
const locale = language as SupportedLanguage;
|
||||
const { showToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ left: x, top: y });
|
||||
const [activeAction, setActiveAction] = useState<ActionKind>(null);
|
||||
@@ -148,6 +150,18 @@ export function BookContextMenu({
|
||||
>
|
||||
{activeAction === "sync" ? t("contextMenu.syncingMetadata") : t("contextMenu.syncMetadata")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.item}
|
||||
role="menuitem"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
navigate(`/books/${book.id}/schedule`);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
📅 Schedule Reading
|
||||
</button>
|
||||
<div className={styles.separator} role="separator" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18n-lite";
|
||||
import { schedulesApi } from "../api/books";
|
||||
import { booksApi } from "../api/books";
|
||||
import type {
|
||||
BookChapter,
|
||||
EBookListItem,
|
||||
MeetingSection,
|
||||
ReadingSchedule,
|
||||
} from "../types/book";
|
||||
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
|
||||
|
||||
/* ---- drag-and-drop types ---- */
|
||||
interface DragData {
|
||||
chapterId: number;
|
||||
sourceMeetingId: number | null; // null = unassigned
|
||||
}
|
||||
|
||||
function formatTime(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
export function RecommendationPage() {
|
||||
const { t } = useTranslation();
|
||||
const { ebookId } = useParams<{ ebookId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMediaQuery(BREAKPOINTS.md);
|
||||
|
||||
const [ebook, setEbook] = useState<EBookListItem | null>(null);
|
||||
const [schedule, setSchedule] = useState<ReadingSchedule | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draggedChapter, setDraggedChapter] = useState<DragData | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<number | null>(null);
|
||||
|
||||
const bookId = Number(ebookId);
|
||||
const validId = !Number.isNaN(bookId);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!validId) {
|
||||
setError("Invalid ebook ID.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [ebookData, schedules] = await Promise.all([
|
||||
booksApi.getEBook(bookId),
|
||||
schedulesApi.getSchedules(),
|
||||
]);
|
||||
setEbook({
|
||||
id: ebookData.id,
|
||||
title: ebookData.title,
|
||||
author: ebookData.author,
|
||||
filename: ebookData.filename,
|
||||
format: ebookData.format,
|
||||
page_count: ebookData.page_count,
|
||||
cover_image:
|
||||
typeof ebookData.cover_image === "string" ? ebookData.cover_image : null,
|
||||
created_at: ebookData.created_at,
|
||||
progress: ebookData.progress?.current_position ?? null,
|
||||
started: ebookData.progress != null,
|
||||
});
|
||||
const existing = schedules.find((s) => s.ebook === bookId);
|
||||
setSchedule(existing ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load data.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [bookId, validId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!validId) return;
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newSchedule = await schedulesApi.generateRecommendations({
|
||||
ebook_id: bookId,
|
||||
meeting_count: 4,
|
||||
});
|
||||
setSchedule(newSchedule);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate recommendations.");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [bookId, validId]);
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
if (!schedule) return;
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await schedulesApi.regenerateRecommendations(schedule.id);
|
||||
setSchedule(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to regenerate recommendations.");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [schedule]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!schedule) return;
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await schedulesApi.confirmSchedule(schedule.id);
|
||||
setSchedule(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to confirm schedule.");
|
||||
}
|
||||
}, [schedule]);
|
||||
|
||||
/* ---- drag-and-drop handlers ---- */
|
||||
const handleDragStart = useCallback(
|
||||
(chapterId: number, sourceMeetingId: number | null) => (e: React.DragEvent) => {
|
||||
setDraggedChapter({ chapterId, sourceMeetingId });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(chapterId));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(targetMeetingId: number) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(targetMeetingId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (targetMeetingId: number) => {
|
||||
setDropTarget(null);
|
||||
if (!draggedChapter || !schedule) return;
|
||||
|
||||
const { chapterId, sourceMeetingId } = draggedChapter;
|
||||
setDraggedChapter(null);
|
||||
|
||||
// Same meeting — no-op
|
||||
if (sourceMeetingId === targetMeetingId) return;
|
||||
|
||||
// Optimistically update UI
|
||||
const updatedMeetings = schedule.meetings.map((m) => ({
|
||||
...m,
|
||||
chapter_ids: [...m.chapter_ids],
|
||||
chapters: [...m.chapters],
|
||||
}));
|
||||
|
||||
// Remove from source
|
||||
if (sourceMeetingId !== null) {
|
||||
const source = updatedMeetings.find((m) => m.id === sourceMeetingId);
|
||||
if (source) {
|
||||
source.chapter_ids = source.chapter_ids.filter((id) => id !== chapterId);
|
||||
source.chapters = source.chapters.filter((ch) => ch.id !== chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to target
|
||||
const target = updatedMeetings.find((m) => m.id === targetMeetingId);
|
||||
if (target && !target.chapter_ids.includes(chapterId)) {
|
||||
target.chapter_ids = [...target.chapter_ids, chapterId];
|
||||
// Find chapter data
|
||||
const allChapters = schedule.meetings.flatMap((m) => m.chapters);
|
||||
const movedChapter = allChapters.find((ch) => ch.id === chapterId);
|
||||
if (movedChapter) {
|
||||
target.chapters = [...target.chapters, movedChapter];
|
||||
}
|
||||
}
|
||||
|
||||
setSchedule({ ...schedule, meetings: updatedMeetings });
|
||||
|
||||
// Persist to backend
|
||||
try {
|
||||
await schedulesApi.updateMeeting(schedule.id, targetMeetingId, {
|
||||
chapter_ids: target?.chapter_ids ?? [],
|
||||
});
|
||||
if (sourceMeetingId !== null) {
|
||||
const source = updatedMeetings.find((m) => m.id === sourceMeetingId);
|
||||
if (source) {
|
||||
await schedulesApi.updateMeeting(schedule.id, sourceMeetingId, {
|
||||
chapter_ids: source.chapter_ids,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to save changes. Please try again.");
|
||||
// Reload to reset
|
||||
void loadData();
|
||||
}
|
||||
},
|
||||
[draggedChapter, schedule, loadData],
|
||||
);
|
||||
|
||||
const handleUnassignDrop = useCallback(
|
||||
async () => {
|
||||
setDropTarget(null);
|
||||
if (!draggedChapter || !schedule) return;
|
||||
const { chapterId, sourceMeetingId } = draggedChapter;
|
||||
setDraggedChapter(null);
|
||||
|
||||
if (sourceMeetingId === null) return;
|
||||
|
||||
// Remove from source
|
||||
const updatedMeetings = schedule.meetings.map((m) => ({
|
||||
...m,
|
||||
chapter_ids: [...m.chapter_ids],
|
||||
chapters: [...m.chapters],
|
||||
}));
|
||||
const source = updatedMeetings.find((m) => m.id === sourceMeetingId);
|
||||
if (source) {
|
||||
source.chapter_ids = source.chapter_ids.filter((id) => id !== chapterId);
|
||||
source.chapters = source.chapters.filter((ch) => ch.id !== chapterId);
|
||||
}
|
||||
|
||||
setSchedule({ ...schedule, meetings: updatedMeetings });
|
||||
|
||||
try {
|
||||
await schedulesApi.updateMeeting(schedule.id, sourceMeetingId, {
|
||||
chapter_ids: source?.chapter_ids ?? [],
|
||||
});
|
||||
} catch {
|
||||
setError("Failed to save changes.");
|
||||
void loadData();
|
||||
}
|
||||
},
|
||||
[draggedChapter, schedule, loadData],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedChapter(null);
|
||||
setDropTarget(null);
|
||||
}, []);
|
||||
|
||||
/* ---- styles ---- */
|
||||
const containerStyle: React.CSSProperties = {
|
||||
maxWidth: 1200,
|
||||
margin: "0 auto",
|
||||
padding: isMobile ? 12 : 24,
|
||||
minHeight: "100vh",
|
||||
background: "#f8f9fa",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const backBtnStyle: React.CSSProperties = {
|
||||
padding: "8px 16px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #e5e7eb",
|
||||
background: "#fff",
|
||||
color: "#374151",
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const btnPrimaryStyle: React.CSSProperties = {
|
||||
padding: "10px 24px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "#4f46e5",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
opacity: generating ? 0.7 : 1,
|
||||
minHeight: 44,
|
||||
};
|
||||
|
||||
const confirmBtnStyle: React.CSSProperties = {
|
||||
...btnPrimaryStyle,
|
||||
background: "#16a34a",
|
||||
};
|
||||
|
||||
const meetingsGridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: isMobile
|
||||
? "1fr"
|
||||
: `repeat(${schedule?.meeting_count ?? 4}, 1fr)`,
|
||||
gap: 16,
|
||||
marginTop: 16,
|
||||
};
|
||||
|
||||
const meetingCardStyle = (isDropTarget: boolean): React.CSSProperties => ({
|
||||
background: isDropTarget ? "#eef2ff" : "#fff",
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${isDropTarget ? "#4f46e5" : "#e5e7eb"}`,
|
||||
padding: 16,
|
||||
minHeight: 200,
|
||||
transition: "border-color 0.2s, background 0.2s",
|
||||
});
|
||||
|
||||
const chapterChipStyle: React.CSSProperties = {
|
||||
background: "#f3f4f6",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
marginBottom: 8,
|
||||
cursor: "grab",
|
||||
fontSize: 13,
|
||||
color: "#374151",
|
||||
border: "1px solid #e5e7eb",
|
||||
userSelect: "none" as const,
|
||||
};
|
||||
|
||||
/* ---- render ---- */
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={{ color: "#ef4444" }}>Invalid ebook ID.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: 400 }}>
|
||||
<p style={{ color: "#888" }}>{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<button onClick={() => navigate(-1)} style={backBtnStyle}>← Back</button>
|
||||
<div style={{ textAlign: "center", padding: 60 }}>
|
||||
<p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>
|
||||
<button onClick={() => void loadData()} style={btnPrimaryStyle}>
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<button onClick={() => navigate("/")} style={backBtnStyle}>
|
||||
← {isMobile ? "Library" : "Back to Library"}
|
||||
</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ fontSize: isMobile ? 18 : 24, margin: 0, color: "#1f2937" }}>
|
||||
Reading Schedule
|
||||
</h1>
|
||||
{ebook && (
|
||||
<p style={{ color: "#6b7280", fontSize: 14, margin: "4px 0 0" }}>
|
||||
{ebook.title}{ebook.author ? ` — ${ebook.author}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!schedule && (
|
||||
<button onClick={handleGenerate} style={btnPrimaryStyle} disabled={generating}>
|
||||
{generating ? "Generating..." : "Generate Recommendations"}
|
||||
</button>
|
||||
)}
|
||||
{schedule && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button onClick={handleRegenerate} style={btnPrimaryStyle} disabled={generating}>
|
||||
{generating ? "Regenerating..." : "Regenerate"}
|
||||
</button>
|
||||
{!schedule.confirmed && (
|
||||
<button onClick={handleConfirm} style={confirmBtnStyle}>
|
||||
Confirm Schedule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
{schedule && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 999,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: schedule.confirmed ? "#dcfce7" : "#fef3c7",
|
||||
color: schedule.confirmed ? "#16a34a" : "#b45309",
|
||||
}}>
|
||||
{schedule.confirmed ? "✓ Confirmed" : "Draft — drag chapters to adjust"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No schedule yet */}
|
||||
{!schedule && (
|
||||
<div style={{ textAlign: "center", padding: "60px 16px" }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>📅</div>
|
||||
<h2 style={{ fontSize: 20, color: "#1f2937", marginBottom: 8 }}>
|
||||
No Schedule Yet
|
||||
</h2>
|
||||
<p style={{ color: "#6b7280", marginBottom: 24, maxWidth: 400, margin: "0 auto 24px" }}>
|
||||
Generate a reading schedule to split this book into balanced weekly assignments for your group.
|
||||
</p>
|
||||
<button onClick={handleGenerate} style={btnPrimaryStyle} disabled={generating}>
|
||||
{generating ? "Generating..." : "Generate Recommendations"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule meetings */}
|
||||
{schedule && (
|
||||
<>
|
||||
{/* Unassigned drop zone */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: dropTarget === null && draggedChapter ? "#fef2f2" : "#f9fafb",
|
||||
borderRadius: 8,
|
||||
border: `2px dashed ${dropTarget === null && draggedChapter ? "#ef4444" : "#d1d5db"}`,
|
||||
marginBottom: 16,
|
||||
textAlign: "center",
|
||||
color: "#6b7280",
|
||||
fontSize: 13,
|
||||
minHeight: 44,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDropTarget(null); }}
|
||||
onDrop={handleUnassignDrop}
|
||||
onDragLeave={() => setDropTarget(null)}
|
||||
>
|
||||
{dropTarget === null && draggedChapter
|
||||
? "Drop here to unassign"
|
||||
: "Drag chapters here to unassign from meetings"}
|
||||
</div>
|
||||
|
||||
{/* Meetings grid */}
|
||||
<div style={meetingsGridStyle}>
|
||||
{schedule.meetings.map((meeting, idx) => (
|
||||
<MeetingColumn
|
||||
key={meeting.id}
|
||||
meeting={meeting}
|
||||
index={idx}
|
||||
isDropTarget={dropTarget === meeting.id}
|
||||
isConfirmed={schedule.confirmed}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver(meeting.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={() => void handleDrop(meeting.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drag overlay instruction */}
|
||||
{draggedChapter && (
|
||||
<div style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "#1f2937",
|
||||
color: "#fff",
|
||||
padding: "12px 24px",
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
|
||||
zIndex: 999,
|
||||
}}>
|
||||
Drop the chapter into a meeting column
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Meeting Column Sub-component ---- */
|
||||
|
||||
interface MeetingColumnProps {
|
||||
meeting: MeetingSection;
|
||||
index: number;
|
||||
isDropTarget: boolean;
|
||||
isConfirmed: boolean;
|
||||
onDragStart: (chapterId: number, sourceMeetingId: number | null) => (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: () => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
function MeetingColumn({
|
||||
meeting,
|
||||
index,
|
||||
isDropTarget,
|
||||
isConfirmed,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: MeetingColumnProps) {
|
||||
const chapterCount = meeting.chapter_ids.length;
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: isDropTarget ? "#eef2ff" : "#fff",
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${isDropTarget ? "#4f46e5" : "#e5e7eb"}`,
|
||||
overflow: "hidden",
|
||||
transition: "border-color 0.2s, background 0.2s",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: "12px 16px",
|
||||
background: "#f9fafb",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
};
|
||||
|
||||
const bodyStyle: React.CSSProperties = {
|
||||
padding: "12px 16px",
|
||||
minHeight: 120,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div style={{ fontWeight: 700, color: "#1f2937", fontSize: 15, marginBottom: 4 }}>
|
||||
Week {index + 1}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, fontSize: 12, color: "#6b7280" }}>
|
||||
<span>{chapterCount} {chapterCount === 1 ? "chapter" : "chapters"}</span>
|
||||
<span>{formatTime(meeting.estimated_time_minutes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={bodyStyle}
|
||||
onDragOver={isConfirmed ? undefined : onDragOver}
|
||||
onDragLeave={isConfirmed ? undefined : onDragLeave}
|
||||
onDrop={isConfirmed ? undefined : onDrop}
|
||||
>
|
||||
{meeting.chapters.length === 0 && (
|
||||
<div style={{
|
||||
color: "#9ca3af",
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
padding: "20px 0",
|
||||
fontStyle: "italic",
|
||||
}}>
|
||||
{isDropTarget ? "Drop here" : "No chapters assigned"}
|
||||
</div>
|
||||
)}
|
||||
{meeting.chapters.map((chapter) => (
|
||||
<div
|
||||
key={`${meeting.id}-${chapter.id}`}
|
||||
draggable={!isConfirmed}
|
||||
onDragStart={isConfirmed ? undefined : onDragStart(chapter.id, meeting.id)}
|
||||
onDragEnd={isConfirmed ? undefined : onDragEnd}
|
||||
style={{
|
||||
background: "#f3f4f6",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
marginBottom: 8,
|
||||
cursor: isConfirmed ? "default" : "grab",
|
||||
fontSize: 13,
|
||||
color: "#374151",
|
||||
border: "1px solid #e5e7eb",
|
||||
userSelect: "none" as const,
|
||||
opacity: isConfirmed ? 0.8 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>{chapter.title}</div>
|
||||
{chapter.href && (
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>{chapter.href}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,4 +101,28 @@ export interface BookSearchParams {
|
||||
ordering?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface MeetingSection {
|
||||
id: number;
|
||||
meeting_index: number;
|
||||
title: string;
|
||||
estimated_time_minutes: number;
|
||||
chapter_ids: number[];
|
||||
chapters: BookChapter[];
|
||||
}
|
||||
|
||||
export interface ReadingSchedule {
|
||||
id: number;
|
||||
ebook: number;
|
||||
meeting_count: number;
|
||||
confirmed: boolean;
|
||||
meetings: MeetingSection[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GenerateRecommendationsRequest {
|
||||
ebook_id: number;
|
||||
meeting_count?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user