Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ba67ddd
feat: drafted `CourseContentOverview` component
Marty-Byrde Mar 28, 2026
5c22859
ref: added course contents to `OverviewSection`
Marty-Byrde Mar 28, 2026
97a6bfc
feat: drafted `/courses/contents/[courseId]` page
Marty-Byrde Mar 28, 2026
57740ba
feat: added "show contents" item to `CourseActionMenu`
Marty-Byrde Mar 28, 2026
f5a34b4
ref: modularized `ContentSection` into subcomponents
Marty-Byrde Mar 28, 2026
510a43f
feat: redesigned `ContentSection` layout
Marty-Byrde Mar 28, 2026
26bc7a1
ref: re-used `ContentSection` in `OverviewSection`
Marty-Byrde Mar 28, 2026
d5ff949
ref: added course content count to `CourseCard`
Marty-Byrde Mar 28, 2026
5248f24
feat: drafted course contents navigation panel
Marty-Byrde Apr 1, 2026
5d0e918
feat: created `ContentProvider` component
Marty-Byrde Apr 1, 2026
7028762
feat: introduced `ContentSwitchButton` component
Marty-Byrde Apr 1, 2026
9b53b64
feat: added `ContentRenderer` component
Marty-Byrde Apr 1, 2026
3ca2726
feat: created `ContentsPageBreadcrumbs` component
Marty-Byrde Apr 1, 2026
a7f3978
feat: added `ContentNavigationButtons` components
Marty-Byrde Apr 1, 2026
45fe45c
ref: updated course contents page layout
Marty-Byrde Apr 1, 2026
f6dfcf7
feat: redesigned course content navigation btn appearances
Marty-Byrde Apr 1, 2026
da8c70a
ref: added `editorContainerClassname` arg to `RichTextEditor`
Marty-Byrde Apr 1, 2026
470cb20
ref: adjusted `RichTextEditor` height in course content page
Marty-Byrde Apr 1, 2026
d698946
ref: localized course contents page and components
Marty-Byrde Apr 1, 2026
62daab8
ref: removed contents navigation on small screens
Marty-Byrde Apr 1, 2026
5f628ba
Revert "ref: adjusted `RichTextEditor` height in course content page"
Marty-Byrde Apr 1, 2026
2ca081b
ref: added empty course page safeguard redirect
Marty-Byrde Apr 1, 2026
b8815d0
ref: added safeguard against invalid index in `ContentSBtn`
Marty-Byrde Apr 1, 2026
1156afa
ref: added missing translation in `ContentSection`
Marty-Byrde Apr 1, 2026
7005501
ref: restructured internal `CreateContentButton` component
Marty-Byrde Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/app/[locale]/courses/contents/[courseId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { notFound, redirect } from 'next/navigation'
import { getCourseById } from '@/database/course/select'
import { NextContentButton, PreviousContentButton } from '@/src/components/courses/contents/[courseId]/ContentNavigationButtons'
import { ContentProvider } from '@/src/components/courses/contents/[courseId]/ContentProvider'
import { ContentRenderer } from '@/src/components/courses/contents/[courseId]/ContentRenderer'
import { ContentPageBreadcrumbs } from '@/src/components/courses/contents/[courseId]/ContentsPageBreadcrumbs'
import { ContentSwitchButton } from '@/src/components/courses/contents/[courseId]/ContentSwitchButton'
import PageHeading from '@/src/components/Shared/PageHeading'
import { getScopedI18n } from '@/src/i18n/server-localization'
import _logger from '@/src/lib/log/Logger'
import getReferer from '@/src/lib/Shared/getReferer'

const logger = _logger.createModuleLogger('/' + import.meta.url.split('/').reverse().slice(0, 2).reverse().join('/')!)

export default async function CourseContentsPage({ params }: { params: Promise<{ courseId: string }> }) {
const t = await getScopedI18n('Courses.Contents')
const { courseId } = await params
const course = await getCourseById(courseId)
const referer = await getReferer()

if (!course) notFound()
if (course.contents.length === 0) {
logger.info(`Course (id: ${course.id}) has no contents, redirecting user back to ${referer ?? '/courses'} page.`)

// redirects users to where they came from when redirected by the app, or to the /courses page if user navigated manually.
redirect(referer ?? '/courses')
}

return (
<>
<ContentPageBreadcrumbs course={course} />
<PageHeading title={t('title')} description={t('description')} />

<ContentProvider initialProps={{ contents: course.contents }}>
Comment thread
Marty-Byrde marked this conversation as resolved.
<div className='flex flex-1 gap-8'>
<div className='flex flex-1 flex-col gap-6'>
<ContentRenderer />
<div className='flex justify-between'>
<PreviousContentButton />
<NextContentButton />
</div>
</div>

<div className='hidden flex-col gap-2 @4xl:flex'>
<h2 className='font-semibold'>{t('Navigation.title')}</h2>
{course.contents.map((c) => (
<ContentSwitchButton content={c} key={c.categoryId} />
))}
</div>
</div>
</ContentProvider>
</>
)
}
14 changes: 13 additions & 1 deletion src/components/courses/(hamburger-menu)/CourseActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { useSession } from '@/src/lib/auth/client'
import { generateToken } from '@/src/lib/Shared/generateToken'
import { Course } from '@/src/schemas/CourseSchema'

export default function CourseActionMenu({ id, questions, share_key, owner_id, collaborators }: {} & Course) {
export default function CourseActionMenu({ id, questions, share_key, owner_id, collaborators, contents }: {} & Course) {
const t = useScopedI18n('Components.CourseActionMenu')
const [menuOpen, setMenuOpen] = useState(false)

Expand Down Expand Up @@ -106,6 +106,18 @@ export default function CourseActionMenu({ id, questions, share_key, owner_id, c
</DropdownMenuItem>
</DropdownMenuGroup>

<DropdownMenuItem
className='group justify-between'
enableTooltip={contents.length === 0}
tooltipOptions={{ ...baseTooltipOptions, content: t('show_course_contents.tooltip') }}
onClick={() => {
router.push(`${window.location.origin}/courses/contents/${id}`)
}}
disabled={contents.length === 0}>
{t('show_course_contents.label')}
<ArrowUpRightIcon className='text-neutral-600 group-data-disabled:text-inherit dark:text-neutral-400 dark:group-data-disabled:text-inherit' />
</DropdownMenuItem>

<DropdownMenuSub>
<DropdownMenuSubTrigger enableTooltip={!hasQuestions} tooltipOptions={{ ...baseTooltipOptions, content: 'This course has no questions, sharing disabled.' }} disabled={!hasQuestions}>
{t('invite_to_submenu_label')}
Expand Down
4 changes: 2 additions & 2 deletions src/components/courses/CourseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function CourseCard(course: Course) {
<span className='line-clamp-2 text-center text-sm text-balance text-neutral-500 dark:text-neutral-400'>{course.description}</span>
</div>
<div className='flex flex-wrap justify-evenly gap-8 px-6'>
<StatisticElement label={t('Statistics.questions_label')} value={course.questions.length} />
<StatisticElement label={t('Statistics.contents_label')} value={course.contents.length} />
<StatisticElement
label={t('Statistics.estimatedTime_label')}
value={
Expand All @@ -53,7 +53,7 @@ export function CourseCard(course: Course) {
</>
}
/>
<StatisticElement label={t('Statistics.points_label')} value={course.questions.map((q) => q.points).reduce((prev, current) => (prev += current), 0)} />
<StatisticElement label={t('Statistics.questions_label')} value={course.questions.length} />
</div>
<Footer updatedAt={course.updatedAt} />
</Card>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client'

import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { useContentContext } from '@/src/components/courses/contents/[courseId]/ContentProvider'
import { Button } from '@/src/components/shadcn/button'
import { useScopedI18n } from '@/src/i18n/client-localization'

export function NextContentButton() {
const t = useScopedI18n('Courses.Contents.Navigation')
const { contents, currentContentIndex, setCurrentIndex } = useContentContext()

if (contents.length <= currentContentIndex + 1) return <div />
return (
<Button onClick={() => setCurrentIndex((prev) => prev + 1)} variant='ghost' className='group flex h-fit max-w-56 flex-col gap-1'>
<span className='self-start text-sm text-muted-foreground'>{t('next_btn_label')}</span>
<div className='flex items-center gap-1 group-hover:text-primary group-hover:underline'>
{contents.at(currentContentIndex + 1)?.title}
<ChevronRightIcon className='transition-transform group-hover:scale-125' />
</div>
</Button>
)
}

export function PreviousContentButton() {
const t = useScopedI18n('Courses.Contents.Navigation')
const { contents, currentContentIndex, setCurrentIndex } = useContentContext()

if (currentContentIndex - 1 < 0) return <div />

return (
<Button onClick={() => setCurrentIndex((prev) => prev - 1)} variant='ghost' className='group flex h-fit max-w-56 flex-col gap-1'>
<span className='self-end text-right text-sm text-muted-foreground'>{t('previous_btn_label')}</span>
<div className='flex items-center gap-1 group-hover:text-primary group-hover:underline'>
<ChevronLeftIcon className='transition-transform group-hover:scale-125' />
{contents.at(currentContentIndex - 1)?.title}
</div>
</Button>
)
}
26 changes: 26 additions & 0 deletions src/components/courses/contents/[courseId]/ContentProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'

import { createContext, SetStateAction, useContext, useState } from 'react'
import { Course } from '@/src/schemas/CourseSchema'

type ContextProps = {
currentContentIndex: number
setCurrentIndex: React.Dispatch<SetStateAction<number>>
contents: Course['contents']
}

const Context = createContext<ContextProps | null>(null)

export function ContentProvider({ children, initialProps }: { children: React.ReactNode; initialProps?: Partial<ContextProps> }) {
const [currentIndex, setCurrentIndex] = useState(initialProps?.currentContentIndex ?? 0)

return <Context.Provider value={{ currentContentIndex: currentIndex, setCurrentIndex, contents: [], ...initialProps }}>{children}</Context.Provider>
}

export function useContentContext() {
const ctx = useContext(Context)

if (!ctx) throw new Error('useContentContext may only be used within a <ContentProvider />')

return ctx
}
11 changes: 11 additions & 0 deletions src/components/courses/contents/[courseId]/ContentRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client'

import { useContentContext } from '@/src/components/courses/contents/[courseId]/ContentProvider'
import { RichTextEditor } from '@/src/components/tiptap-examples/RichTextEditor'
import { cn } from '@/src/lib/Shared/utils'

export function ContentRenderer() {
const { contents, currentContentIndex } = useContentContext()

return <RichTextEditor readOnly defaultContent={contents.at(currentContentIndex)?.content} editorPaneClassname={cn('border-dashed p-4')} />
}
26 changes: 26 additions & 0 deletions src/components/courses/contents/[courseId]/ContentSwitchButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'
import { useMemo } from 'react'
import { BookTextIcon } from 'lucide-react'
import { useContentContext } from '@/src/components/courses/contents/[courseId]/ContentProvider'
import { Button } from '@/src/components/shadcn/button'
import { cn } from '@/src/lib/Shared/utils'
import { Course } from '@/src/schemas/CourseSchema'

/**
* Used to switch between available contents. Shows the title of a given content as the button-label in combination with an icon.
*/
export function ContentSwitchButton({ content }: { content: Course['contents'][number] }) {
const { contents, currentContentIndex, setCurrentIndex } = useContentContext()
const index = useMemo(() => contents.findIndex((c) => c.categoryId === content.categoryId), [content, contents])

return (
<Button
disabled={index === -1} // index retrieval was unsuccesful -> switching not possible
variant='link'
className={cn('w-fit text-ellipsis', index === currentContentIndex ? 'text-primary underline' : 'text-muted-foreground')}
onClick={() => setCurrentIndex(index)}>
<BookTextIcon />
{content.title}
</Button>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Link from 'next/link'
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/src/components/shadcn/breadcrumb'
import { Course } from '@/src/schemas/CourseSchema'

export function ContentPageBreadcrumbs({ course }: { course: Course }) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/`}>Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/courses`}>Courses</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/courses/edit/${course.id}`}>{course.name}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />

<BreadcrumbPage>Contents</BreadcrumbPage>
</BreadcrumbList>
</Breadcrumb>
)
}
138 changes: 88 additions & 50 deletions src/components/courses/create/(sections)/ContentSection.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,106 @@
'use client'

import { generateHTML } from '@tiptap/core'
import { PenIcon, PlusCircleIcon, TrashIcon } from 'lucide-react'
import { Folder, Info, Pen, Plus, Trash2 } from 'lucide-react'
import CourseContentDialog from '@/src/components/courses/create/(sections)/CourseContentDialog'
import { useCourseStore } from '@/src/components/courses/create/CreateCourseProvider'
import { Button } from '@/src/components/shadcn/button'
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/src/components/shadcn/card'
import GenericCard from '@/src/components/Shared/Card'
import { CardStageJumpButton } from '@/src/components/Shared/CardStageJumpButton'
import ConfirmationDialog from '@/src/components/Shared/ConfirmationDialog/ConfirmationDialog'
import { RichTextEditor, RichTextEditorExtensions } from '@/src/components/tiptap-examples/RichTextEditor'
import { useScopedI18n } from '@/src/i18n/client-localization'
import { cn } from '@/src/lib/Shared/utils'

export default function ContentSection() {
const { contents, removeCourseContent } = useCourseStore((store) => store)
export default function ContentSection({ jumpBackButton, disabled }: { jumpBackButton?: boolean; disabled?: boolean }) {
const { contents } = useCourseStore((store) => store)
const t = useScopedI18n('Courses.Create.ContentSection')

return (
<div className='flex flex-1 flex-col gap-10'>
<div className='flex flex-col gap-1'>
<h2 className='h-fit text-xl font-semibold'>{t('title')}</h2>
<span className='text-muted-foreground'>{t('description')}</span>
<GenericCard disableInteractions className='relative flex break-inside-avoid flex-col p-3'>
{jumpBackButton && <CardStageJumpButton targetStage={2} />}
<div className='-mx-3 -mt-3 flex flex-col rounded-t-md border-b border-neutral-400 bg-neutral-300 p-2 px-3 text-neutral-600 dark:border-neutral-500 dark:bg-neutral-700/60 dark:text-neutral-300'>
<div className='flex flex-col gap-0'>
<h2 className=''>{t('title')}</h2>
</div>
</div>

<div className='grid grid-cols-[repeat(auto-fill,minmax(380px,1fr))] gap-12'>
<CourseContentDialog mode='create'>
<Card className='flex h-full items-center justify-center'>
<CardContent className='flex gap-4 text-primary'>
<PlusCircleIcon /> {t('Actions.create_new_button_label')}
</CardContent>
</Card>
</CourseContentDialog>
{contents.map((content) => {
const htmlContent = content.content ? generateHTML(content.content, RichTextEditorExtensions) : ''
{contents.length > 0 ? <CourseContentRenderer disabled={disabled} /> : <EmptyCourseContentBody />}

return (
<Card key={content.categoryId} className=''>
<CardHeader>
<CardTitle>{content.title}</CardTitle>
<CardDescription>{content.description}</CardDescription>
<CardAction>
<CourseContentDialog mode='edit' courseContent={content}>
<Button variant='link' asChild aria-label={t('Actions.edit_content_button_aria_label')} className='enabled:text-orange-400 dark:enabled:text-orange-300/80'>
<PenIcon />
{t('Actions.edit_content_button_label')}
</Button>
</CourseContentDialog>
<CreateContentButton disabled={disabled} />
</GenericCard>
)
}

<ConfirmationDialog
confirmAction={() => removeCourseContent(content.categoryId)}
confirmLabel={t('Actions.delete_content_confirm_label')}
body={t('Actions.delete_content_dialog_body')}>
<Button variant='link' asChild aria-label={t('Actions.delete_content_button_aria_label')} className='enabled:text-destructive/80'>
<TrashIcon />
{t('Actions.delete_content_button_label')}
</Button>
</ConfirmationDialog>
</CardAction>
</CardHeader>
<CardContent className='flex h-full px-4.5 **:[div]:[[role=presentation]]:max-h-42 **:[div]:[[role=presentation]]:min-h-auto **:[div]:[[role=presentation]]:cursor-default **:[div]:[[role=presentation]]:border-ring-subtle **:[div]:[[role=presentation]]:p-2.5'>
<RichTextEditor key={htmlContent} defaultContent={content.content} readOnly size='sm' />
</CardContent>
</Card>
)
})}
</div>
function CreateContentButton({ disabled }: { disabled?: boolean }) {
const t = useScopedI18n('Courses.Create.ContentSection')

return (
<div className='flex justify-center gap-8'>
<CourseContentDialog mode='create'>
<Button variant='outline' size='lg' disabled={disabled}>
<Plus className='size-5' />
{t('Actions.create_new_button_label')}
</Button>
Comment thread
Marty-Byrde marked this conversation as resolved.
</CourseContentDialog>
</div>
)
}

function CourseContentRenderer({ disabled }: { disabled?: boolean }) {
const t = useScopedI18n('Courses.Create.ContentSection')
const { contents, removeCourseContent, questionCategories } = useCourseStore((store) => store)

return (
<div className={cn('my-4 grid flex-1 grid-cols-1 gap-6')}>
{contents.map((content, i) => (
<GenericCard disableInteractions key={i + content.categoryId} className={cn('relative flex h-fit gap-3 p-2 first:mt-3 hover:bg-none')}>
Comment thread
Marty-Byrde marked this conversation as resolved.
<div className='flex flex-1 flex-col p-1'>
<div className='flex items-center justify-between'>
<h2 className='text-neutral-700 dark:text-neutral-300'>{content.title}</h2>
<span className='text-neutral-700 dark:text-neutral-300'>{}</span>
</div>
Comment thread
Marty-Byrde marked this conversation as resolved.
<div className='flex justify-between text-xs'>
<span className='text-neutral-500 lowercase dark:text-neutral-400'>{content.description}</span>
<div className='flex items-center gap-1 text-neutral-500 dark:text-neutral-400'>
<Folder className='size-3' />
<span className='lowercase'>{questionCategories.find((c) => c.id === content.categoryId)?.name}</span>
</div>
</div>
</div>
<CourseContentDialog mode='edit' courseContent={content}>
<Button
aria-label={t('Actions.edit_content_button_aria_label')}
size='icon'
variant='base'
type='button'
disabled={disabled}
className='group my-auto flex size-7.5 items-center gap-4 rounded-lg bg-neutral-300/50 p-1.5 ring-1 ring-neutral-400 hover:cursor-pointer hover:ring-[1.5px] hover:ring-ring-hover dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:ring-ring-hover'>
<Pen className='size-4 text-orange-600/70 group-hover:stroke-3 dark:text-orange-400/70' />
</Button>
</CourseContentDialog>

<ConfirmationDialog confirmAction={() => removeCourseContent(content.categoryId)} confirmLabel={t('Actions.delete_content_confirm_label')} body={t('Actions.delete_content_dialog_body')}>
<Button
size='icon'
variant='base'
aria-label={t('Actions.delete_content_button_aria_label')}
type='button'
disabled={disabled}
className='group my-auto flex size-7.5 items-center gap-4 rounded-lg bg-neutral-300/50 ring-1 ring-neutral-400 hover:cursor-pointer hover:ring-[1.5px] hover:ring-ring-hover dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:ring-ring-hover'>
<Trash2 className='size-4 text-red-600/70 group-hover:stroke-[2.5] dark:text-red-400/70' />
</Button>
</ConfirmationDialog>
</GenericCard>
))}
</div>
)
}

function EmptyCourseContentBody() {
const t = useScopedI18n('Courses.Create.ContentSection')
return (
<div className={cn('flex min-h-60 flex-1 flex-col items-center justify-center gap-6')}>
<Info className='size-16 text-neutral-400 dark:text-neutral-500' />
<span className='text-center tracking-wide text-balance text-neutral-500 dark:text-neutral-400'>{t('empty_content_text')}</span>
</div>
Comment thread
Marty-Byrde marked this conversation as resolved.
)
}
Loading
Loading