Stage 2: student lesson viewer, Kinescope player, PDF files, prev/next nav, My Courses dashboard
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface LessonFile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: string; initialFiles: LessonFile[] }) {
|
||||
const [files, setFiles] = useState(initialFiles);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("lessonId", lessonId);
|
||||
const res = await fetch("/api/admin/lesson-files", { method: "POST", body: fd });
|
||||
const created = await res.json();
|
||||
if (created.id) setFiles((prev) => [...prev, created]);
|
||||
setUploading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
async function handleDelete(fileId: string) {
|
||||
if (!confirm("Удалить файл?")) return;
|
||||
await fetch("/api/admin/lesson-files", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId }),
|
||||
});
|
||||
setFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((f) => (
|
||||
<div key={f.id} className="flex items-center gap-3 px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||
<span className="text-base">📎</span>
|
||||
<a href={f.url} target="_blank" rel="noopener noreferrer" className="flex-1 underline font-medium">{f.name}</a>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
||||
<button onClick={() => handleDelete(f.id)} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="btn-aubade text-xs cursor-pointer">
|
||||
{uploading ? "Загрузка..." : "+ Добавить файл"}
|
||||
<input type="file" className="sr-only" onChange={handleUpload} disabled={uploading} />
|
||||
</label>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX — до 100 МБ</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import KinescopeReactPlayer from "@kinescope/react-kinescope-player";
|
||||
|
||||
interface Props {
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
export function KinescopePlayer({ videoId }: Props) {
|
||||
return (
|
||||
<div className="w-full aspect-video" style={{ border: "2px solid var(--border)" }}>
|
||||
<KinescopeReactPlayer
|
||||
videoId={videoId}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
title: string;
|
||||
lessons: Lesson[];
|
||||
}
|
||||
|
||||
interface Course {
|
||||
slug: string;
|
||||
title: string;
|
||||
modules: Module[];
|
||||
}
|
||||
|
||||
export function CourseSidebar({ course }: { course: Course }) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
className="md:hidden fixed bottom-4 right-4 z-20 btn-aubade px-3 py-2 text-sm"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? "✕" : "☰ Уроки"}
|
||||
</button>
|
||||
|
||||
<aside
|
||||
className={`w-64 shrink-0 flex flex-col overflow-y-auto ${open ? "flex" : "hidden md:flex"}`}
|
||||
style={{
|
||||
borderRight: "2px solid var(--border)",
|
||||
backgroundColor: "var(--background)",
|
||||
maxHeight: "calc(100vh - 53px)",
|
||||
position: "sticky",
|
||||
top: "53px",
|
||||
}}
|
||||
>
|
||||
{/* Course title */}
|
||||
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
|
||||
<Link
|
||||
href={`/courses/${course.slug}`}
|
||||
className="font-bold text-sm leading-snug block"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-xs mt-1 block underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
← Все курсы
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Modules and lessons */}
|
||||
<nav className="flex-1 py-2">
|
||||
{course.modules.map((mod) => (
|
||||
<div key={mod.id}>
|
||||
<p
|
||||
className="px-4 py-2 text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{mod.title}
|
||||
</p>
|
||||
{mod.lessons.map((lesson) => {
|
||||
const active = pathname.includes(lesson.id);
|
||||
return (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/courses/${course.slug}/lessons/${lesson.id}`}
|
||||
className="block px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
|
||||
style={{
|
||||
borderLeftColor: active ? "var(--foreground)" : "transparent",
|
||||
backgroundColor: active ? "var(--color-highlight)" : "transparent",
|
||||
color: active ? "var(--foreground)" : "var(--foreground)",
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{lesson.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
|
||||
export function LessonContent({ content }: { content: object }) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({ inline: false }),
|
||||
Link.configure({ openOnClick: true }),
|
||||
],
|
||||
content,
|
||||
editable: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-slate max-w-none focus:outline-none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
}
|
||||
Reference in New Issue
Block a user