Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
BIN
View File
Binary file not shown.
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
interface ExtendedNodeProps extends NodeProps {
layoutDirection: 'TB' | 'LR';
}
const DefaultNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => {
const isHorizontal = layoutDirection === 'LR';
return (
<div style={{ padding: 10, border: '1px solid #ddd', borderRadius: 5, backgroundColor: '#f0f0f0', minWidth: 150 }}>
<div style={{ fontWeight: 'bold', marginBottom: 5 }}>{data.label as string}</div>
{data && Object.entries(data).map(([key, value]) => (
key !== 'label' && <div key={key}><strong>{key}:</strong> {value as string}</div>
))}
<Handle type="source" position={isHorizontal ? Position.Right : Position.Bottom} style={{ background: '#555' }} />
<Handle type="target" position={isHorizontal ? Position.Left : Position.Top} style={{ background: '#555' }} />
</div>
);
};
const UserNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.user_name as string,
'User ID': data.user_id as string,
'User Email': data.user_email as string,
'User Type': data.user_type as string,
'User DB ID': data.worker_db_name as string,
}}
id={data.user_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
const TeacherNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.teacher_name_formal,
'Teacher ID': data.teacher_code,
'Teacher Email': data.teacher_email,
}}
id={data.unique_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
const TeacherTimetableNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.label,
}}
id={data.unique_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
const SubjectClassNode: React.FC<ExtendedNodeProps> = ({ data, layoutDirection }) => (
<DefaultNode
data={{
label: data.subject_class_code,
'Year Group': data.year_group,
'Subject': data.subject,
'Subject Code': data.subject_code,
}}
id={data.unique_id as string}
type='userNode'
dragging={false}
zIndex={1}
layoutDirection={layoutDirection}
isConnectable={false}
positionAbsoluteX={0}
positionAbsoluteY={0}
draggable={true}
selected={false}
selectable={true}
deletable={true}
/>
);
export const nodeTypes = {
default: DefaultNode,
userNode: UserNode,
teacherNode: TeacherNode,
teacherTimetableNode: TeacherTimetableNode,
subjectClassNode: SubjectClassNode,
};
+126
View File
@@ -0,0 +1,126 @@
import { TLUiAssetUrlOverrides } from '@tldraw/tldraw';
// Custom asset URLs
export const customAssets: TLUiAssetUrlOverrides = {
icons: {
'sticker-icon': '/icons/sticker-tool.svg'
}
};
// Blank canvas snapshot template
export const blankCanvasSnapshot = {
store: {
"document:document": {
gridSize: 10,
name: "",
meta: {},
id: "document:document",
typeName: "document"
},
"page:page": {
meta: {},
id: "page:page",
name: "Page 1",
index: "a1",
typeName: "page"
}
},
schema: {
schemaVersion: 2 as const,
storeVersion: 4,
sequences: {
"com.tldraw.store":4,
"com.tldraw.asset":1,
"com.tldraw.camera":1,
"com.tldraw.document":2,
"com.tldraw.instance":25,
"com.tldraw.instance_page_state":5,
"com.tldraw.page":1,
"com.tldraw.instance_presence":5,
"com.tldraw.pointer":1,
"com.tldraw.shape":4,
"com.tldraw.asset.bookmark":2,
"com.tldraw.asset.image":5,
"com.tldraw.asset.video":5,
"com.tldraw.shape.arrow":5,
"com.tldraw.shape.bookmark":2,
"com.tldraw.shape.draw":2,
"com.tldraw.shape.embed":4,
"com.tldraw.shape.frame":0,
"com.tldraw.shape.geo":9,
"com.tldraw.shape.group":0,
"com.tldraw.shape.highlight":1,
"com.tldraw.shape.image":4,
"com.tldraw.shape.line":5,
"com.tldraw.shape.note":8,
"com.tldraw.shape.text":2,
"com.tldraw.shape.video":2,
"com.tldraw.shape.youtube-embed":0,
"com.tldraw.shape.calendar":0,
"com.tldraw.shape.microphone":1,
"com.tldraw.shape.transcriptionText":0,
"com.tldraw.shape.slide":0,"com.tldraw.shape.slideshow":0,
"com.tldraw.shape.user_node":1,
"com.tldraw.shape.developer_node":1,
"com.tldraw.shape.student_node":1,
"com.tldraw.shape.teacher_node":1,
"com.tldraw.shape.calendar_node":1,
"com.tldraw.shape.calendar_year_node":1,
"com.tldraw.shape.calendar_month_node":1,
"com.tldraw.shape.calendar_week_node":1,
"com.tldraw.shape.calendar_day_node":1,
"com.tldraw.shape.calendar_time_chunk_node":1,
"com.tldraw.shape.teacher_timetable_node":1,
"com.tldraw.shape.timetable_lesson_node":1,
"com.tldraw.shape.planned_lesson_node":1,
"com.tldraw.shape.pastoral_structure_node":1,
"com.tldraw.shape.year_group_node":1,
"com.tldraw.shape.curriculum_structure_node":1,
"com.tldraw.shape.key_stage_node":1,
"com.tldraw.shape.key_stage_syllabus_node":1,
"com.tldraw.shape.year_group_syllabus_node":1,
"com.tldraw.shape.subject_node":1,
"com.tldraw.shape.topic_node":1,
"com.tldraw.shape.topic_lesson_node":1,
"com.tldraw.shape.learning_statement_node":1,
"com.tldraw.shape.science_lab_node":1,
"com.tldraw.shape.school_timetable_node":1,
"com.tldraw.shape.academic_year_node":1,
"com.tldraw.shape.academic_term_node":1,
"com.tldraw.shape.academic_week_node":1,
"com.tldraw.shape.academic_day_node":1,
"com.tldraw.shape.academic_period_node":1,
"com.tldraw.shape.registration_period_node":1,
"com.tldraw.shape.school_node":1,
"com.tldraw.shape.department_node":1,
"com.tldraw.shape.room_node":1,
"com.tldraw.shape.subject_class_node":1,
"com.tldraw.shape.general_relationship":1,
"com.tldraw.binding.arrow":0,
"com.tldraw.binding.slide-layout":0
},
recordVersions: {
asset: { version: 1, subTypeKey: "type", subTypeVersions: {} },
camera: { version: 1 },
document: { version: 2 },
instance: { version: 21 },
instance_page_state: { version: 5 },
page: { version: 1 },
shape: { version: 3, subTypeKey: "type", subTypeVersions: {} },
instance_presence: { version: 5 },
pointer: { version: 1 }
}
},
rootShapeIds: [],
bindings: {},
assets: {},
session: {
version: 0,
currentPageId: "page:page",
pageStates: [{
pageId: "page:page",
camera: { x: 0, y: 0, z: 1 },
selectedShapeIds: []
}]
}
};
+14
View File
@@ -0,0 +1,14 @@
// TLDraw bindings
import { TLAnyBindingUtilConstructor } from '@tldraw/tldraw'
import { CCSlideLayoutBindingUtil } from './cc-base/cc-slideshow/CCSlideLayoutBindingUtil'
import { ccBindingProps } from './cc-base/cc-props'
// Export CC bindings
export { ccBindingProps }
// Define all binding utils in a single object for easy maintenance
export const BindingUtils = {
CCSlideLayout: CCSlideLayoutBindingUtil,
}
export const allBindingUtils: TLAnyBindingUtilConstructor[] = Object.values(BindingUtils)
@@ -0,0 +1,138 @@
import React from 'react'
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
import { CCBaseShape } from './cc-types'
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
import { logger } from '../../../debugConfig'
export interface ToolbarItem {
id: string
icon: string | React.ReactNode
label: string
onClick: (e: React.MouseEvent, shape: CCBaseShape) => void
isActive?: boolean
}
export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShapeUtil<T> {
abstract renderContent: (shape: T) => React.ReactElement
indicator(shape: T) {
return (
<rect
width={shape.props.w}
height={shape.props.h}
fill="none"
rx={CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius}
stroke={CC_BASE_STYLE_CONSTANTS.COLORS.border}
strokeWidth={CC_BASE_STYLE_CONSTANTS.CONTAINER.borderWidth}
/>
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getToolbarItems(shape: T): ToolbarItem[] {
return []
}
onAfterCreate(shape: T) {
logger.info('cc-base-shape-util', 'onAfterCreate', shape)
return shape
}
component(shape: T) {
const {
props: { w, h, isLocked },
} = shape
const toolbarItems = this.getToolbarItems(shape)
return (
<HTMLContainer
id={shape.id}
style={{
width: toDomPrecision(w),
height: toDomPrecision(h),
backgroundColor: shape.props.headerColor,
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
overflow: 'hidden',
position: 'relative',
}}
>
{/* Header */}
<div
style={{
backgroundColor: shape.props.headerColor,
padding: CC_BASE_STYLE_CONSTANTS.HEADER.padding,
height: CC_BASE_STYLE_CONSTANTS.HEADER.height,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: isLocked ? 'not-allowed' : 'move',
pointerEvents: 'all',
position: 'relative',
zIndex: 1,
}}
>
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
{toolbarItems.map((item) => (
<button
key={item.id}
title={item.label}
onClick={(e) => {
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
e.preventDefault()
e.stopPropagation()
item.onClick(e, shape)
}}
onPointerDown={(e) => {
logger.info('cc-base-shape-util', 'toolbar item pointer down', item.id)
e.preventDefault()
e.stopPropagation()
}}
style={{
background: 'transparent',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: 'white',
opacity: item.isActive ? 1 : 0.7,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
fontSize: '16px',
width: '24px',
height: '24px',
zIndex: 100,
userSelect: 'none',
position: 'relative',
touchAction: 'none',
}}
>
<div style={{ pointerEvents: 'none' }}>
{item.icon}
</div>
</button>
))}
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
</div>
</div>
{/* Content */}
<div
style={{
position: 'absolute',
top: CC_BASE_STYLE_CONSTANTS.HEADER.height,
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
backgroundColor: shape.props.backgroundColor,
}}
>
{this.renderContent(shape)}
</div>
</HTMLContainer>
)
}
}
@@ -0,0 +1,78 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { ccShapeProps, getDefaultCCCalendarProps } from '../cc-props'
import { ccShapeMigrations } from '../cc-migrations'
import { Rectangle2d } from 'tldraw'
import { CalendarComponent, CalendarViewType } from './CalendarComponent'
import { TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
export interface CCCalendarShape extends CCBaseShape {
type: 'cc-calendar'
props: CCBaseShape['props'] & {
date: string
selectedDate: string
view: CalendarViewType
events: TeacherTimetableEvent[]
}
}
export class CCCalendarShapeUtil extends CCBaseShapeUtil<CCCalendarShape> {
static override type = 'cc-calendar' as const;
static override props = ccShapeProps.calendar;
static override migrations = ccShapeMigrations.calendar;
override getDefaultProps(): CCCalendarShape['props'] {
return getDefaultCCCalendarProps() as CCCalendarShape['props'];
}
override isAspectRatioLocked = () => true
override canResize = () => true
override canBind = () => false
onCreate = (shape: CCCalendarShape) => {
// Force a resize after creation to ensure calendar renders correctly
setTimeout(() => {
const element = document.getElementById(shape.id)
if (element) {
const calendar = element.querySelector('.fc') as HTMLElement
if (calendar) {
calendar.style.height = `${shape.props.h}px`
}
}
}, 0)
return {
...shape,
props: this.getDefaultProps(),
}
}
override renderContent = (shape: CCCalendarShape) => {
return <CalendarComponent shape={shape} />
}
override getGeometry(shape: CCCalendarShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
override onResize = (
shape: CCCalendarShape,
info: { initialShape: CCCalendarShape; scaleX: number; scaleY: number }
) => {
const { initialShape, scaleX, scaleY } = info
const newW = Math.max(300, Math.round(initialShape.props.w * scaleX))
const newH = Math.max(200, Math.round(initialShape.props.h * scaleY))
return {
props: {
...shape.props,
w: newW,
h: newH,
},
}
}
}
@@ -0,0 +1,274 @@
import React, { useCallback, useEffect, useState, useRef } from 'react'
import { HTMLContainer, useEditor, useDialogs } from '@tldraw/tldraw'
import FullCalendar from '@fullcalendar/react'
import { DateSelectArg, ViewMountArg } from '@fullcalendar/core'
import { useNeoUser } from '../../../../contexts/NeoUserContext'
import { LoadingState } from '../../../../services/tldraw/snapshotService'
import { TimetableNeoDBService, TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
import { useCalendarOptions } from './useCalendarOptions'
import { openTldrawFile } from './utils'
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles'
import { CCCalendarShape } from './CCCalendarShapeUtil'
import { ClassFilterDialog, ViewMenuDialog, EventDetailsDialog } from './CalendarDialogs'
export type CalendarViewType = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listYear' | 'listMonth' | 'listWeek' | 'listDay' | 'timeGridYear' | 'timeGridMonth'
interface CalendarComponentProps {
shape: CCCalendarShape
}
export const CalendarComponent: React.FC<CalendarComponentProps> = ({ shape }) => {
const editor = useEditor()
const { addDialog } = useDialogs()
const { workerNode, isLoading, error, workerDbName } = useNeoUser()
const [events, setEvents] = useState<TeacherTimetableEvent[]>(shape.props.events)
const calendarRef = useRef<FullCalendar>(null)
const lastFetchRef = useRef<number>(0)
const timeoutIdRef = useRef<ReturnType<typeof setTimeout>>()
const [selectedClasses, setSelectedClasses] = useState<string[]>([])
const [subjectClasses, setSubjectClasses] = useState<string[]>([])
const [fileLoadingState, setFileLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
})
const debouncedUpdateSize = useCallback(() => {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(() => {
if (calendarRef.current) {
calendarRef.current.getApi().updateSize();
}
}, 200);
}, [calendarRef]);
const loadEvents = useCallback(async () => {
const now = Date.now()
if (now - lastFetchRef.current < 60000) {
return;
}
lastFetchRef.current = now
if ((events && events.length > 0) || isLoading || error || !workerNode?.nodeData) {
if (isLoading || error || !workerNode?.nodeData) {
console.error('Unable to fetch events: NeoUser context not ready');
}
return;
}
try {
const fetchedEvents = await TimetableNeoDBService.fetchTeacherTimetableEvents(
workerNode.nodeData.unique_id,
workerDbName || ''
);
setEvents(fetchedEvents);
const range = TimetableNeoDBService.getEventRange(fetchedEvents);
if (calendarRef.current) {
const api = calendarRef.current.getApi();
if (range.start && range.end) {
api.setOption('validRange', {
start: range.start,
end: range.end
});
} else {
api.setOption('validRange', undefined);
}
}
editor.updateShape({
id: shape.id,
type: 'cc-calendar',
props: { ...shape.props, events: fetchedEvents }
})
} catch (error) {
console.error('Error fetching calendar events:', error)
}
}, [editor, workerNode, workerDbName, isLoading, error, events, shape.id, shape.props])
useEffect(() => {
debouncedUpdateSize();
}, [debouncedUpdateSize]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
const handleDateSelect = useCallback((arg: DateSelectArg) => {
editor.updateShape<CCCalendarShape>({
id: shape.id,
type: 'cc-calendar',
props: { ...shape.props, selectedDate: arg.start.toISOString() }
})
}, [editor, shape.id, shape.props])
const handleViewChange = useCallback((mountArg: ViewMountArg) => {
const newView = mountArg.view.type as CCCalendarShape['props']['view']
editor.updateShape<CCCalendarShape>({
id: shape.id,
type: 'cc-calendar',
props: { ...shape.props, view: newView }
})
}, [editor, shape.id, shape.props])
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
}, [])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
}, [])
const handleWheel = useCallback((e: React.WheelEvent) => {
e.stopPropagation()
}, [])
const handleClassToggle = (subjectClass: string) => {
setSelectedClasses(prev =>
prev.includes(subjectClass)
? prev.filter(c => c !== subjectClass)
: [...prev, subjectClass]
);
};
useEffect(() => {
const classes = Array.from(new Set(events.map(event => event.extendedProps?.subjectClass || '')));
setSubjectClasses(classes);
setSelectedClasses(classes);
}, [events]);
const filteredEvents = events.filter(event =>
selectedClasses.includes(event.extendedProps?.subjectClass || '')
);
// Calculate available height for calendar
const getAvailableHeight = useCallback(() => {
const totalPadding = CC_BASE_STYLE_CONSTANTS.CONTENT.padding * 2
const availableHeight = shape.props.h - CC_BASE_STYLE_CONSTANTS.HEADER.height - totalPadding
return Math.max(availableHeight, CC_BASE_STYLE_CONSTANTS.MIN_DIMENSIONS.height)
}, [shape.props.h])
// Calculate available width for calendar
const getAvailableWidth = useCallback(() => {
const totalPadding = CC_BASE_STYLE_CONSTANTS.CONTENT.padding * 2
const availableWidth = shape.props.w - totalPadding
return Math.max(availableWidth, CC_BASE_STYLE_CONSTANTS.MIN_DIMENSIONS.width)
}, [shape.props.w])
const showClassFilterDialog = useCallback(() => {
addDialog({
component: ({ onClose }) => (
<ClassFilterDialog
onClose={onClose}
subjectClasses={subjectClasses}
selectedClasses={selectedClasses}
onClassToggle={handleClassToggle}
events={events}
/>
),
onClose: () => {
// Optional cleanup
},
})
}, [addDialog, subjectClasses, selectedClasses, events, handleClassToggle])
const showViewMenuDialog = useCallback(() => {
addDialog({
component: ({ onClose }) => (
<ViewMenuDialog
onSelect={(view) => {
calendarRef.current?.getApi().changeView(view)
onClose()
}}
onClose={onClose}
/>
),
onClose: () => {
// Optional cleanup
},
})
}, [addDialog, calendarRef])
const showEventDetailsDialog = useCallback((event: TeacherTimetableEvent) => {
addDialog({
component: ({ onClose }) => (
<EventDetailsDialog
onClose={onClose}
selectedEvent={event}
fileLoadingState={fileLoadingState}
editor={editor}
workerDbName={workerDbName || ''}
onOpenFile={openTldrawFile}
setFileLoadingState={setFileLoadingState}
/>
),
onClose: () => {
// Optional cleanup
},
})
}, [addDialog, fileLoadingState, editor, workerDbName])
const calendarOptions = useCalendarOptions({
shape: {
...shape,
props: {
...shape.props,
h: getAvailableHeight(),
}
},
filteredEvents,
handleDateSelect,
handleViewChange,
toggleClassFilterModal: showClassFilterDialog,
setIsViewMenuOpen: () => showViewMenuDialog(),
setSelectedEvent: (event: TeacherTimetableEvent | null) => {
if (event) showEventDetailsDialog(event)
},
setIsEventModalOpen: () => {}, // No longer needed
});
// Update calendar size when shape dimensions change
useEffect(() => {
if (calendarRef.current) {
const calendar = calendarRef.current.getApi();
calendar.updateSize();
}
}, [shape.props.w, shape.props.h]);
return (
<HTMLContainer
id={shape.id}
style={{
width: getAvailableWidth(),
height: getAvailableHeight(),
position: 'relative',
overflow: 'hidden',
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
color: 'black',
pointerEvents: 'all',
display: 'flex',
flexDirection: 'column',
}}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
onWheel={handleWheel}
>
<div style={{
width: '100%',
height: '100%',
overflow: 'hidden'
}}>
<FullCalendar
ref={calendarRef}
{...calendarOptions}
/>
</div>
</HTMLContainer>
)
}
@@ -0,0 +1,184 @@
import {
TldrawUiButton,
TldrawUiButtonLabel,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogFooter,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
Editor
} from '@tldraw/tldraw'
import { FaCheck, FaExternalLinkAlt } from 'react-icons/fa'
import { TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
import { LoadingState } from '../../../../services/tldraw/snapshotService'
// View Menu Dialog Component
interface ViewMenuDialogProps {
onSelect: (view: string) => void
onClose: () => void
}
export const ViewMenuDialog = ({ onSelect, onClose }: ViewMenuDialogProps) => {
const views = [
{ name: 'Day', view: 'timeGridDay' },
{ name: 'Week', view: 'timeGridWeek' },
{ name: 'Month', view: 'dayGridMonth' },
{ name: 'Year', view: 'dayGridYear' },
{ name: 'List Day', view: 'listDay' },
{ name: 'List Week', view: 'listWeek' },
{ name: 'List Month', view: 'listMonth' },
{ name: 'List Year', view: 'listYear' },
]
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Select Calendar View</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{views.map(({name, view}) => (
<TldrawUiButton key={view} type="normal" onClick={() => onSelect(view)}>
<TldrawUiButtonLabel>{name}</TldrawUiButtonLabel>
</TldrawUiButton>
))}
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="normal" onClick={onClose}>
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
// Class Filter Dialog
interface ClassFilterDialogProps {
onClose: () => void
subjectClasses: string[]
selectedClasses: string[]
onClassToggle: (subjectClass: string) => void
events: TeacherTimetableEvent[]
}
export const ClassFilterDialog = ({
onClose,
subjectClasses,
selectedClasses,
onClassToggle,
events
}: ClassFilterDialogProps) => {
const renderClassFilterButton = (subjectClass: string) => {
const isSelected = selectedClasses.includes(subjectClass)
const color = events.find(e => e.extendedProps?.subjectClass === subjectClass)?.extendedProps?.color || '#000000'
return (
<TldrawUiButton
key={subjectClass}
type="normal"
onClick={() => onClassToggle(subjectClass)}
style={{
backgroundColor: isSelected ? color : '#ffffff',
color: isSelected ? '#ffffff' : '#000000',
border: `2px solid ${color}`,
}}
>
<TldrawUiButtonLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ marginRight: '8px' }}>
{isSelected && <FaCheck />}
</div>
<span>{subjectClass}</span>
</div>
</TldrawUiButtonLabel>
</TldrawUiButton>
)
}
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Filter Classes</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{subjectClasses.map(renderClassFilterButton)}
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="normal" onClick={onClose}>
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
// Event Details Dialog
interface EventDetailsDialogProps {
onClose: () => void
selectedEvent: TeacherTimetableEvent | null
fileLoadingState: LoadingState
editor: Editor
workerDbName: string | undefined
onOpenFile: (path: string, dbName: string, editor: Editor, setFileLoadingState: (state: LoadingState) => void) => void
setFileLoadingState: (state: LoadingState) => void
}
export const EventDetailsDialog = ({
onClose,
selectedEvent,
fileLoadingState,
editor,
workerDbName,
onOpenFile,
setFileLoadingState
}: EventDetailsDialogProps) => {
const handleOpenFile = () => {
if (!selectedEvent?.extendedProps?.tldraw_snapshot || !workerDbName) {
console.error('❌ Failed to open tldraw file - missing snapshot or db name')
return
}
onOpenFile(
selectedEvent.extendedProps.tldraw_snapshot,
workerDbName,
editor,
setFileLoadingState
)
}
if (!selectedEvent) return null
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>{selectedEvent.title}</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody>
<p><strong>Start Time:</strong> {new Date(selectedEvent.start).toLocaleString()}</p>
<p><strong>End Time:</strong> {new Date(selectedEvent.end).toLocaleString()}</p>
<p><strong>Class:</strong> {selectedEvent.extendedProps?.subjectClass}</p>
<p><strong>Period:</strong> {selectedEvent.extendedProps?.periodCode}</p>
{fileLoadingState.status === 'loading' && <p>Loading file...</p>}
{fileLoadingState.status === 'error' && (
<p style={{color: 'red'}}>Error: {fileLoadingState.error}</p>
)}
{selectedEvent.extendedProps?.tldraw_snapshot && fileLoadingState.status !== 'loading' && (
<TldrawUiButton type="normal" onClick={handleOpenFile}>
<TldrawUiButtonLabel>
Open Tldraw File <FaExternalLinkAlt style={{ marginLeft: '8px' }} />
</TldrawUiButtonLabel>
</TldrawUiButton>
)}
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="normal" onClick={onClose}>
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}
@@ -0,0 +1,131 @@
import { CalendarOptions, EventContentArg, formatDate, DateSelectArg, ViewMountArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import { TimetableNeoDBService } from '../../../../services/graph/timetableNeoDBService'
import { CC_CALENDAR_STYLE_CONSTANTS } from '../cc-styles'
// Calendar layout constants
export const CALENDAR_LAYOUT = {
TOOLBAR_HEIGHT: 5,
HEADER_PADDING: 5,
CONTENT_PADDING: 5,
FOOTER_HEIGHT: 0,
// Function to calculate available content height
getAvailableHeight: (totalHeight: number) => {
return totalHeight - (
CALENDAR_LAYOUT.TOOLBAR_HEIGHT +
CALENDAR_LAYOUT.HEADER_PADDING * 2 +
CALENDAR_LAYOUT.CONTENT_PADDING * 2 +
CALENDAR_LAYOUT.FOOTER_HEIGHT
);
}
} as const;
// Re-export types that other files might need
export type { CalendarOptions, EventContentArg, DateSelectArg, ViewMountArg }
// Export plugins
export const getCalendarPlugins = () => [
dayGridPlugin,
timeGridPlugin,
listPlugin,
interactionPlugin,
]
// Export event content renderer
export const renderEventContent = (arg: EventContentArg) => {
const backgroundColor = TimetableNeoDBService.lightenColor(arg.event.extendedProps?.color || '#000000', 100);
const textColor = TimetableNeoDBService.getContrastColor(backgroundColor);
const formatEventTime = (date: Date | null) => {
if (!date) {
return '';
}
return formatDate(date, {
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZone: 'local'
});
};
const formattedStartTime = formatEventTime(arg.event.start); //DO NOT REMOVE THIS COMMENT
const formattedEndTime = formatEventTime(arg.event.end); //DO NOT REMOVE THIS COMMENT
const timeAndPeriodCodeString = `${arg.event.extendedProps?.periodCode || ''}`; //DO NOT REMOVE THIS COMMENT
return {
html: `
<div class="fc-event-main-frame" style="background-color: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.backgroundColor}; color: ${textColor}; display: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.display}; align-items: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.alignItems}; justify-content: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.justifyContent}; min-height: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.minHeight}; padding: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.padding}; border-radius: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.mainFrame.borderRadius};">
<div class="fc-event-title fc-sticky" style="color: ${textColor}; font-size: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.fontSize}; font-weight: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.fontWeight}; text-align: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.textAlign}; overflow: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.overflow}; text-overflow: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.textOverflow}; white-space: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.whiteSpace}; opacity: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.opacity}; padding: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.padding}; width: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.width}; letter-spacing: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.letterSpacing}; margin: ${CC_CALENDAR_STYLE_CONSTANTS.EVENT.title.margin};">${arg.event.title}</div>
</div>
`
};
}
// Export base calendar options
export const getBaseCalendarOptions = (): Partial<CalendarOptions> => ({
aspectRatio: undefined,
expandRows: true,
stickyHeaderDates: true,
stickyFooterScrollbar: false,
dayHeaders: true,
dayHeaderFormat(arg) {
return formatDate(arg.date.marker, { weekday: 'long' });
},
titleFormat: (arg) => {
return formatDate(arg.date.marker, { weekday: 'long' });
},
dayMaxEvents: 10,
selectable: true,
eventMinHeight: 10,
navLinks: true,
hiddenDays: [1],
weekends: false,
slotMinTime: '08:00:00',
slotMaxTime: '16:00:00',
slotDuration: '00:30:00',
slotLabelInterval: '01:00',
allDaySlot: false,
slotEventOverlap: true,
dayMaxEventRows: 1,
eventMaxStack: 1,
slotLabelFormat: [
{
formatMatcher: 'best fit',
hour: 'numeric',
minute: '2-digit',
omitZeroMinute: true,
meridiem: 'narrow',
hour12: false,
}
],
buttonText: {
today: 'Today',
month: 'Month',
week: 'Week',
day: 'Day',
list: 'List',
},
buttonIcons: {
prev: 'chevron-left',
next: 'chevron-right',
prevYear: 'chevrons-left',
nextYear: 'chevrons-right',
},
themeSystem: 'standard',
eventContent: renderEventContent,
eventClassNames: (arg: { event: { extendedProps?: { subjectClass?: string; color?: string } } }) => {
return [arg.event.extendedProps?.subjectClass || ''];
},
eventDidMount: (arg: { event: { extendedProps?: { color?: string }; id: string }; el: HTMLElement }) => {
if (arg.event.extendedProps?.color) {
const originalColor = arg.event.extendedProps.color;
const lightenedColor = TimetableNeoDBService.lightenColor(originalColor, 100);
arg.el.style.backgroundColor = lightenedColor;
arg.el.style.borderColor = originalColor;
arg.el.style.color = TimetableNeoDBService.getContrastColor(lightenedColor);
}
},
})
@@ -0,0 +1,62 @@
import { TeacherTimetableEvent } from '../../../../services/graph/timetableNeoDBService'
import { getBaseCalendarOptions, getCalendarPlugins, CalendarOptions, DateSelectArg, ViewMountArg, CALENDAR_LAYOUT } from './calendarOptions'
interface UseCalendarOptionsProps {
shape: {
props: {
view: string
h: number
}
}
filteredEvents: TeacherTimetableEvent[]
handleDateSelect: (arg: DateSelectArg) => void
handleViewChange: (arg: ViewMountArg) => void
toggleClassFilterModal: () => void
setIsViewMenuOpen: (isOpen: boolean) => void
setSelectedEvent: (event: TeacherTimetableEvent | null) => void
setIsEventModalOpen: (isOpen: boolean) => void
}
export const useCalendarOptions = ({
shape,
filteredEvents,
handleDateSelect,
handleViewChange,
toggleClassFilterModal,
setIsViewMenuOpen,
setSelectedEvent,
setIsEventModalOpen,
}: UseCalendarOptionsProps): CalendarOptions => {
const availableHeight = CALENDAR_LAYOUT.getAvailableHeight(shape.props.h);
return {
...getBaseCalendarOptions(),
plugins: getCalendarPlugins(),
initialView: shape.props.view,
events: filteredEvents,
select: handleDateSelect,
viewDidMount: handleViewChange,
height: availableHeight,
contentHeight: availableHeight,
headerToolbar: {
left: 'prev,next today viewToggle',
center: 'title',
right: 'filterClasses'
},
customButtons: {
filterClasses: {
text: 'Classes',
click: toggleClassFilterModal,
},
viewToggle: {
text: 'View',
click: () => setIsViewMenuOpen(true),
},
},
eventClick: (arg) => {
const eventData = arg.event.toPlainObject();
setSelectedEvent(eventData as unknown as TeacherTimetableEvent);
setIsEventModalOpen(true);
},
};
};
@@ -0,0 +1,28 @@
import { Editor } from '@tldraw/tldraw'
import { LoadingState, NavigationSnapshotService } from '../../../../services/tldraw/snapshotService'
import logger from '../../../../debugConfig'
export const openTldrawFile = async (
path: string,
dbName: string,
editor: Editor,
setFileLoadingState: (state: LoadingState) => void
) => {
logger.info('calendar-shape', '📂 Opening tldraw file', {
path,
db_name: dbName
});
try {
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
path,
dbName,
editor.store,
setFileLoadingState
);
} catch (error) {
logger.error('calendar-shape', '❌ Failed to open tldraw file', {
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
+108
View File
@@ -0,0 +1,108 @@
import { CC_BASE_STYLE_CONSTANTS, CC_SLIDESHOW_STYLE_CONSTANTS } from './cc-styles'
import { TLShapeId } from '@tldraw/tldraw'
export interface CCShapeConfig {
width: number
height: number
xOffset: number
yOffset: number
defaultProps: {
title: string
headerColor: string
backgroundColor: string
isLocked: boolean
[key: string]: string | number | boolean | Date | TLShapeId[] | undefined | null
}
}
export const CC_SHAPE_CONFIGS: Record<string, CCShapeConfig> = {
'cc-calendar': {
width: 400,
height: 600,
xOffset: 0,
yOffset: 0,
defaultProps: {
title: 'Calendar',
headerColor: CC_BASE_STYLE_CONSTANTS.COLORS.primary,
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
isLocked: false,
date: new Date().toISOString(),
events: [],
view: 'timeGridWeek',
}
},
'cc-settings': {
width: 400,
height: 500,
xOffset: 0,
yOffset: 0,
defaultProps: {
title: 'User Settings',
headerColor: CC_BASE_STYLE_CONSTANTS.COLORS.primary,
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
isLocked: false,
}
},
'cc-live-transcription': {
width: 300,
height: 400,
xOffset: 0,
yOffset: 0,
defaultProps: {
title: 'Live Transcription',
headerColor: CC_BASE_STYLE_CONSTANTS.COLORS.primary,
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
isLocked: false,
isRecording: false,
segments: [],
currentSegment: undefined,
lastProcessedSegment: undefined,
}
},
'cc-slideshow': {
width: CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_WIDTH * 3 + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING * 4,
height: CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING * 2 +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING * 2,
xOffset: 0,
yOffset: 0,
defaultProps: {
title: 'Slideshow',
headerColor: CC_BASE_STYLE_CONSTANTS.COLORS.primary,
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
isLocked: false,
currentSlideIndex: 0,
slidePattern: 'horizontal',
numSlides: 3,
}
},
'cc-slide': {
width: CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_WIDTH,
height: CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_HEIGHT,
xOffset: 0,
yOffset: 0,
defaultProps: {
title: 'Slide',
headerColor: CC_BASE_STYLE_CONSTANTS.COLORS.primary,
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
isLocked: false,
imageData: '',
}
},
'cc-youtube-embed': {
width: 800,
height: 450 + CC_BASE_STYLE_CONSTANTS.HEADER.height + (CC_BASE_STYLE_CONSTANTS.CONTENT.padding * 2),
xOffset: 0,
yOffset: 0,
defaultProps: {
title: 'YouTube Video',
headerColor: CC_BASE_STYLE_CONSTANTS.COLORS.primary,
backgroundColor: CC_BASE_STYLE_CONSTANTS.CONTENT.backgroundColor,
isLocked: false,
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
transcript: [],
transcriptVisible: false,
}
}
}
@@ -0,0 +1,62 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { ccGraphShapeProps, getDefaultCCAcademicDayNodeProps } from './cc-graph-props'
import { CCAcademicDayNodeProps } from './cc-graph-types'
import { NodeProperty } from './cc-graph-shared'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCBaseShape } from '../cc-types'
export interface CCAcademicDayNodeShape extends CCBaseShape {
type: 'cc-academic-day-node'
props: CCAcademicDayNodeProps
}
export class CCAcademicDayNodeShapeUtil extends CCBaseShapeUtil<CCAcademicDayNodeShape> {
static type = 'cc-academic-day-node' as const
static props = ccGraphShapeProps['cc-academic-day-node']
getDefaultProps(): CCAcademicDayNodeShape['props'] {
const defaultProps = getDefaultCCAcademicDayNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCAcademicDayNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCAcademicDayNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Academic Day"
value={shape.props.academic_day}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Day of Week"
value={shape.props.day_of_week}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Day Type"
value={shape.props.day_type}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,68 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCAcademicPeriodNodeProps } from './cc-graph-props'
import { CCAcademicPeriodNodeProps } from './cc-graph-types'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
export interface CCAcademicPeriodNodeShape extends CCBaseShape {
type: 'cc-academic-period-node'
props: CCAcademicPeriodNodeProps
}
export class CCAcademicPeriodNodeShapeUtil extends CCBaseShapeUtil<CCAcademicPeriodNodeShape> {
static type = 'cc-academic-period-node' as const
static props = ccGraphShapeProps['cc-academic-period-node']
getDefaultProps(): CCAcademicPeriodNodeShape['props'] {
const defaultProps = getDefaultCCAcademicPeriodNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCAcademicPeriodNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCAcademicPeriodNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Name"
value={shape.props.name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,62 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCAcademicTermNodeProps } from './cc-graph-props'
import { CCAcademicTermNodeProps } from './cc-graph-types'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
export interface CCAcademicTermNodeShape extends CCBaseShape {
type: 'cc-academic-term-node'
props: CCAcademicTermNodeProps
}
export class CCAcademicTermNodeShapeUtil extends CCBaseShapeUtil<CCAcademicTermNodeShape> {
static type = 'cc-academic-term-node' as const
static props = ccGraphShapeProps['cc-academic-term-node']
getDefaultProps(): CCAcademicTermNodeShape['props'] {
const defaultProps = getDefaultCCAcademicTermNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCAcademicTermNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCAcademicTermNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Term Name"
value={shape.props.term_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Term Number"
value={shape.props.term_number}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Date"
value={shape.props.start_date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Date"
value={shape.props.end_date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,56 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCAcademicWeekNodeProps } from './cc-graph-props'
import { CCAcademicWeekNodeProps } from './cc-graph-types'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
export interface CCAcademicWeekNodeShape extends CCBaseShape {
type: 'cc-academic-week-node'
props: CCAcademicWeekNodeProps
}
export class CCAcademicWeekNodeShapeUtil extends CCBaseShapeUtil<CCAcademicWeekNodeShape> {
static type = 'cc-academic-week-node' as const
static props = ccGraphShapeProps['cc-academic-week-node']
getDefaultProps(): CCAcademicWeekNodeShape['props'] {
const defaultProps = getDefaultCCAcademicWeekNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCAcademicWeekNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCAcademicWeekNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Academic Week Number"
value={shape.props.academic_week_number}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Date"
value={shape.props.start_date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Week Type"
value={shape.props.week_type}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,44 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCAcademicYearNodeProps } from './cc-graph-props'
import { CCAcademicYearNodeProps } from './cc-graph-types'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
export interface CCAcademicYearNodeShape extends CCBaseShape {
type: 'cc-academic-year-node'
props: CCAcademicYearNodeProps
}
export class CCAcademicYearNodeShapeUtil extends CCBaseShapeUtil<CCAcademicYearNodeShape> {
static type = 'cc-academic-year-node' as const
static props = ccGraphShapeProps['cc-academic-year-node']
getDefaultProps(): CCAcademicYearNodeShape['props'] {
const defaultProps = getDefaultCCAcademicYearNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCAcademicYearNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCAcademicYearNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Academic Year"
value={shape.props.year}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,56 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty, formatDate } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCCalendarDayNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCCalendarDayNodeProps } from './cc-graph-types'
export interface CCCalendarDayNodeShape extends CCBaseShape {
type: 'cc-calendar-day-node'
props: CCCalendarDayNodeProps
}
export class CCCalendarDayNodeShapeUtil extends CCBaseShapeUtil<CCCalendarDayNodeShape> {
static type = 'cc-calendar-day-node' as const
static props = ccGraphShapeProps['cc-calendar-day-node']
getDefaultProps(): CCCalendarDayNodeShape['props'] {
const defaultProps = getDefaultCCCalendarDayNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCCalendarDayNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCCalendarDayNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Date"
value={formatDate(shape.props.date)}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Day of Week"
value={shape.props.day_of_week}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="ISO Day"
value={shape.props.iso_day}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,56 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCCalendarMonthNodeProps } from './cc-graph-props'
import { CCCalendarMonthNodeProps } from './cc-graph-types'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
export interface CCCalendarMonthNodeShape extends CCBaseShape {
type: 'cc-calendar-month-node'
props: CCCalendarMonthNodeProps
}
export class CCCalendarMonthNodeShapeUtil extends CCBaseShapeUtil<CCCalendarMonthNodeShape> {
static type = 'cc-calendar-month-node' as const
static props = ccGraphShapeProps['cc-calendar-month-node']
getDefaultProps(): CCCalendarMonthNodeShape['props'] {
const defaultProps = getDefaultCCCalendarMonthNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCCalendarMonthNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCCalendarMonthNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Year"
value={shape.props.year}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Month"
value={shape.props.month}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Month Name"
value={shape.props.month_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,62 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty, formatDate } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCCalendarNodeProps } from './cc-graph-props'
import { CCCalendarNodeProps } from './cc-graph-types'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
export interface CCCalendarNodeShape extends CCBaseShape {
type: 'cc-calendar-node'
props: CCCalendarNodeProps
}
export class CCCalendarNodeShapeUtil extends CCBaseShapeUtil<CCCalendarNodeShape> {
static type = 'cc-calendar-node' as const
static props = ccGraphShapeProps['cc-calendar-node']
getDefaultProps(): CCCalendarNodeShape['props'] {
const defaultProps = getDefaultCCCalendarNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCCalendarNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCCalendarNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Calendar Name"
value={shape.props.calendar_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Calendar Type"
value={shape.props.calendar_type}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Date"
value={formatDate(shape.props.start_date)}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Date"
value={formatDate(shape.props.end_date)}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,50 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCCalendarTimeChunkNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCCalendarTimeChunkNodeProps } from './cc-graph-types'
export interface CCCalendarTimeChunkNodeShape extends CCBaseShape {
type: 'cc-calendar-time-chunk-node'
props: CCCalendarTimeChunkNodeProps
}
export class CCCalendarTimeChunkNodeShapeUtil extends CCBaseShapeUtil<CCCalendarTimeChunkNodeShape> {
static type = 'cc-calendar-time-chunk-node' as const
static props = ccGraphShapeProps['cc-calendar-time-chunk-node']
getDefaultProps(): CCCalendarTimeChunkNodeShape['props'] {
const defaultProps = getDefaultCCCalendarTimeChunkNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCCalendarTimeChunkNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCCalendarTimeChunkNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,56 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty, formatDate, DateValue } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCCalendarWeekNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCCalendarWeekNodeProps } from './cc-graph-types'
export interface CCCalendarWeekNodeShape extends CCBaseShape {
type: 'cc-calendar-week-node'
props: CCCalendarWeekNodeProps
}
export class CCCalendarWeekNodeShapeUtil extends CCBaseShapeUtil<CCCalendarWeekNodeShape> {
static type = 'cc-calendar-week-node' as const
static props = ccGraphShapeProps['cc-calendar-week-node']
getDefaultProps(): CCCalendarWeekNodeShape['props'] {
const defaultProps = getDefaultCCCalendarWeekNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCCalendarWeekNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCCalendarWeekNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Start Date"
value={formatDate(shape.props.start_date as DateValue)}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Week Number"
value={shape.props.week_number}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="ISO Week"
value={shape.props.iso_week}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,44 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCCalendarYearNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCCalendarYearNodeProps } from './cc-graph-types'
export interface CCCalendarYearNodeShape extends CCBaseShape {
type: 'cc-calendar-year-node'
props: CCCalendarYearNodeProps
}
export class CCCalendarYearNodeShapeUtil extends CCBaseShapeUtil<CCCalendarYearNodeShape> {
static type = 'cc-calendar-year-node' as const
static props = ccGraphShapeProps['cc-calendar-year-node']
getDefaultProps(): CCCalendarYearNodeShape['props'] {
const defaultProps = getDefaultCCCalendarYearNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCCalendarYearNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCCalendarYearNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Year"
value={shape.props.year}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,44 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCCurriculumStructureNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCCurriculumStructureNodeProps } from './cc-graph-types'
export interface CCCurriculumStructureNodeShape extends CCBaseShape {
type: 'cc-curriculum-structure-node'
props: CCCurriculumStructureNodeProps
}
export class CCCurriculumStructureNodeShapeUtil extends CCBaseShapeUtil<CCCurriculumStructureNodeShape> {
static type = 'cc-curriculum-structure-node' as const
static props = ccGraphShapeProps['cc-curriculum-structure-node']
getDefaultProps(): CCCurriculumStructureNodeShape['props'] {
const defaultProps = getDefaultCCCurriculumStructureNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCCurriculumStructureNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCCurriculumStructureNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Structure Type"
value="Curriculum"
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,44 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCDepartmentNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCDepartmentNodeProps } from './cc-graph-types'
export interface CCDepartmentNodeShape extends CCBaseShape {
type: 'cc-department-node'
props: CCDepartmentNodeProps
}
export class CCDepartmentNodeShapeUtil extends CCBaseShapeUtil<CCDepartmentNodeShape> {
static type = 'cc-department-node' as const
static props = ccGraphShapeProps['cc-department-node']
getDefaultProps(): CCDepartmentNodeShape['props'] {
const defaultProps = getDefaultCCDepartmentNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCDepartmentNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCDepartmentNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Department Name"
value={shape.props.department_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,44 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCDepartmentStructureNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCDepartmentStructureNodeProps } from './cc-graph-types'
export interface CCDepartmentStructureNodeShape extends CCBaseShape {
type: 'cc-department-structure-node'
props: CCDepartmentStructureNodeProps
}
export class CCDepartmentStructureNodeShapeUtil extends CCBaseShapeUtil<CCDepartmentStructureNodeShape> {
static type = 'cc-department-structure-node' as const
static props = ccGraphShapeProps['cc-department-structure-node']
getDefaultProps(): CCDepartmentStructureNodeShape['props'] {
const defaultProps = getDefaultCCDepartmentStructureNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCDepartmentStructureNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCDepartmentStructureNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Structure Type"
value={shape.props.department_structure_type || "Department"}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,50 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCKeyStageNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCKeyStageNodeProps } from './cc-graph-types'
export interface CCKeyStageNodeShape extends CCBaseShape {
type: 'cc-key-stage-node'
props: CCKeyStageNodeProps
}
export class CCKeyStageNodeShapeUtil extends CCBaseShapeUtil<CCKeyStageNodeShape> {
static type = 'cc-key-stage-node' as const
static props = ccGraphShapeProps['cc-key-stage-node']
getDefaultProps(): CCKeyStageNodeShape['props'] {
const defaultProps = getDefaultCCKeyStageNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCKeyStageNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCKeyStageNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Key Stage"
value={shape.props.key_stage}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Name"
value={shape.props.key_stage_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,62 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCKeyStageSyllabusNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCKeyStageSyllabusNodeProps } from './cc-graph-types'
export interface CCKeyStageSyllabusNodeShape extends CCBaseShape {
type: 'cc-key-stage-syllabus-node'
props: CCKeyStageSyllabusNodeProps
}
export class CCKeyStageSyllabusNodeShapeUtil extends CCBaseShapeUtil<CCKeyStageSyllabusNodeShape> {
static type = 'cc-key-stage-syllabus-node' as const
static props = ccGraphShapeProps['cc-key-stage-syllabus-node']
getDefaultProps(): CCKeyStageSyllabusNodeShape['props'] {
const defaultProps = getDefaultCCKeyStageSyllabusNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCKeyStageSyllabusNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCKeyStageSyllabusNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Syllabus Name"
value={shape.props.ks_syllabus_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Key Stage"
value={shape.props.ks_syllabus_key_stage}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject"
value={shape.props.ks_syllabus_subject}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject Code"
value={shape.props.ks_syllabus_subject_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,56 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCLearningStatementNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCLearningStatementNodeProps } from './cc-graph-types'
export interface CCLearningStatementNodeShape extends CCBaseShape {
type: 'cc-learning-statement-node'
props: CCLearningStatementNodeProps
}
export class CCLearningStatementNodeShapeUtil extends CCBaseShapeUtil<CCLearningStatementNodeShape> {
static type = 'cc-learning-statement-node' as const
static props = ccGraphShapeProps['cc-learning-statement-node']
getDefaultProps(): CCLearningStatementNodeShape['props'] {
const defaultProps = getDefaultCCLearningStatementNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCLearningStatementNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCLearningStatementNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Learning Statement"
value={shape.props.lesson_learning_statement}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Statement Type"
value={shape.props.lesson_learning_statement_type}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Statement ID"
value={shape.props.lesson_learning_statement_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,45 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCPastoralStructureNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCPastoralStructureNodeProps } from './cc-graph-types'
export interface CCPastoralStructureNodeShape extends CCBaseShape {
type: 'cc-pastoral-structure-node'
props: CCPastoralStructureNodeProps
}
export class CCPastoralStructureNodeShapeUtil extends CCBaseShapeUtil<CCPastoralStructureNodeShape> {
static type = 'cc-pastoral-structure-node' as const
static props = ccGraphShapeProps['cc-pastoral-structure-node']
getDefaultProps(): CCPastoralStructureNodeShape['props'] {
const defaultProps = getDefaultCCPastoralStructureNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCPastoralStructureNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCPastoralStructureNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Structure Type"
value="Pastoral"
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,140 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCPlannedLessonNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCPlannedLessonNodeProps } from './cc-graph-types'
export interface CCPlannedLessonNodeShape extends CCBaseShape {
type: 'cc-planned-lesson-node'
props: CCPlannedLessonNodeProps
}
export class CCPlannedLessonNodeShapeUtil extends CCBaseShapeUtil<CCPlannedLessonNodeShape> {
static type = 'cc-planned-lesson-node' as const
static props = ccGraphShapeProps['cc-planned-lesson-node']
getDefaultProps(): CCPlannedLessonNodeShape['props'] {
const defaultProps = getDefaultCCPlannedLessonNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCPlannedLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCPlannedLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Subject Class"
value={shape.props.subject_class}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Year Group"
value={shape.props.year_group}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject"
value={shape.props.subject}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Teacher Code"
value={shape.props.teacher_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Planning Status"
value={shape.props.planning_status}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Topic Code"
value={shape.props.topic_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Topic Name"
value={shape.props.topic_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Lesson Code"
value={shape.props.lesson_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Lesson Name"
value={shape.props.lesson_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Learning Statement Codes"
value={shape.props.learning_statement_codes}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Learning Statements"
value={shape.props.learning_statements}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Learning Resource Codes"
value={shape.props.learning_resource_codes}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Learning Resources"
value={shape.props.learning_resources}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,68 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCRegistrationPeriodNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCRegistrationPeriodNodeProps } from './cc-graph-types'
export interface CCRegistrationPeriodNodeShape extends CCBaseShape {
type: 'cc-registration-period-node'
props: CCRegistrationPeriodNodeProps
}
export class CCRegistrationPeriodNodeShapeUtil extends CCBaseShapeUtil<CCRegistrationPeriodNodeShape> {
static type = 'cc-registration-period-node' as const
static props = ccGraphShapeProps['cc-registration-period-node']
getDefaultProps(): CCRegistrationPeriodNodeShape['props'] {
const defaultProps = getDefaultCCRegistrationPeriodNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCRegistrationPeriodNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCRegistrationPeriodNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Name"
value={shape.props.name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,50 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCRoomNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCRoomNodeProps } from './cc-graph-types'
export interface CCRoomNodeShape extends CCBaseShape {
type: 'cc-room-node'
props: CCRoomNodeProps
}
export class CCRoomNodeShapeUtil extends CCBaseShapeUtil<CCRoomNodeShape> {
static type = 'cc-room-node' as const
static props = ccGraphShapeProps['cc-room-node']
getDefaultProps(): CCRoomNodeShape['props'] {
const defaultProps = getDefaultCCRoomNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCRoomNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCRoomNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Room Name"
value={shape.props.room_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Room Code"
value={shape.props.room_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,54 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil';
import { CCBaseShape } from '../cc-types';
import { NodeProperty } from './cc-graph-shared';
import { ccGraphShapeProps, getDefaultCCSchoolNodeProps } from './cc-graph-props';
import { getNodeStyles } from './cc-graph-styles';
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles';
import { CCSchoolNodeProps } from './cc-graph-types';
export interface CCSchoolNodeShape extends CCBaseShape {
type: 'cc-school-node';
props: CCSchoolNodeProps;
}
export class CCSchoolNodeShapeUtil extends CCBaseShapeUtil<CCSchoolNodeShape> {
static type = 'cc-school-node' as const;
static props = ccGraphShapeProps['cc-school-node'];
getDefaultProps(): CCSchoolNodeShape['props'] {
const defaultProps = getDefaultCCSchoolNodeProps();
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCSchoolNodeShapeUtil.type]];
return {
...defaultProps,
headerColor: theme.headerColor,
};
}
DefaultComponent = () => null;
renderContent = (shape: CCSchoolNodeShape) => {
const styles = getNodeStyles(shape.type);
return (
<div style={styles.container}>
<NodeProperty
label="School Name"
value={shape.props.school_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="School Website"
value={shape.props.school_website}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="School UUID"
value={shape.props.school_uuid}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
);
};
}
@@ -0,0 +1,50 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCSchoolTimetableNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCSchoolTimetableNodeProps } from './cc-graph-types'
export interface CCSchoolTimetableNodeShape extends CCBaseShape {
type: 'cc-school-timetable-node'
props: CCSchoolTimetableNodeProps
}
export class CCSchoolTimetableNodeShapeUtil extends CCBaseShapeUtil<CCSchoolTimetableNodeShape> {
static type = 'cc-school-timetable-node' as const
static props = ccGraphShapeProps['cc-school-timetable-node']
getDefaultProps(): CCSchoolTimetableNodeShape['props'] {
const defaultProps = getDefaultCCSchoolTimetableNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCSchoolTimetableNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCSchoolTimetableNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Start Date"
value={shape.props.start_date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Date"
value={shape.props.end_date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,80 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCScienceLabNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCScienceLabNodeProps } from './cc-graph-types'
export interface CCScienceLabNodeShape extends CCBaseShape {
type: 'cc-science-lab-node'
props: CCScienceLabNodeProps
}
export class CCScienceLabNodeShapeUtil extends CCBaseShapeUtil<CCScienceLabNodeShape> {
static type = 'cc-science-lab-node' as const
static props = ccGraphShapeProps['cc-science-lab-node']
getDefaultProps(): CCScienceLabNodeShape['props'] {
const defaultProps = getDefaultCCScienceLabNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCScienceLabNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCScienceLabNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Lab Title"
value={shape.props.science_lab_title}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Summary"
value={shape.props.science_lab_summary}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Requirements"
value={shape.props.science_lab_requirements}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Procedure"
value={shape.props.science_lab_procedure}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Safety"
value={shape.props.science_lab_safety}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Web Links"
value={shape.props.science_lab_weblinks}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Lab ID"
value={shape.props.science_lab_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,56 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil';
import { CCBaseShape } from '../cc-types';
import { NodeProperty } from './cc-graph-shared';
import { ccGraphShapeProps, getDefaultCCStudentNodeProps } from './cc-graph-props';
import { getNodeStyles } from './cc-graph-styles';
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles';
import { CCStudentNodeProps } from './cc-graph-types';
export interface CCStudentNodeShape extends CCBaseShape {
type: 'cc-student-node';
props: CCStudentNodeProps;
}
export class CCStudentNodeShapeUtil extends CCBaseShapeUtil<CCStudentNodeShape> {
static type = 'cc-student-node' as const;
static props = ccGraphShapeProps['cc-student-node'];
getDefaultProps(): CCStudentNodeShape['props'] {
const defaultProps = getDefaultCCStudentNodeProps();
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCStudentNodeShapeUtil.type]];
return {
...defaultProps,
headerColor: theme.headerColor,
};
}
// Override to nullify the default node component
DefaultComponent = () => null;
renderContent = (shape: CCStudentNodeShape) => {
const styles = getNodeStyles(shape.type);
return (
<div style={styles.container}>
<NodeProperty
label="Student Name"
value={shape.props.student_name_formal}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Student Code"
value={shape.props.student_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Email"
value={shape.props.student_email}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
);
};
}
@@ -0,0 +1,62 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCSubjectClassNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCSubjectClassNodeProps } from './cc-graph-types'
export interface CCSubjectClassNodeShape extends CCBaseShape {
type: 'cc-subject-class-node'
props: CCSubjectClassNodeProps
}
export class CCSubjectClassNodeShapeUtil extends CCBaseShapeUtil<CCSubjectClassNodeShape> {
static type = 'cc-subject-class-node' as const
static props = ccGraphShapeProps['cc-subject-class-node']
getDefaultProps(): CCSubjectClassNodeShape['props'] {
const defaultProps = getDefaultCCSubjectClassNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCSubjectClassNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCSubjectClassNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Class Code"
value={shape.props.subject_class_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Year Group"
value={shape.props.year_group}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject"
value={shape.props.subject}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject Code"
value={shape.props.subject_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,50 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCSubjectNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCSubjectNodeProps } from './cc-graph-types'
export interface CCSubjectNodeShape extends CCBaseShape {
type: 'cc-subject-node'
props: CCSubjectNodeProps
}
export class CCSubjectNodeShapeUtil extends CCBaseShapeUtil<CCSubjectNodeShape> {
static type = 'cc-subject-node' as const
static props = ccGraphShapeProps['cc-subject-node']
getDefaultProps(): CCSubjectNodeShape['props'] {
const defaultProps = getDefaultCCSubjectNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCSubjectNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCSubjectNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Subject Name"
value={shape.props.subject_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject Code"
value={shape.props.subject_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,80 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import {
DefaultNodeComponent,
NodeErrorDisplay,
checkShapeState,
checkDefaultComponent
} from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCTeacherNodeProps } from './cc-graph-props'
import { getNodeStyles, NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCTeacherNodeProps } from './cc-graph-types'
export interface CCTeacherNodeShape extends CCBaseShape {
type: 'cc-teacher-node'
props: CCTeacherNodeProps
}
export class CCTeacherNodeShapeUtil extends CCBaseShapeUtil<CCTeacherNodeShape> {
static type = 'cc-teacher-node' as const
static props = ccGraphShapeProps['cc-teacher-node']
getDefaultProps(): CCTeacherNodeShape['props'] {
const defaultProps = getDefaultCCTeacherNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTeacherNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor
}
}
renderContent = (shape: CCTeacherNodeShape) => {
const { props } = shape
const { state, defaultComponent } = props
const styles = getNodeStyles(shape.type)
const isPageChild = state?.isPageChild ?? true
// Check state and default component
const stateError = checkShapeState(state)
if (stateError.hasError) {
return <NodeErrorDisplay message={stateError.error.message} details={stateError.error.details} />
}
const defaultComponentError = checkDefaultComponent(defaultComponent)
if (defaultComponentError.hasError) {
return <NodeErrorDisplay message={defaultComponentError.error.message} details={defaultComponentError.error.details} />
}
if (isPageChild) {
// Define properties to show for page-level view
const properties = [
{ label: 'Teacher Name', value: props.teacher_name_formal },
{ label: 'Teacher Code', value: props.teacher_code },
{ label: 'Email', value: props.teacher_email },
{ label: 'Node Snapshot', value: props.tldraw_snapshot }
]
return (
<div style={styles.container}>
{defaultComponent && <DefaultNodeComponent tldraw_snapshot={props.tldraw_snapshot} />}
{properties.map((prop, index) => (
<div key={index} style={styles.property.wrapper}>
<span style={styles.property.label}>{prop.label}:</span>
<span style={styles.property.value}>{prop.value}</span>
</div>
))}
</div>
)
}
// Simplified view when child of another shape
return (
<div style={styles.container}>
<div style={styles.property.wrapper}>
<span style={styles.property.label}>Code:</span>
<span style={styles.property.value}>{props.teacher_code}</span>
</div>
</div>
)
}
}
@@ -0,0 +1,55 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCTeacherTimetableNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCTeacherTimetableNodeProps } from './cc-graph-types'
export interface CCTeacherTimetableNodeShape extends CCBaseShape {
type: 'cc-teacher-timetable-node'
props: CCTeacherTimetableNodeProps
}
export class CCTeacherTimetableNodeShapeUtil extends CCBaseShapeUtil<CCTeacherTimetableNodeShape> {
static type = 'cc-teacher-timetable-node' as const
static props = ccGraphShapeProps['cc-teacher-timetable-node']
getDefaultProps(): CCTeacherTimetableNodeShape['props'] {
const defaultProps = getDefaultCCTeacherTimetableNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTeacherTimetableNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCTeacherTimetableNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Teacher ID"
value={shape.props.teacher_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Date"
value={shape.props.start_date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Date"
value={shape.props.end_date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,68 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCTimetableLessonNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCTimetableLessonNodeProps } from './cc-graph-types'
export interface CCTimetableLessonNodeShape extends CCBaseShape {
type: 'cc-timetable-lesson-node'
props: CCTimetableLessonNodeProps
}
export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableLessonNodeShape> {
static type = 'cc-timetable-lesson-node' as const
static props = ccGraphShapeProps['cc-timetable-lesson-node']
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
const defaultProps = getDefaultCCTimetableLessonNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCTimetableLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Subject Class"
value={shape.props.subject_class}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,80 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCTopicLessonNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCTopicLessonNodeProps } from './cc-graph-types'
export interface CCTopicLessonNodeShape extends CCBaseShape {
type: 'cc-topic-lesson-node'
props: CCTopicLessonNodeProps
}
export class CCTopicLessonNodeShapeUtil extends CCBaseShapeUtil<CCTopicLessonNodeShape> {
static type = 'cc-topic-lesson-node' as const
static props = ccGraphShapeProps['cc-topic-lesson-node']
getDefaultProps(): CCTopicLessonNodeShape['props'] {
const defaultProps = getDefaultCCTopicLessonNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTopicLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCTopicLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Lesson Title"
value={shape.props.topic_lesson_title}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Lesson Type"
value={shape.props.topic_lesson_type}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Length"
value={shape.props.topic_lesson_length}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Skills Learned"
value={shape.props.topic_lesson_skills_learned}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Suggested Activities"
value={shape.props.topic_lesson_suggested_activities}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Web Links"
value={shape.props.topic_lesson_weblinks}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Lesson ID"
value={shape.props.topic_lesson_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,68 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCTopicNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCTopicNodeProps } from './cc-graph-types'
export interface CCTopicNodeShape extends CCBaseShape {
type: 'cc-topic-node'
props: CCTopicNodeProps
}
export class CCTopicNodeShapeUtil extends CCBaseShapeUtil<CCTopicNodeShape> {
static type = 'cc-topic-node' as const
static props = ccGraphShapeProps['cc-topic-node']
getDefaultProps(): CCTopicNodeShape['props'] {
const defaultProps = getDefaultCCTopicNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTopicNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCTopicNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Topic Title"
value={shape.props.topic_title}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Total Lessons"
value={shape.props.total_number_of_lessons_for_topic}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Topic Type"
value={shape.props.topic_type}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Assessment Type"
value={shape.props.topic_assessment_type}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Topic ID"
value={shape.props.topic_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,73 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import {
DefaultNodeComponent,
NodeErrorDisplay,
checkShapeState,
checkDefaultComponent
} from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCUserNodeProps } from './cc-graph-props'
import { CCUserNodeProps } from './cc-graph-types'
import { getNodeStyles, NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
export interface CCUserNodeShape extends CCBaseShape {
type: 'cc-user-node'
props: CCUserNodeProps
}
export class CCUserNodeShapeUtil extends CCBaseShapeUtil<CCUserNodeShape> {
static type = 'cc-user-node' as const
static props = ccGraphShapeProps['cc-user-node']
getDefaultProps(): CCUserNodeProps {
const defaultProps = getDefaultCCUserNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCUserNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
}
}
renderContent = (shape: CCUserNodeShape) => {
const { props } = shape
const { state, defaultComponent } = props
const styles = getNodeStyles(shape.type)
// Check state and default component
const stateError = checkShapeState(state)
if (stateError.hasError) {
return <NodeErrorDisplay message={stateError.error.message} details={stateError.error.details} />
}
const defaultComponentError = checkDefaultComponent(defaultComponent)
if (defaultComponentError.hasError) {
return <NodeErrorDisplay message={defaultComponentError.error.message} details={defaultComponentError.error.details} />
}
// Define properties to show based on view type
const properties = defaultComponent ? [
{ label: 'User Name', value: props.user_name },
{ label: 'User Email', value: props.user_email },
{ label: 'User Type', value: props.user_type },
{ label: 'User ID', value: props.user_id },
{ label: 'Node Snapshot', value: props.tldraw_snapshot },
{ label: 'Worker Node Data', value: props.worker_node_data }
] : [
{ label: 'User Name', value: props.user_name },
{ label: 'User Email', value: props.user_email }
]
return (
<div style={styles.container}>
{defaultComponent && <DefaultNodeComponent tldraw_snapshot={props.tldraw_snapshot} />}
{properties.map((prop, index) => (
<div key={index} style={styles.property.wrapper}>
<span style={styles.property.label}>{prop.label}:</span>
<span style={styles.property.value}>{prop.value}</span>
</div>
))}
</div>
)
}
}
@@ -0,0 +1,50 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCUserTeacherTimetableNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCUserTeacherTimetableNodeProps } from './cc-graph-types'
export interface CCUserTeacherTimetableNodeShape extends CCBaseShape {
type: 'cc-user-teacher-timetable-node'
props: CCUserTeacherTimetableNodeProps
}
export class CCUserTeacherTimetableNodeShapeUtil extends CCBaseShapeUtil<CCUserTeacherTimetableNodeShape> {
static type = 'cc-user-teacher-timetable-node' as const
static props = ccGraphShapeProps['cc-user-teacher-timetable-node']
getDefaultProps(): CCUserTeacherTimetableNodeShape['props'] {
const defaultProps = getDefaultCCUserTeacherTimetableNodeProps() as CCUserTeacherTimetableNodeShape['props']
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCUserTeacherTimetableNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCUserTeacherTimetableNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="School DB"
value={shape.props.school_db_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="School Timetable ID"
value={shape.props.school_timetable_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,80 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty, formatDate } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCUserTimetableLessonNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCUserTimetableLessonNodeProps } from './cc-graph-types'
export interface CCUserTimetableLessonNodeShape extends CCBaseShape {
type: 'cc-user-timetable-lesson-node'
props: CCUserTimetableLessonNodeProps
}
export class CCUserTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCUserTimetableLessonNodeShape> {
static type = 'cc-user-timetable-lesson-node' as const
static props = ccGraphShapeProps['cc-user-timetable-lesson-node']
getDefaultProps(): CCUserTimetableLessonNodeShape['props'] {
const defaultProps = getDefaultCCUserTimetableLessonNodeProps() as CCUserTimetableLessonNodeShape['props']
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCUserTimetableLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCUserTimetableLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Subject Class"
value={shape.props.subject_class}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Date"
value={formatDate(shape.props.date)}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Start Time"
value={formatDate(shape.props.start_time)}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="End Time"
value={formatDate(shape.props.end_time)}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="School DB"
value={shape.props.school_db_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="School Period ID"
value={shape.props.school_period_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,50 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCYearGroupNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCYearGroupNodeProps } from './cc-graph-types'
export interface CCYearGroupNodeShape extends CCBaseShape {
type: 'cc-year-group-node'
props: CCYearGroupNodeProps
}
export class CCYearGroupNodeShapeUtil extends CCBaseShapeUtil<CCYearGroupNodeShape> {
static type = 'cc-year-group-node' as const
static props = ccGraphShapeProps['cc-year-group-node']
getDefaultProps(): CCYearGroupNodeShape['props'] {
const defaultProps = getDefaultCCYearGroupNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCYearGroupNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCYearGroupNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Year Group"
value={shape.props.year_group}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Year Group Name"
value={shape.props.year_group_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,68 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { NodeProperty } from './cc-graph-shared'
import { ccGraphShapeProps, getDefaultCCYearGroupSyllabusNodeProps } from './cc-graph-props'
import { getNodeStyles } from './cc-graph-styles'
import { NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
import { CCYearGroupSyllabusNodeProps } from './cc-graph-types'
export interface CCYearGroupSyllabusNodeShape extends CCBaseShape {
type: 'cc-year-group-syllabus-node'
props: CCYearGroupSyllabusNodeProps
}
export class CCYearGroupSyllabusNodeShapeUtil extends CCBaseShapeUtil<CCYearGroupSyllabusNodeShape> {
static type = 'cc-year-group-syllabus-node' as const
static props = ccGraphShapeProps['cc-year-group-syllabus-node']
getDefaultProps(): CCYearGroupSyllabusNodeShape['props'] {
const defaultProps = getDefaultCCYearGroupSyllabusNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCYearGroupSyllabusNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
}
}
// Override to nullify the default node component
DefaultComponent = () => null
renderContent = (shape: CCYearGroupSyllabusNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
label="Syllabus Name"
value={shape.props.yr_syllabus_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Year Group"
value={shape.props.yr_syllabus_year_group}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject"
value={shape.props.yr_syllabus_subject}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Subject Code"
value={shape.props.yr_syllabus_subject_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Syllabus ID"
value={shape.props.yr_syllabus_id}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
</div>
)
}
}
@@ -0,0 +1,32 @@
import { ccGraphShapeProps } from './cc-graph-props'
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '@tldraw/tldraw'
import { CCGraphShape, GraphShapeType } from './cc-graph-types'
// Helper function to create version IDs for a shape type
const createVersions = (shapeType: GraphShapeType) => {
return createShapePropsMigrationIds(shapeType, {
Initial: 1 // All shapes start at version 1 as required by TLDraw
})
}
// Helper function to create a migration sequence for a shape
const createMigrationSequence = (shapeType: GraphShapeType) => {
const versions = createVersions(shapeType)
return createShapePropsMigrationSequence({
sequence: [
{
id: versions.Initial,
up: (props: CCGraphShape['props']) => {
// Initial version - no changes needed
return props
},
},
],
})
}
// Create migrations for all graph shapes
export const ccGraphMigrations = Object.keys(ccGraphShapeProps).reduce((acc, shapeType) => ({
...acc,
[shapeType]: createMigrationSequence(shapeType as GraphShapeType)
}), {} as Record<GraphShapeType, ReturnType<typeof createShapePropsMigrationSequence>>)
@@ -0,0 +1,657 @@
import { T, TLBinding, TLShapeId } from '@tldraw/tldraw'
import { baseShapeProps } from '../cc-props'
import { ShapeState } from './cc-graph-types'
// State props validation
const stateProps = T.object({
parentId: T.optional(T.string.nullable()),
isPageChild: T.optional(T.boolean.nullable()),
hasChildren: T.optional(T.boolean.nullable()),
bindings: T.optional(T.arrayOf(T.object({})).nullable())
})
// Base props for all nodes
const graphBaseProps = {
...baseShapeProps,
__primarylabel__: T.string,
unique_id: T.string,
tldraw_snapshot: T.string,
created: T.string,
merged: T.string,
state: T.optional(stateProps.nullable()),
defaultComponent: T.optional(T.boolean.nullable())
}
// Props for specific node types
export const ccGraphShapeProps = {
'cc-user-node': {
...graphBaseProps,
user_name: T.string,
user_email: T.string,
user_type: T.string,
user_id: T.string,
worker_node_data: T.string,
},
'cc-teacher-node': {
...graphBaseProps,
teacher_code: T.string,
teacher_name_formal: T.string,
teacher_email: T.string,
user_db_name: T.string,
worker_db_name: T.string,
},
'cc-student-node': {
...graphBaseProps,
student_code: T.string,
student_name_formal: T.string,
student_email: T.string,
worker_db_name: T.string,
},
'cc-calendar-node': {
...graphBaseProps,
name: T.string,
calendar_type: T.string,
calendar_name: T.string,
start_date: T.string,
end_date: T.string,
},
'cc-calendar-year-node': {
...graphBaseProps,
year: T.string,
},
'cc-calendar-month-node': {
...graphBaseProps,
year: T.string,
month: T.string,
month_name: T.string,
},
'cc-calendar-week-node': {
...graphBaseProps,
start_date: T.string,
week_number: T.string,
iso_week: T.string,
},
'cc-calendar-day-node': {
...graphBaseProps,
date: T.string,
day_of_week: T.string,
iso_day: T.string,
},
'cc-calendar-time-chunk-node': {
...graphBaseProps,
start_time: T.string,
end_time: T.string,
},
'cc-school-node': {
...graphBaseProps,
school_uuid: T.string,
school_name: T.string,
school_website: T.string,
},
'cc-department-node': {
...graphBaseProps,
department_name: T.string,
},
'cc-room-node': {
...graphBaseProps,
room_code: T.string,
room_name: T.string,
},
'cc-subject-class-node': {
...graphBaseProps,
subject_class_code: T.string,
year_group: T.string,
subject: T.string,
subject_code: T.string,
},
'cc-pastoral-structure-node': {
...graphBaseProps,
},
'cc-year-group-node': {
...graphBaseProps,
year_group: T.string,
year_group_name: T.string,
},
'cc-curriculum-structure-node': {
...graphBaseProps,
},
'cc-key-stage-node': {
...graphBaseProps,
key_stage_name: T.string,
key_stage: T.string,
},
'cc-key-stage-syllabus-node': {
...graphBaseProps,
ks_syllabus_id: T.string,
ks_syllabus_name: T.string,
ks_syllabus_key_stage: T.string,
ks_syllabus_subject: T.string,
ks_syllabus_subject_code: T.string,
},
'cc-year-group-syllabus-node': {
...graphBaseProps,
yr_syllabus_id: T.string,
yr_syllabus_name: T.string,
yr_syllabus_year_group: T.string,
yr_syllabus_subject: T.string,
yr_syllabus_subject_code: T.string,
},
'cc-subject-node': {
...graphBaseProps,
subject_code: T.string,
subject_name: T.string,
},
'cc-topic-node': {
...graphBaseProps,
topic_id: T.string,
topic_title: T.string,
total_number_of_lessons_for_topic: T.string,
topic_type: T.string,
topic_assessment_type: T.string,
},
'cc-topic-lesson-node': {
...graphBaseProps,
topic_lesson_id: T.string,
topic_lesson_title: T.string,
topic_lesson_type: T.string,
topic_lesson_length: T.string,
topic_lesson_skills_learned: T.string,
topic_lesson_suggested_activities: T.string,
topic_lesson_weblinks: T.string,
},
'cc-learning-statement-node': {
...graphBaseProps,
lesson_learning_statement_id: T.string,
lesson_learning_statement: T.string,
lesson_learning_statement_type: T.string,
},
'cc-science-lab-node': {
...graphBaseProps,
science_lab_id: T.string,
science_lab_title: T.string,
science_lab_summary: T.string,
science_lab_requirements: T.string,
science_lab_procedure: T.string,
science_lab_safety: T.string,
science_lab_weblinks: T.string,
},
'cc-teacher-timetable-node': {
...graphBaseProps,
teacher_id: T.string,
start_date: T.string,
end_date: T.string,
},
'cc-timetable-lesson-node': {
...graphBaseProps,
subject_class: T.string,
date: T.string,
start_time: T.string,
end_time: T.string,
period_code: T.string,
},
'cc-planned-lesson-node': {
...graphBaseProps,
date: T.string,
start_time: T.string,
end_time: T.string,
period_code: T.string,
subject_class: T.string,
year_group: T.string,
subject: T.string,
teacher_code: T.string,
planning_status: T.string,
topic_code: T.string,
topic_name: T.string,
lesson_code: T.string,
lesson_name: T.string,
learning_statement_codes: T.string,
learning_statements: T.string,
learning_resource_codes: T.string,
learning_resources: T.string,
},
'cc-school-timetable-node': {
...graphBaseProps,
start_date: T.string,
end_date: T.string,
},
'cc-academic-year-node': {
...graphBaseProps,
year: T.string,
},
'cc-academic-term-node': {
...graphBaseProps,
term_name: T.string,
term_number: T.string,
start_date: T.string,
end_date: T.string,
},
'cc-academic-week-node': {
...graphBaseProps,
academic_week_number: T.string,
start_date: T.string,
week_type: T.string,
},
'cc-academic-day-node': {
...graphBaseProps,
academic_day: T.string,
date: T.string,
day_of_week: T.string,
day_type: T.string,
},
'cc-academic-period-node': {
...graphBaseProps,
name: T.string,
date: T.string,
start_time: T.string,
end_time: T.string,
period_code: T.string,
},
'cc-registration-period-node': {
...graphBaseProps,
name: T.string,
date: T.string,
start_time: T.string,
end_time: T.string,
period_code: T.string,
},
'cc-department-structure-node': {
...graphBaseProps,
department_structure_type: T.string,
},
'cc-user-teacher-timetable-node': {
...graphBaseProps,
school_db_name: T.string,
school_timetable_id: T.string,
},
'cc-user-timetable-lesson-node': {
...graphBaseProps,
subject_class: T.string,
date: T.string,
start_time: T.string,
end_time: T.string,
period_code: T.string,
school_db_name: T.string,
school_period_id: T.string,
},
} as const
// Default props getters
export const getDefaultBaseProps = () => ({
w: 200 as number,
h: 200 as number,
headerColor: '#3e6589' as string,
backgroundColor: '#f0f0f0' as string,
title: 'Untitled' as string,
isLocked: false as boolean,
unique_id: '' as string,
tldraw_snapshot: '' as string,
created: '' as string,
merged: '' as string,
state: {
parentId: null as TLShapeId | null,
isPageChild: true as boolean | null,
hasChildren: null as boolean | null,
bindings: null as TLBinding[] | null
} as ShapeState | null,
defaultComponent: true as boolean | null
})
export const getDefaultCCUserNodeProps = () => ({
...getDefaultBaseProps(),
__primarylabel__: 'User',
user_name: '',
user_email: '',
user_type: '',
user_id: '',
worker_node_data: ''
})
export const getDefaultCCTeacherNodeProps = () => ({
...getDefaultBaseProps(),
__primarylabel__: 'Teacher',
teacher_code: '',
teacher_name_formal: '',
teacher_email: '',
user_db_name: '',
school_db_name: '',
})
export const getDefaultCCStudentNodeProps = () => ({
...getDefaultBaseProps(),
__primarylabel__: 'Student',
student_code: '',
student_name_formal: '',
student_email: '',
school_db_name: '',
user_db_name: '',
})
export const getDefaultCCCalendarNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Calendar',
__primarylabel__: 'Calendar',
name: '',
calendar_type: '',
calendar_name: '',
start_date: '',
end_date: '',
})
export const getDefaultCCCalendarYearNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Calendar Year',
__primarylabel__: 'Calendar Year',
year: '',
})
export const getDefaultCCCalendarMonthNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Calendar Month',
__primarylabel__: 'Calendar Month',
year: '',
month: '',
month_name: '',
})
export const getDefaultCCCalendarWeekNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Calendar Week',
__primarylabel__: 'Calendar Week',
start_date: '',
week_number: '',
iso_week: '',
})
export const getDefaultCCCalendarDayNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Calendar Day',
__primarylabel__: 'Calendar Day',
date: '',
day_of_week: '',
iso_day: '',
})
export const getDefaultCCCalendarTimeChunkNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Calendar Time Chunk',
__primarylabel__: 'Calendar Time Chunk',
start_time: '',
end_time: '',
})
export const getDefaultCCSchoolNodeProps = () => ({
...getDefaultBaseProps(),
title: 'School',
__primarylabel__: 'School',
school_uuid: '',
school_name: '',
school_website: '',
})
export const getDefaultCCDepartmentNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Department',
__primarylabel__: 'Department',
department_name: '',
})
export const getDefaultCCRoomNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Room',
__primarylabel__: 'Room',
room_code: '',
room_name: '',
})
export const getDefaultCCSubjectClassNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Subject Class',
__primarylabel__: 'Subject Class',
subject_class_code: '',
year_group: '',
subject: '',
subject_code: '',
})
export const getDefaultCCPastoralStructureNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Pastoral Structure',
__primarylabel__: 'Pastoral Structure',
pastoral_structure_type: '',
})
export const getDefaultCCYearGroupNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Year Group',
__primarylabel__: 'Year Group',
year_group: '',
year_group_name: '',
})
export const getDefaultCCCurriculumStructureNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Curriculum Structure',
__primarylabel__: 'Curriculum Structure',
curriculum_structure_type: '',
})
export const getDefaultCCKeyStageNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Key Stage',
__primarylabel__: 'Key Stage',
key_stage_name: '',
key_stage: '',
})
export const getDefaultCCKeyStageSyllabusNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Key Stage Syllabus',
__primarylabel__: 'Key Stage Syllabus',
ks_syllabus_id: '',
ks_syllabus_name: '',
ks_syllabus_key_stage: '',
ks_syllabus_subject: '',
ks_syllabus_subject_code: '',
})
export const getDefaultCCYearGroupSyllabusNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Year Group Syllabus',
__primarylabel__: 'Year Group Syllabus',
yr_syllabus_id: '',
yr_syllabus_name: '',
yr_syllabus_year_group: '',
yr_syllabus_subject: '',
yr_syllabus_subject_code: '',
})
export const getDefaultCCSubjectNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Subject',
__primarylabel__: 'Subject',
subject_code: '',
subject_name: '',
})
export const getDefaultCCTopicNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Topic',
__primarylabel__: 'Topic',
topic_id: '',
topic_title: '',
total_number_of_lessons_for_topic: '',
topic_type: '',
topic_assessment_type: '',
})
export const getDefaultCCTopicLessonNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Topic Lesson',
__primarylabel__: 'Topic Lesson',
topic_lesson_id: '',
topic_lesson_title: '',
topic_lesson_type: '',
topic_lesson_length: '',
topic_lesson_skills_learned: '',
topic_lesson_suggested_activities: '',
topic_lesson_weblinks: '',
})
export const getDefaultCCLearningStatementNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Learning Statement',
__primarylabel__: 'Learning Statement',
lesson_learning_statement_id: '',
lesson_learning_statement: '',
lesson_learning_statement_type: '',
})
export const getDefaultCCScienceLabNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Science Lab',
__primarylabel__: 'Science Lab',
science_lab_id: '',
science_lab_title: '',
science_lab_summary: '',
science_lab_requirements: '',
science_lab_procedure: '',
science_lab_safety: '',
science_lab_weblinks: '',
})
export const getDefaultCCTeacherTimetableNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Teacher Timetable',
__primarylabel__: 'Teacher Timetable',
teacher_id: '',
start_date: '',
end_date: '',
})
export const getDefaultCCTimetableLessonNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Timetable Lesson',
__primarylabel__: 'Timetable Lesson',
subject_class: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
})
export const getDefaultCCPlannedLessonNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Planned Lesson',
__primarylabel__: 'Planned Lesson',
date: '',
start_time: '',
end_time: '',
period_code: '',
subject_class: '',
year_group: '',
subject: '',
teacher_code: '',
planning_status: '',
topic_code: '',
topic_name: '',
lesson_code: '',
lesson_name: '',
learning_statement_codes: '',
learning_statements: '',
learning_resource_codes: '',
learning_resources: '',
})
export const getDefaultCCSchoolTimetableNodeProps = () => ({
...getDefaultBaseProps(),
title: 'School Timetable',
__primarylabel__: 'School Timetable',
start_date: '',
end_date: '',
})
export const getDefaultCCAcademicYearNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Academic Year',
__primarylabel__: 'Academic Year',
year: '',
})
export const getDefaultCCAcademicTermNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Academic Term',
__primarylabel__: 'Academic Term',
term_name: '',
term_number: '',
start_date: '',
end_date: '',
})
export const getDefaultCCAcademicWeekNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Academic Week',
__primarylabel__: 'Academic Week',
academic_week_number: '',
start_date: '',
week_type: '',
})
export const getDefaultCCAcademicDayNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Academic Day',
__primarylabel__: 'Academic Day',
academic_day: '',
date: '',
day_of_week: '',
day_type: '',
})
export const getDefaultCCAcademicPeriodNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Academic Period',
__primarylabel__: 'Academic Period',
name: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
})
export const getDefaultCCRegistrationPeriodNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Registration Period',
__primarylabel__: 'Registration Period',
name: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
})
export const getDefaultCCDepartmentStructureNodeProps = () => ({
...getDefaultBaseProps(),
title: 'Department Structure',
__primarylabel__: 'DepartmentStructure',
department_structure_type: '',
})
export const getDefaultCCUserTeacherTimetableNodeProps = () => ({
...getDefaultBaseProps(),
title: 'User Teacher Timetable',
__primarylabel__: 'UserTeacherTimetable',
school_db_name: '',
school_timetable_id: '',
})
export const getDefaultCCUserTimetableLessonNodeProps = () => ({
...getDefaultBaseProps(),
title: 'User Timetable Lesson',
__primarylabel__: 'UserTimetableLesson',
subject_class: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
school_db_name: '',
school_period_id: '',
})
@@ -0,0 +1,175 @@
import { TLShape } from '@tldraw/tldraw'
import { CCUserNodeShape, CCUserNodeShapeUtil } from './CCUserNodeShapeUtil'
import { CCTeacherNodeShape, CCTeacherNodeShapeUtil } from './CCTeacherNodeShapeUtil'
import { CCStudentNodeShape, CCStudentNodeShapeUtil } from './CCStudentNodeShapeUtil'
import { CCCalendarNodeShape, CCCalendarNodeShapeUtil } from './CCCalendarNodeShapeUtil'
import { CCCalendarYearNodeShape, CCCalendarYearNodeShapeUtil } from './CCCalendarYearNodeShapeUtil'
import { CCCalendarMonthNodeShape, CCCalendarMonthNodeShapeUtil } from './CCCalendarMonthNodeShapeUtil'
import { CCCalendarWeekNodeShape, CCCalendarWeekNodeShapeUtil } from './CCCalendarWeekNodeShapeUtil'
import { CCCalendarDayNodeShape, CCCalendarDayNodeShapeUtil } from './CCCalendarDayNodeShapeUtil'
import { CCCalendarTimeChunkNodeShape, CCCalendarTimeChunkNodeShapeUtil } from './CCCalendarTimeChunkNodeShapeUtil'
import { CCSchoolNodeShape, CCSchoolNodeShapeUtil } from './CCSchoolNodeShapeUtil'
import { CCDepartmentNodeShape, CCDepartmentNodeShapeUtil } from './CCDepartmentNodeShapeUtil'
import { CCRoomNodeShape, CCRoomNodeShapeUtil } from './CCRoomNodeShapeUtil'
import { CCSubjectClassNodeShape, CCSubjectClassNodeShapeUtil } from './CCSubjectClassNodeShapeUtil'
import { CCPastoralStructureNodeShape, CCPastoralStructureNodeShapeUtil } from './CCPastoralStructureNodeShapeUtil'
import { CCYearGroupNodeShape, CCYearGroupNodeShapeUtil } from './CCYearGroupNodeShapeUtil'
import { CCCurriculumStructureNodeShape, CCCurriculumStructureNodeShapeUtil } from './CCCurriculumStructureNodeShapeUtil'
import { CCKeyStageNodeShape, CCKeyStageNodeShapeUtil } from './CCKeyStageNodeShapeUtil'
import { CCKeyStageSyllabusNodeShape, CCKeyStageSyllabusNodeShapeUtil } from './CCKeyStageSyllabusNodeShapeUtil'
import { CCYearGroupSyllabusNodeShape, CCYearGroupSyllabusNodeShapeUtil } from './CCYearGroupSyllabusNodeShapeUtil'
import { CCSubjectNodeShape, CCSubjectNodeShapeUtil } from './CCSubjectNodeShapeUtil'
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
import { CCAcademicWeekNodeShape, CCAcademicWeekNodeShapeUtil } from './CCAcademicWeekNodeShapeUtil'
import { CCAcademicDayNodeShape, CCAcademicDayNodeShapeUtil } from './CCAcademicDayNodeShapeUtil'
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
import { CCUserTimetableLessonNodeShape, CCUserTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
// Create a const object with all node types
export const NODE_SHAPE_TYPES = {
USER: CCUserNodeShapeUtil.type,
TEACHER: CCTeacherNodeShapeUtil.type,
STUDENT: CCStudentNodeShapeUtil.type,
CALENDAR: CCCalendarNodeShapeUtil.type,
CALENDAR_YEAR: CCCalendarYearNodeShapeUtil.type,
CALENDAR_MONTH: CCCalendarMonthNodeShapeUtil.type,
CALENDAR_WEEK: CCCalendarWeekNodeShapeUtil.type,
CALENDAR_DAY: CCCalendarDayNodeShapeUtil.type,
CALENDAR_TIME_CHUNK: CCCalendarTimeChunkNodeShapeUtil.type,
SCHOOL: CCSchoolNodeShapeUtil.type,
DEPARTMENT: CCDepartmentNodeShapeUtil.type,
ROOM: CCRoomNodeShapeUtil.type,
SUBJECT_CLASS: CCSubjectClassNodeShapeUtil.type,
PASTORAL_STRUCTURE: CCPastoralStructureNodeShapeUtil.type,
YEAR_GROUP: CCYearGroupNodeShapeUtil.type,
CURRICULUM_STRUCTURE: CCCurriculumStructureNodeShapeUtil.type,
KEY_STAGE: CCKeyStageNodeShapeUtil.type,
KEY_STAGE_SYLLABUS: CCKeyStageSyllabusNodeShapeUtil.type,
YEAR_GROUP_SYLLABUS: CCYearGroupSyllabusNodeShapeUtil.type,
SUBJECT: CCSubjectNodeShapeUtil.type,
TOPIC: CCTopicNodeShapeUtil.type,
TOPIC_LESSON: CCTopicLessonNodeShapeUtil.type,
LEARNING_STATEMENT: CCLearningStatementNodeShapeUtil.type,
SCIENCE_LAB: CCScienceLabNodeShapeUtil.type,
SCHOOL_TIMETABLE: CCSchoolTimetableNodeShapeUtil.type,
ACADEMIC_YEAR: CCAcademicYearNodeShapeUtil.type,
ACADEMIC_TERM: CCAcademicTermNodeShapeUtil.type,
ACADEMIC_WEEK: CCAcademicWeekNodeShapeUtil.type,
ACADEMIC_DAY: CCAcademicDayNodeShapeUtil.type,
ACADEMIC_PERIOD: CCAcademicPeriodNodeShapeUtil.type,
REGISTRATION_PERIOD: CCRegistrationPeriodNodeShapeUtil.type,
TEACHER_TIMETABLE: CCTeacherTimetableNodeShapeUtil.type,
TIMETABLE_LESSON: CCTimetableLessonNodeShapeUtil.type,
PLANNED_LESSON: CCPlannedLessonNodeShapeUtil.type,
DEPARTMENT_STRUCTURE: CCDepartmentStructureNodeShapeUtil.type,
USER_TEACHER_TIMETABLE: CCUserTeacherTimetableNodeShapeUtil.type,
USER_TIMETABLE_LESSON: CCUserTimetableLessonNodeShapeUtil.type,
} as const;
// Create the type from the const object's values
export type NodeShapeType = typeof NODE_SHAPE_TYPES[keyof typeof NODE_SHAPE_TYPES];
// Define AllNodeShapes as a union type of all shape types
export type AllNodeShapes =
| CCUserNodeShape
| CCTeacherNodeShape
| CCStudentNodeShape
| CCCalendarNodeShape
| CCCalendarYearNodeShape
| CCCalendarMonthNodeShape
| CCCalendarWeekNodeShape
| CCCalendarDayNodeShape
| CCCalendarTimeChunkNodeShape
| CCSchoolNodeShape
| CCDepartmentNodeShape
| CCRoomNodeShape
| CCSubjectClassNodeShape
| CCPastoralStructureNodeShape
| CCYearGroupNodeShape
| CCCurriculumStructureNodeShape
| CCKeyStageNodeShape
| CCKeyStageSyllabusNodeShape
| CCYearGroupSyllabusNodeShape
| CCSubjectNodeShape
| CCTopicNodeShape
| CCTopicLessonNodeShape
| CCLearningStatementNodeShape
| CCScienceLabNodeShape
| CCSchoolTimetableNodeShape
| CCAcademicYearNodeShape
| CCAcademicTermNodeShape
| CCAcademicWeekNodeShape
| CCAcademicDayNodeShape
| CCAcademicPeriodNodeShape
| CCRegistrationPeriodNodeShape
| CCTeacherTimetableNodeShape
| CCTimetableLessonNodeShape
| CCPlannedLessonNodeShape
| CCDepartmentStructureNodeShape
| CCUserTeacherTimetableNodeShape
| CCUserTimetableLessonNodeShape;
// Export all shape utils in an object for easy access
export const ShapeUtils = {
[CCUserNodeShapeUtil.type]: CCUserNodeShapeUtil,
[CCTeacherNodeShapeUtil.type]: CCTeacherNodeShapeUtil,
[CCStudentNodeShapeUtil.type]: CCStudentNodeShapeUtil,
[CCCalendarNodeShapeUtil.type]: CCCalendarNodeShapeUtil,
[CCCalendarYearNodeShapeUtil.type]: CCCalendarYearNodeShapeUtil,
[CCCalendarMonthNodeShapeUtil.type]: CCCalendarMonthNodeShapeUtil,
[CCCalendarWeekNodeShapeUtil.type]: CCCalendarWeekNodeShapeUtil,
[CCCalendarDayNodeShapeUtil.type]: CCCalendarDayNodeShapeUtil,
[CCCalendarTimeChunkNodeShapeUtil.type]: CCCalendarTimeChunkNodeShapeUtil,
[CCSchoolNodeShapeUtil.type]: CCSchoolNodeShapeUtil,
[CCDepartmentNodeShapeUtil.type]: CCDepartmentNodeShapeUtil,
[CCRoomNodeShapeUtil.type]: CCRoomNodeShapeUtil,
[CCSubjectClassNodeShapeUtil.type]: CCSubjectClassNodeShapeUtil,
[CCPastoralStructureNodeShapeUtil.type]: CCPastoralStructureNodeShapeUtil,
[CCYearGroupNodeShapeUtil.type]: CCYearGroupNodeShapeUtil,
[CCCurriculumStructureNodeShapeUtil.type]: CCCurriculumStructureNodeShapeUtil,
[CCKeyStageNodeShapeUtil.type]: CCKeyStageNodeShapeUtil,
[CCKeyStageSyllabusNodeShapeUtil.type]: CCKeyStageSyllabusNodeShapeUtil,
[CCYearGroupSyllabusNodeShapeUtil.type]: CCYearGroupSyllabusNodeShapeUtil,
[CCSubjectNodeShapeUtil.type]: CCSubjectNodeShapeUtil,
[CCTopicNodeShapeUtil.type]: CCTopicNodeShapeUtil,
[CCTopicLessonNodeShapeUtil.type]: CCTopicLessonNodeShapeUtil,
[CCLearningStatementNodeShapeUtil.type]: CCLearningStatementNodeShapeUtil,
[CCScienceLabNodeShapeUtil.type]: CCScienceLabNodeShapeUtil,
[CCSchoolTimetableNodeShapeUtil.type]: CCSchoolTimetableNodeShapeUtil,
[CCAcademicYearNodeShapeUtil.type]: CCAcademicYearNodeShapeUtil,
[CCAcademicTermNodeShapeUtil.type]: CCAcademicTermNodeShapeUtil,
[CCAcademicWeekNodeShapeUtil.type]: CCAcademicWeekNodeShapeUtil,
[CCAcademicDayNodeShapeUtil.type]: CCAcademicDayNodeShapeUtil,
[CCAcademicPeriodNodeShapeUtil.type]: CCAcademicPeriodNodeShapeUtil,
[CCRegistrationPeriodNodeShapeUtil.type]: CCRegistrationPeriodNodeShapeUtil,
[CCTeacherTimetableNodeShapeUtil.type]: CCTeacherTimetableNodeShapeUtil,
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
[CCUserTimetableLessonNodeShapeUtil.type]: CCUserTimetableLessonNodeShapeUtil,
} as const;
// Add a type guard to check if a shape is a valid node shape
export const isValidNodeShape = (shape: TLShape): shape is AllNodeShapes => {
return shape &&
typeof shape.type === 'string' &&
Object.values(NODE_SHAPE_TYPES).includes(shape.type as NodeShapeType);
};
// Add a type guard to check if a type string is a valid node type
export const isValidNodeType = (type: string): type is NodeShapeType => {
return Object.values(NODE_SHAPE_TYPES).includes(type as NodeShapeType);
};
@@ -0,0 +1,236 @@
import React, { CSSProperties } from 'react';
import { ShapeState } from './cc-graph-types'
import { SHARED_NODE_STYLES } from './cc-graph-styles'
import { logger } from '../../../../debugConfig'
interface Neo4jDate {
_Date__year: number;
_Date__month: number;
_Date__day: number;
_Date__ordinal?: number;
}
interface Neo4jTime {
_Time__hour: number;
_Time__minute: number;
_Time__second: number;
_Time__nanosecond: number;
_Time__ticks: number;
_Time__tzinfo: null;
}
interface Neo4jDateTime {
_DateTime__date: Neo4jDate;
_DateTime__time: Neo4jTime;
}
export type DateValue = string | Date | Neo4jDate | Neo4jDateTime | null | undefined;
const isValidDate = (year: number | undefined, month: number | undefined, day: number | undefined): boolean => {
if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') return false;
if (month < 1 || month > 12 || day < -31 || day > 31) return false;
// Handle special case for end of month markers
if (day < 0) return true;
const date = new Date(year, month - 1, day);
return date.getMonth() === month - 1 && date.getDate() === day;
};
// Date formatting utility for Neo4j date objects and other formats
export const formatDate = (dateValue: DateValue): string => {
if (!dateValue) return '';
// Handle string dates
if (typeof dateValue === 'string') return dateValue;
// Handle standard Date objects
if (dateValue instanceof Date) {
return dateValue.toISOString().split('T')[0];
}
// Handle Neo4j DateTime objects
if (typeof dateValue === 'object' && '_DateTime__date' in dateValue) {
const nestedDate = dateValue._DateTime__date as Neo4jDate;
return formatDate(nestedDate);
}
// Handle Neo4j Date objects and raw date objects
if (typeof dateValue === 'object' && '_Date__year' in dateValue) {
const year = dateValue._Date__year;
const month = dateValue._Date__month;
const day = dateValue._Date__day;
if (isValidDate(year, month, day)) {
try {
// Handle negative days (end of month markers)
if (typeof day === 'number' && day < 0 && typeof year === 'number' && typeof month === 'number') {
const nextMonth = new Date(year, month, 1);
nextMonth.setDate(nextMonth.getDate() - 1);
return nextMonth.toISOString().split('T')[0];
}
if (typeof year === 'number' && typeof month === 'number' && typeof day === 'number') {
const date = new Date(year, month - 1, day);
return date.toISOString().split('T')[0];
}
} catch {
logger.warn('graph-shape-shared', '⚠️ Failed to format Neo4j date', { dateValue });
}
}
return 'Invalid Date';
}
// For any other object, try to convert it
try {
const date = new Date(String(dateValue));
if (!isNaN(date.getTime())) {
return date.toISOString().split('T')[0];
}
} catch {
logger.warn('graph-shape-shared', '⚠️ Failed to format unknown date type', { dateValue });
}
return 'Invalid Date';
}
// Error display component for nodes
interface NodeErrorDisplayProps {
message: string;
details?: string;
style?: CSSProperties;
}
interface ErrorResult {
hasError: true;
error: {
message: string;
details: string;
};
}
interface SuccessResult {
hasError: false;
}
type ValidationResult = ErrorResult | SuccessResult;
export const NodeErrorDisplay: React.FC<NodeErrorDisplayProps> = ({
message,
details,
style
}) => (
<div style={{ ...SHARED_NODE_STYLES.error.container, ...style }}>
<div style={SHARED_NODE_STYLES.error.message}>{message}</div>
{details && <div style={SHARED_NODE_STYLES.error.details}>{details}</div>}
</div>
);
// Error checking utilities
export const checkShapeState = (state: ShapeState | undefined | null): ValidationResult => {
if (!state) {
logger.warn('graph-shape-shared', '⚠️ Missing shape state', { state })
return {
hasError: true,
error: {
message: "Invalid Shape State",
details: "Shape state is missing or undefined"
}
}
}
if (state.isPageChild === null) {
logger.warn('graph-shape-shared', '⚠️ Invalid page child state', { state })
return {
hasError: true,
error: {
message: "Invalid Page State",
details: "Unable to determine if node is a page child"
}
}
}
if (state.isPageChild === false && state.parentId === null) {
logger.warn('graph-shape-shared', '⚠️ Shape child with no parent', { state })
return {
hasError: true,
error: {
message: "Invalid Shape Child State",
details: "Shape child with no parent"
}
}
}
return { hasError: false }
}
export const checkDefaultComponent = (defaultComponent: boolean | { action: { label: string; handler: () => void } } | undefined | null): ValidationResult => {
if (defaultComponent === null) {
logger.warn('graph-shape-shared', '⚠️ Invalid default component', { defaultComponent })
return {
hasError: true,
error: {
message: "Invalid Default Component",
details: "Default component is not set"
}
}
}
return { hasError: false }
}
// Base component for all graph nodes
interface DefaultNodeComponentProps {
tldraw_snapshot: string
onInspect?: (tldraw_snapshot: string) => void
customAction?: {
label: string
handler: () => void
}
}
export const DefaultNodeComponent: React.FC<DefaultNodeComponentProps> = ({
tldraw_snapshot,
onInspect = () => console.log(`Inspecting node at path: ${tldraw_snapshot}`),
customAction
}) => {
return (
<div style={SHARED_NODE_STYLES.defaultComponent.container}>
<button style={SHARED_NODE_STYLES.defaultComponent.button} onClick={() => onInspect(tldraw_snapshot)}>
Inspect
</button>
{customAction && (
<button style={SHARED_NODE_STYLES.defaultComponent.button} onClick={customAction.handler}>
{customAction.label}
</button>
)}
</div>
)
}
// Helper function to create a node content wrapper
export const NodeContentWrapper = ({
children,
style
}: {
children: React.ReactNode;
style: CSSProperties;
}) => (
<div style={{ ...SHARED_NODE_STYLES.container, ...style }}>
{children}
</div>
);
// Helper function to create a node property display
export const NodeProperty = ({
label,
value,
labelStyle,
valueStyle
}: {
label: string;
value: string;
labelStyle: CSSProperties;
valueStyle: CSSProperties;
}) => (
<div style={SHARED_NODE_STYLES.property.wrapper}>
<span style={{ ...SHARED_NODE_STYLES.property.label, ...labelStyle }}>{label}:</span>
<span style={{ ...SHARED_NODE_STYLES.property.value, ...valueStyle }}>{value}</span>
</div>
);
@@ -0,0 +1,176 @@
// Shared styles for all nodes
export const SHARED_NODE_STYLES = {
container: {
display: 'flex',
flexDirection: 'column' as const,
padding: '8px',
gap: '4px',
backgroundColor: 'var(--color-muted)',
color: 'var(--color-text)',
borderRadius: '4px',
minWidth: '150px',
},
header: {
fontSize: '14px',
fontWeight: 'bold' as const,
marginBottom: '4px',
color: 'var(--color-text)',
},
property: {
label: {
fontSize: '12px',
color: 'var(--color-text-2)',
marginRight: '4px',
fontWeight: '500' as const,
},
value: {
fontSize: '12px',
color: 'var(--color-text)',
fontWeight: '200' as const,
},
wrapper: {
display: 'flex',
alignItems: 'center',
gap: '4px',
},
},
error: {
container: {
padding: '8px',
backgroundColor: 'var(--color-error)',
color: 'white',
borderRadius: '4px',
fontSize: '12px',
},
message: {
fontWeight: 'bold' as const,
},
details: {
marginTop: '4px',
opacity: 0.8,
}
},
defaultComponent: {
container: {
display: 'flex',
gap: '8px',
marginBottom: '8px',
},
button: {
padding: '4px 8px',
fontSize: '12px',
borderRadius: '4px',
backgroundColor: 'var(--color-muted-2)',
color: 'var(--color-text)',
border: 'none',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'var(--color-muted-3)',
}
}
}
} as const
// Color themes for different node types
export const NODE_THEMES = {
calendar: {
headerColor: '#0066cc',
backgroundColor: '#e6f0ff',
},
academic: {
headerColor: '#008000',
backgroundColor: '#e6ffe6',
},
curriculum: {
headerColor: '#ff8c00',
backgroundColor: '#fff3e6',
},
pastoral: {
headerColor: '#8a2be2',
backgroundColor: '#f5e6ff',
},
people: {
headerColor: '#cc0000',
backgroundColor: '#ffe6e6',
},
resource: {
headerColor: '#cccc00',
backgroundColor: '#fffff0',
},
} as const
// Node type to theme mapping
export const NODE_TYPE_THEMES: Record<string, keyof typeof NODE_THEMES> = {
// Calendar nodes
'cc-calendar-node': 'calendar',
'cc-calendar-year-node': 'calendar',
'cc-calendar-month-node': 'calendar',
'cc-calendar-week-node': 'calendar',
'cc-calendar-day-node': 'calendar',
'cc-calendar-time-chunk-node': 'calendar',
// Academic nodes
'cc-academic-year-node': 'academic',
'cc-academic-term-node': 'academic',
'cc-academic-week-node': 'academic',
'cc-academic-day-node': 'academic',
'cc-academic-period-node': 'academic',
'cc-registration-period-node': 'academic',
'cc-timetable-lesson-node': 'academic',
'cc-planned-lesson-node': 'academic',
'cc-school-timetable-node': 'academic',
'cc-user-teacher-timetable-node': 'academic',
'cc-user-timetable-lesson-node': 'academic',
// Curriculum nodes
'cc-curriculum-structure-node': 'curriculum',
'cc-key-stage-node': 'curriculum',
'cc-key-stage-syllabus-node': 'curriculum',
'cc-year-group-syllabus-node': 'curriculum',
'cc-subject-node': 'curriculum',
'cc-topic-node': 'curriculum',
'cc-topic-lesson-node': 'curriculum',
'cc-learning-statement-node': 'curriculum',
'cc-science-lab-node': 'curriculum',
// Pastoral nodes
'cc-pastoral-structure-node': 'pastoral',
'cc-year-group-node': 'pastoral',
// People nodes
'cc-user-node': 'people',
'cc-teacher-node': 'people',
'cc-student-node': 'people',
// Resource nodes
'cc-school-node': 'resource',
'cc-department-node': 'resource',
'cc-room-node': 'resource',
'cc-subject-class-node': 'resource',
} as const
// Helper function to get theme for a node type
export const getNodeTheme = (nodeType: string) => {
const themeKey = NODE_TYPE_THEMES[nodeType]
return themeKey ? NODE_THEMES[themeKey] : NODE_THEMES.resource // Default to resource theme
}
// Helper function to get theme from primary label
export const getThemeFromLabel = (primaryLabel: string) => {
// Convert primary label to node type format (e.g., 'User' -> 'cc-user-node')
const nodeType = `cc-${primaryLabel.toLowerCase()}-node`;
return getNodeTheme(nodeType);
}
// Helper function to get styles for a specific node type
export const getNodeStyles = (nodeType: string) => {
const theme = getNodeTheme(nodeType)
return {
...SHARED_NODE_STYLES,
container: {
...SHARED_NODE_STYLES.container,
backgroundColor: theme.backgroundColor,
},
}
}
@@ -0,0 +1,389 @@
import { TLBinding, TLBaseShape, TLShapeId } from '@tldraw/tldraw'
import { CCBaseShape } from '../cc-types'
import { CCBaseProps } from '../cc-props'
import { ccGraphShapeProps } from './cc-graph-props'
// Export type for graph shape types
export type GraphShapeType = keyof typeof ccGraphShapeProps
export interface ShapeState {
parentId: TLShapeId | null
isPageChild: boolean | null
hasChildren: boolean | null
bindings: TLBinding[] | null
}
export type CCGraphShapeProps = CCBaseProps & {
__primarylabel__: string
unique_id: string
tldraw_snapshot: string
created: string
merged: string
state: ShapeState | null | undefined
defaultComponent: boolean | null
}
// Define the base shape type for graph shapes
export type CCGraphShape = CCBaseShape & TLBaseShape<GraphShapeType, {
__primarylabel__: CCGraphShapeProps['__primarylabel__']
unique_id: CCGraphShapeProps['unique_id']
tldraw_snapshot: CCGraphShapeProps['tldraw_snapshot']
created: CCGraphShapeProps['created']
merged: CCGraphShapeProps['merged']
state: CCGraphShapeProps['state']
defaultComponent: CCGraphShapeProps['defaultComponent']
}>
export type CCUserNodeProps = CCGraphShapeProps & {
user_name: string
user_email: string
user_type: string
user_id: string
worker_node_data: string
}
export type CCTeacherNodeProps = CCGraphShapeProps & {
teacher_code: string
teacher_name_formal: string
teacher_email: string
user_db_name: string
school_db_name: string
}
export type CCStudentNodeProps = CCGraphShapeProps & {
student_name_formal: string
student_code: string
student_email: string
user_db_name: string
school_db_name: string
}
export type CCCalendarNodeProps = CCGraphShapeProps & {
title: string
name: string
calendar_type: string
calendar_name: string
start_date: string
end_date: string
}
export type CCCalendarYearNodeProps = CCGraphShapeProps & {
year: string
}
export type CCCalendarMonthNodeProps = CCGraphShapeProps & {
year: string
month: string
month_name: string
}
export type CCCalendarWeekNodeProps = CCGraphShapeProps & {
start_date: string
week_number: string
iso_week: string
}
export type CCCalendarDayNodeProps = CCGraphShapeProps & {
date: string
day_of_week: string
iso_day: string
}
export type CCCalendarTimeChunkNodeProps = CCGraphShapeProps & {
start_time: string
end_time: string
}
export type CCSchoolNodeProps = CCGraphShapeProps & {
school_uuid: string
school_name: string
school_website: string
}
export type CCDepartmentNodeProps = CCGraphShapeProps & {
department_name: string
}
export type CCRoomNodeProps = CCGraphShapeProps & {
room_code: string
room_name: string
}
export type CCSubjectClassNodeProps = CCGraphShapeProps & {
subject_class_code: string
year_group: string
subject: string
subject_code: string
}
export type CCPastoralStructureNodeProps = CCGraphShapeProps & {
pastoral_structure_type: string
}
export type CCYearGroupNodeProps = CCGraphShapeProps & {
year_group: string
year_group_name: string
}
export type CCCurriculumStructureNodeProps = CCGraphShapeProps & {
curriculum_structure_type: string
}
export type CCKeyStageNodeProps = CCGraphShapeProps & {
key_stage_name: string
key_stage: string
}
export type CCKeyStageSyllabusNodeProps = CCGraphShapeProps & {
ks_syllabus_id: string
ks_syllabus_name: string
ks_syllabus_key_stage: string
ks_syllabus_subject: string
ks_syllabus_subject_code: string
}
export type CCYearGroupSyllabusNodeProps = CCGraphShapeProps & {
yr_syllabus_id: string
yr_syllabus_name: string
yr_syllabus_year_group: string
yr_syllabus_subject: string
yr_syllabus_subject_code: string
}
export type CCSubjectNodeProps = CCGraphShapeProps & {
subject_code: string
subject_name: string
}
export type CCTopicNodeProps = CCGraphShapeProps & {
topic_id: string
topic_title: string
total_number_of_lessons_for_topic: string
topic_type: string
topic_assessment_type: string
}
export type CCTopicLessonNodeProps = CCGraphShapeProps & {
topic_lesson_id: string
topic_lesson_title: string
topic_lesson_type: string
topic_lesson_length: string
topic_lesson_skills_learned: string
topic_lesson_suggested_activities: string
topic_lesson_weblinks: string
}
export type CCLearningStatementNodeProps = CCGraphShapeProps & {
lesson_learning_statement_id: string
lesson_learning_statement: string
lesson_learning_statement_type: string
}
export type CCScienceLabNodeProps = CCGraphShapeProps & {
science_lab_id: string
science_lab_title: string
science_lab_summary: string
science_lab_requirements: string
science_lab_procedure: string
science_lab_safety: string
science_lab_weblinks: string
}
export type CCTeacherTimetableNodeProps = CCGraphShapeProps & {
teacher_id: string
start_date: string
end_date: string
}
export type CCTimetableLessonNodeProps = CCGraphShapeProps & {
subject_class: string
date: string
start_time: string
end_time: string
period_code: string
}
export type CCPlannedLessonNodeProps = CCGraphShapeProps & {
date: string
start_time: string
end_time: string
period_code: string
subject_class: string
year_group: string
subject: string
teacher_code: string
planning_status: string
topic_code: string
topic_name: string
lesson_code: string
lesson_name: string
learning_statement_codes: string
learning_statements: string
learning_resource_codes: string
learning_resources: string
}
export type CCSchoolTimetableNodeProps = CCGraphShapeProps & {
start_date: string
end_date: string
}
export type CCAcademicYearNodeProps = CCGraphShapeProps & {
year: string
}
export type CCAcademicTermNodeProps = CCGraphShapeProps & {
term_name: string
term_number: string
start_date: string
end_date: string
}
export type CCAcademicWeekNodeProps = CCGraphShapeProps & {
academic_week_number: string
start_date: string
week_type: string
}
export type CCAcademicDayNodeProps = CCGraphShapeProps & {
academic_day: string
date: string
day_of_week: string
day_type: string
}
export type CCAcademicPeriodNodeProps = CCGraphShapeProps & {
name: string
date: string
start_time: string
end_time: string
period_code: string
}
export type CCRegistrationPeriodNodeProps = CCGraphShapeProps & {
name: string
date: string
start_time: string
end_time: string
period_code: string
}
export type CCDepartmentStructureNodeProps = CCGraphShapeProps & {
department_structure_type: string
}
export type CCUserTeacherTimetableNodeProps = CCGraphShapeProps & {
school_db_name: string
school_timetable_id: string
}
export type CCUserTimetableLessonNodeProps = CCGraphShapeProps & {
subject_class: string
date: string
start_time: string
end_time: string
period_code: string
school_db_name: string
school_period_id: string
}
// Define a type-safe mapping of node types to their configurations
export type CCNodeTypes = {
User: { props: CCUserNodeProps }
Developer: { props: CCUserNodeProps }
Teacher: { props: CCTeacherNodeProps }
Student: { props: CCStudentNodeProps }
Calendar: { props: CCCalendarNodeProps }
TeacherTimetable: { props: CCTeacherTimetableNodeProps }
TimetableLesson: { props: CCTimetableLessonNodeProps }
PlannedLesson: { props: CCPlannedLessonNodeProps }
School: { props: CCSchoolNodeProps }
CalendarYear: { props: CCCalendarYearNodeProps }
CalendarMonth: { props: CCCalendarMonthNodeProps }
CalendarWeek: { props: CCCalendarWeekNodeProps }
CalendarDay: { props: CCCalendarDayNodeProps }
CalendarTimeChunk: { props: CCCalendarTimeChunkNodeProps }
ScienceLab: { props: CCScienceLabNodeProps }
KeyStageSyllabus: { props: CCKeyStageSyllabusNodeProps }
YearGroupSyllabus: { props: CCYearGroupSyllabusNodeProps }
CurriculumStructure: { props: CCCurriculumStructureNodeProps }
Topic: { props: CCTopicNodeProps }
TopicLesson: { props: CCTopicLessonNodeProps }
LearningStatement: { props: CCLearningStatementNodeProps }
SchoolTimetable: { props: CCSchoolTimetableNodeProps }
AcademicYear: { props: CCAcademicYearNodeProps }
AcademicTerm: { props: CCAcademicTermNodeProps }
AcademicWeek: { props: CCAcademicWeekNodeProps }
AcademicDay: { props: CCAcademicDayNodeProps }
AcademicPeriod: { props: CCAcademicPeriodNodeProps }
RegistrationPeriod: { props: CCRegistrationPeriodNodeProps }
PastoralStructure: { props: CCPastoralStructureNodeProps }
KeyStage: { props: CCKeyStageNodeProps }
Department: { props: CCDepartmentNodeProps }
Room: { props: CCRoomNodeProps }
SubjectClass: { props: CCSubjectClassNodeProps }
DepartmentStructure: { props: CCDepartmentStructureNodeProps }
UserTeacherTimetable: { props: CCUserTeacherTimetableNodeProps }
UserTimetableLesson: { props: CCUserTimetableLessonNodeProps }
}
// Helper function to get shape type from node type
export const getShapeType = (nodeType: keyof CCNodeTypes): string => {
return `cc-${nodeType.replace(/([A-Z])/g, '-$1').toLowerCase().substring(1)}-node`;
}
// Helper function to get allowed props from node type
export const getAllowedProps = (): string[] => {
return ['__primarylabel__', 'unique_id'];
}
// Helper function to get node configuration
export const getNodeConfig = <T extends keyof CCNodeTypes>(nodeType: T) => {
const shapeType = getShapeType(nodeType);
return {
shapeType,
allowedProps: getAllowedProps()
};
}
// Helper function to check if a string is a valid node type
export const isValidNodeType = (type: string): type is keyof CCNodeTypes => {
return type in {
User: true,
Developer: true,
Teacher: true,
Student: true,
Calendar: true,
TeacherTimetable: true,
TimetableLesson: true,
PlannedLesson: true,
School: true,
CalendarYear: true,
CalendarMonth: true,
CalendarWeek: true,
CalendarDay: true,
CalendarTimeChunk: true,
ScienceLab: true,
KeyStageSyllabus: true,
YearGroupSyllabus: true,
CurriculumStructure: true,
Topic: true,
TopicLesson: true,
LearningStatement: true,
SchoolTimetable: true,
AcademicYear: true,
AcademicTerm: true,
AcademicWeek: true,
AcademicDay: true,
AcademicPeriod: true,
RegistrationPeriod: true,
PastoralStructure: true,
KeyStage: true,
Department: true,
Room: true,
SubjectClass: true,
DepartmentStructure: true,
UserTeacherTimetable: true,
UserTimetableLesson: true,
};
}
@@ -0,0 +1,218 @@
import { Editor, createShapeId } from '@tldraw/tldraw';
import { getShapeType, isValidNodeType } from './cc-graph-types';
import { AllNodeShapes, NodeShapeType } from './cc-graph-shapes';
import { logger } from '../../../../debugConfig';
export const GRID_CELL_SIZE = 250;
export const GRID_PADDING = 50;
export const GRID_MAX_COLUMNS = 8;
export const graphState = {
nodeData: new Map<string, AllNodeShapes>(),
shapeIds: new Set<string>(),
editor: null as Editor | null,
arrangeNodesInGrid: () => {
if (!graphState.editor) {
logger.error('graphStateUtil', '❌ Editor not initialized');
return;
}
const nodes = Array.from(graphState.nodeData.values());
if (nodes.length === 0) return;
logger.debug('graphStateUtil', '📊 Arranging nodes in grid', {
nodeCount: nodes.length,
currentNodes: nodes
});
// Get viewport bounds
const viewportBounds = graphState.editor.getViewportPageBounds();
// Calculate available space
const availableWidth = viewportBounds.width;
// Calculate number of columns based on available space
const columnsFromSpace = Math.floor(availableWidth / (GRID_CELL_SIZE + GRID_PADDING));
const gridColumns = Math.min(
Math.max(1, Math.min(columnsFromSpace, GRID_MAX_COLUMNS)),
Math.ceil(Math.sqrt(nodes.length * 2)) // Allow grid to grow with more nodes
);
// Calculate total grid dimensions
const rowCount = Math.ceil(nodes.length / gridColumns);
const totalWidth = gridColumns * (GRID_CELL_SIZE + GRID_PADDING);
const totalHeight = rowCount * (GRID_CELL_SIZE + GRID_PADDING);
// Calculate starting position to center the grid
const startX = viewportBounds.minX + (viewportBounds.width - totalWidth) / 2;
const startY = viewportBounds.minY + (viewportBounds.height - totalHeight) / 2;
// Track created/updated shapes for viewport adjustment
const updatedShapeIds: string[] = [];
nodes.forEach((node, index) => {
if (!node.props?.unique_id) return;
const row = Math.floor(index / gridColumns);
const col = index % gridColumns;
const x = startX + (col * (GRID_CELL_SIZE + GRID_PADDING));
const y = startY + (row * (GRID_CELL_SIZE + GRID_PADDING));
const shapeId = createShapeId(node.props.unique_id);
updatedShapeIds.push(shapeId.toString());
// Update both our internal state and the editor
node.x = x;
node.y = y;
graphState.nodeData.set(node.props.unique_id, node);
// Only create if the shape doesn't exist in our tracking
if (!graphState.shapeIds.has(shapeId.toString())) {
graphState.editor!.createShape({
id: shapeId,
type: node.type,
x: x,
y: y,
props: node.props
});
graphState.shapeIds.add(shapeId.toString());
logger.debug('graphStateUtil', ' Created new shape', {
id: shapeId.toString(),
position: { x, y }
});
} else {
graphState.editor!.updateShape({
id: shapeId,
type: node.type,
x: x,
y: y,
});
logger.debug('graphStateUtil', '🔄 Updated existing shape', {
id: shapeId.toString(),
position: { x, y }
});
}
});
// Only attempt to adjust view if we have shapes
if (updatedShapeIds.length > 0) {
const shapeIds = updatedShapeIds.map(id => createShapeId(id));
graphState.editor.select(...shapeIds);
graphState.editor.zoomToSelection();
graphState.editor.deselect();
logger.debug('graphStateUtil', '🔍 Adjusted viewport for shapes', {
shapeCount: updatedShapeIds.length
});
}
},
updateShapesWithDagre: () => {
if (!graphState.editor) {
logger.error('graphStateUtil', '❌ Editor not initialized');
return;
}
graphState.arrangeNodesInGrid();
},
addNode: (shape: AllNodeShapes) => {
logger.debug('graphStateUtil', '🔍 Adding shape to graphState:', { shape });
if (!shape.props?.unique_id || !shape.type) {
logger.error('graphStateUtil', '❌ Invalid shape data', { shape });
return;
}
const id = shape.props.unique_id;
const shapeId = createShapeId(id).toString();
// Track the shape ID
graphState.shapeIds.add(shapeId);
const nodeType = shape.props.__primarylabel__;
if (!isValidNodeType(nodeType)) {
logger.error('graphStateUtil', '❌ Unknown node type', {
type: nodeType,
shape
});
return;
}
const shapeType = getShapeType(nodeType) as NodeShapeType;
graphState.nodeData.set(id, {
...shape,
type: shapeType
} as AllNodeShapes);
// Only rearrange if we have an editor
if (graphState.editor) {
graphState.arrangeNodesInGrid();
}
},
getNode: (id: string) => {
return graphState.nodeData.get(id);
},
getAllNodes: () => {
return Array.from(graphState.nodeData.values()).filter(item => {
// Check if the item has a type property and it's not an edge type
logger.debug('graphStateUtil', '🔍 Checking if item has a type property and it\'s not an edge type:', { item });
return item.type && !item.type.includes('relationship');
});
},
setEditor: (editor: Editor) => {
graphState.editor = editor;
graphState.shapeIds.clear();
},
updateNodePosition: (nodeId: string, newPos: { x: number, y: number }) => {
const node = graphState.nodeData.get(nodeId);
if (node) {
node.x = newPos.x;
node.y = newPos.y;
graphState.nodeData.set(nodeId, node);
// If we have an editor reference, update the shape
if (graphState.editor) {
const shapeId = createShapeId(nodeId);
logger.debug('graphStateUtil', '🎯 Updating shape position', {
id: shapeId,
position: { x: newPos.x, y: newPos.y }
});
graphState.editor.updateShape({
id: shapeId,
type: node.type,
x: newPos.x,
y: newPos.y,
});
}
logger.debug('graphStateUtil', '📍 Updated node position', {
nodeId,
newPos,
node
});
}
},
hasNode: (uniqueId: string): boolean => {
return graphState.nodeData.has(uniqueId);
},
getShapeByUniqueId: (uniqueId: string) => {
return Array.from(graphState.nodeData.values()).find(
shape => shape.props?.unique_id === uniqueId
);
},
hasShape: (shapeId: string): boolean => {
return graphState.shapeIds.has(shapeId);
},
};
+246
View File
@@ -0,0 +1,246 @@
import { TLRecord, TLShape } from '@tldraw/tldraw'
import { getDefaultCCBaseProps, getDefaultCCCalendarProps, getDefaultCCLiveTranscriptionProps, getDefaultCCSettingsProps, getDefaultCCSlideProps, getDefaultCCSlideShowProps, getDefaultCCSlideLayoutBindingProps, getDefaultCCYoutubeEmbedProps, getDefaultCCSearchProps, getDefaultCCWebBrowserProps } from './cc-props'
// Export both shape and binding migrations
export const ccBindingMigrations = {
'cc-slide-layout': {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'binding') return record
if (record.type !== 'cc-slide-layout') return record
return {
...record,
props: {
...getDefaultCCSlideLayoutBindingProps(),
...record.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
}
export const ccShapeMigrations = {
base: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-base') return record
return {
...shape,
props: {
...getDefaultCCBaseProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
calendar: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-calendar') return record
return {
...shape,
props: {
...getDefaultCCCalendarProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
liveTranscription: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-live-transcription') return record
return {
...shape,
props: {
...getDefaultCCLiveTranscriptionProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
settings: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-settings') return record
return {
...shape,
props: {
...getDefaultCCSettingsProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
slideshow: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-slideshow') return record
return {
...shape,
props: {
...getDefaultCCSlideShowProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
slide: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-slide') return record
return {
...shape,
props: {
...getDefaultCCSlideProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
'cc-youtube-embed': {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-youtube-embed') return record
return {
...shape,
props: {
...getDefaultCCYoutubeEmbedProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
search: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-search') return record
return {
...shape,
props: {
...getDefaultCCSearchProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
webBrowser: {
firstVersion: 1,
currentVersion: 1,
migrators: {
1: {
up: (record: TLRecord) => {
if (record.typeName !== 'shape') return record
const shape = record as TLShape
if (shape.type !== 'cc-web-browser') return record
return {
...shape,
props: {
...getDefaultCCWebBrowserProps(),
...shape.props,
},
}
},
down: (record: TLRecord) => {
return record
},
},
},
},
}
+262
View File
@@ -0,0 +1,262 @@
import { T } from 'tldraw'
import { CC_BASE_STYLE_CONSTANTS, CC_SLIDESHOW_STYLE_CONSTANTS } from './cc-styles'
export interface CCBaseProps {
title: string
w: number
h: number
headerColor: string
backgroundColor: string
isLocked: boolean
}
// Create a constant for the base props validation
export const baseShapeProps = {
title: T.string,
w: T.number,
h: T.number,
headerColor: T.string,
backgroundColor: T.string,
isLocked: T.boolean,
}
export const ccShapeProps = {
base: baseShapeProps,
calendar: {
...baseShapeProps,
date: T.string,
selectedDate: T.string,
view: T.string,
events: T.arrayOf(T.object({
id: T.string,
title: T.string,
start: T.string,
end: T.string,
groupId: T.string.optional(),
extendedProps: T.object({
subjectClass: T.string,
color: T.string,
periodCode: T.string,
tldraw_snapshot: T.string.optional()
})
})),
},
liveTranscription: {
...baseShapeProps,
isRecording: T.boolean,
segments: T.arrayOf(T.object({
id: T.string,
text: T.string,
completed: T.boolean,
start: T.string,
end: T.string,
})),
currentSegment: T.object({
id: T.string,
text: T.string,
completed: T.boolean,
start: T.string,
end: T.string,
}).optional(),
lastProcessedSegment: T.string.optional(),
},
settings: {
...baseShapeProps,
userEmail: T.string,
user_role: T.string,
isTeacher: T.boolean,
},
slideshow: {
...baseShapeProps,
currentSlideIndex: T.number,
slidePattern: T.string,
numSlides: T.number,
slides: T.arrayOf(T.object({
imageData: T.string,
meta: T.object({
text: T.string,
format: T.string,
}),
})).optional(),
},
slide: {
...baseShapeProps,
imageData: T.string,
meta: T.object({
text: T.string,
format: T.string,
}),
},
'cc-youtube-embed': {
...baseShapeProps,
video_url: T.string,
transcript: T.arrayOf(T.object({
start: T.number,
duration: T.number,
text: T.string,
})),
transcriptVisible: T.boolean,
},
search: {
...baseShapeProps,
query: T.string,
results: T.arrayOf(T.object({
title: T.string,
url: T.string,
content: T.string,
})),
isSearching: T.boolean,
},
webBrowser: {
...baseShapeProps,
url: T.string,
history: T.arrayOf(T.string),
currentHistoryIndex: T.number,
isLoading: T.boolean,
},
}
export const ccBindingProps = {
'cc-slide-layout': {
isMovingWithParent: T.boolean.optional(),
placeholder: T.boolean.optional(),
index: T.string
},
}
export const getDefaultCCBaseProps = () => ({
title: 'Base Shape',
w: 100,
h: 100,
headerColor: '#3e6589',
backgroundColor: '#ffffff',
isLocked: false,
})
export const getDefaultCCCalendarProps = () => ({
...getDefaultCCBaseProps(),
date: new Date().toISOString(),
selectedDate: new Date().toISOString(),
view: 'timeGridWeek',
events: [],
})
export const getDefaultCCLiveTranscriptionProps = () => ({
...getDefaultCCBaseProps(),
isRecording: false,
segments: [],
currentSegment: undefined,
lastProcessedSegment: undefined,
})
export const getDefaultCCSettingsProps = () => ({
...getDefaultCCBaseProps(),
userEmail: '',
user_role: '',
isTeacher: false,
})
export function getDefaultCCSlideShowProps() {
// Base 16:9 ratio dimensions
const baseWidth = 1280
const baseHeight = 720
// Add header height and spacing
const totalHeight = baseHeight +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT + // Slideshow's own header
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING * 2 + // Top and bottom spacing
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING // Extra padding for content
return {
title: 'Slideshow',
w: baseWidth,
h: totalHeight,
headerColor: '#3e6589',
backgroundColor: '#0f0f0f',
isLocked: false,
currentSlideIndex: 0,
slidePattern: 'horizontal',
numSlides: 3,
slides: [],
}
}
export function getDefaultCCSlideProps() {
// Base 16:9 ratio dimensions
const baseWidth = 1280
const baseHeight = 720
// Add header height
const totalHeight = baseHeight + CC_BASE_STYLE_CONSTANTS.HEADER.height
return {
title: 'Slide',
w: baseWidth,
h: totalHeight,
headerColor: '#3e6589',
backgroundColor: '#0f0f0f',
isLocked: false,
imageData: '',
meta: {
text: '',
format: 'markdown'
}
}
}
export function getDefaultCCSlideLayoutBindingProps() {
return {
isMovingWithParent: false,
placeholder: false,
index: '0',
}
}
export function getDefaultCCYoutubeEmbedProps() {
const videoHeight = 450
const totalHeight = videoHeight + CC_BASE_STYLE_CONSTANTS.HEADER.height + (CC_BASE_STYLE_CONSTANTS.CONTENT.padding * 2)
return {
...getDefaultCCBaseProps(),
title: 'YouTube Video',
w: 800,
h: totalHeight,
headerColor: '#ff0000',
backgroundColor: '#0f0f0f',
isLocked: false,
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
transcript: [],
transcriptVisible: false,
}
}
export const getDefaultCCSearchProps = () => ({
...getDefaultCCBaseProps(),
w: 400,
h: 500,
title: 'Search',
headerColor: '#1a73e8',
backgroundColor: '#ffffff',
query: '',
results: [],
isSearching: false,
})
export const getDefaultCCWebBrowserProps = () => ({
...getDefaultCCBaseProps(),
title: 'Web Browser',
w: 800,
h: 600,
headerColor: '#1a73e8',
backgroundColor: '#ffffff',
url: '',
history: [],
currentHistoryIndex: -1,
isLoading: false,
})
@@ -0,0 +1,85 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { ccShapeProps, getDefaultCCSearchProps } from '../cc-props'
import { ccShapeMigrations } from '../cc-migrations'
import { Rectangle2d, Vec } from 'tldraw'
import { SearchComponent } from './SearchComponent'
import { SearchResult } from '../../../../services/tldraw/searchService'
export interface CCSearchShape extends CCBaseShape {
type: 'cc-search'
props: CCBaseShape['props'] & {
query: string
results: SearchResult[]
isSearching: boolean
}
}
export class CCSearchShapeUtil extends CCBaseShapeUtil<CCSearchShape> {
static override type = 'cc-search' as const;
static override props = ccShapeProps.search;
static override migrations = ccShapeMigrations.search;
override getDefaultProps(): CCSearchShape['props'] {
return getDefaultCCSearchProps() as CCSearchShape['props'];
}
override isAspectRatioLocked = () => false
override canResize = () => true
override canBind = () => false
override hideResizeHandles = () => false
override hideRotateHandle = () => false
override canEdit = () => false
override renderContent = (shape: CCSearchShape) => {
return (
<div style={{ width: '100%', height: '100%', pointerEvents: 'all' }}>
<SearchComponent shape={shape} />
</div>
)
}
override getGeometry(shape: CCSearchShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
override onResize = (
shape: CCSearchShape,
info: { initialShape: CCSearchShape; scaleX: number; scaleY: number }
) => {
const { initialShape, scaleX, scaleY } = info
const newW = Math.max(300, Math.round(initialShape.props.w * scaleX))
const newH = Math.max(200, Math.round(initialShape.props.h * scaleY))
return {
props: {
...shape.props,
w: newW,
h: newH,
},
}
}
hitTestPoint = (shape: CCSearchShape, point: Vec) => {
const geometry = this.getGeometry(shape)
return geometry.hitTestPoint(point)
}
hitTestLineSegment = (shape: CCSearchShape, start: Vec, end: Vec) => {
const geometry = this.getGeometry(shape)
return geometry.hitTestLineSegment(start, end)
}
shouldRender = (prev: CCSearchShape, next: CCSearchShape) => {
return (
prev.props.w !== next.props.w ||
prev.props.h !== next.props.h ||
prev.props.query !== next.props.query ||
prev.props.results !== next.props.results ||
prev.props.isSearching !== next.props.isSearching
)
}
}
@@ -0,0 +1,383 @@
import React, { useState, useCallback } from 'react'
import { useEditor } from '@tldraw/tldraw'
import {
TextField,
IconButton,
CircularProgress,
List,
ListItem,
ListItemText,
Paper,
Button,
ButtonGroup,
Menu,
MenuItem,
Tooltip,
IconButton as MuiIconButton
} from '@mui/material'
import SearchIcon from '@mui/icons-material/Search'
import GridViewIcon from '@mui/icons-material/GridView'
import ViewStreamIcon from '@mui/icons-material/ViewStream'
import ViewWeekIcon from '@mui/icons-material/ViewWeek'
import FilterNoneIcon from '@mui/icons-material/FilterNone'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import { CCSearchShape } from './CCSearchShapeUtil'
import { SearchService, SearchResult } from '../../../../services/tldraw/searchService'
import { logger } from '../../../../debugConfig'
import { createWebBrowserShape, createMultipleWebBrowsers } from '../shape-helpers/web-browser-helpers'
interface SearchComponentProps {
shape: CCSearchShape
}
type LayoutType = 'grid' | 'cascade' | 'horizontal' | 'vertical'
export const SearchComponent: React.FC<SearchComponentProps> = ({ shape }) => {
const editor = useEditor()
const [query, setQuery] = useState(shape.props.query || '')
const [isSearching, setIsSearching] = useState(false)
const [selectedResults, setSelectedResults] = useState<SearchResult[]>([])
const [layoutAnchorEl, setLayoutAnchorEl] = useState<null | HTMLElement>(null)
const [currentLayout, setCurrentLayout] = useState<LayoutType>('grid')
const handleSearch = useCallback(async () => {
if (!query.trim()) return
logger.debug('cc-search', '🔍 Starting search', { query })
setIsSearching(true)
try {
const results = await SearchService.search(query)
logger.debug('cc-search', '✅ Search completed', {
query,
resultCount: results.length
})
// Update the shape's properties
editor.updateShape({
id: shape.id,
type: 'cc-search',
props: {
...shape.props,
query,
results,
isSearching: false,
},
})
} catch (error) {
logger.error('cc-search', '❌ Search failed', { error })
// Clear results on error
editor.updateShape({
id: shape.id,
type: 'cc-search',
props: {
...shape.props,
query,
results: [],
isSearching: false,
},
})
} finally {
setIsSearching(false)
setSelectedResults([])
}
}, [query, editor, shape])
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch()
}
}
const handleResultClick = useCallback((result: SearchResult) => {
logger.debug('cc-search', '🖱️ Search result clicked', {
resultTitle: result.title,
resultUrl: result.url
})
try {
logger.debug('cc-web-browser', '🌐 Creating web browser shape', {
url: result.url,
position: { x: shape.x + shape.props.w + 20, y: shape.y }
})
// Create a single browser shape for direct click
createWebBrowserShape(editor, {
url: result.url,
title: result.title,
x: shape.x + shape.props.w + 20,
y: shape.y
})
} catch (error) {
logger.error('cc-web-browser', '❌ Failed to create web browser shape', { error })
}
}, [editor, shape])
const handleResultSelect = (result: SearchResult, event: React.MouseEvent) => {
// If shift key is pressed, handle multi-select
if (event.shiftKey) {
setSelectedResults(prev => {
const isSelected = prev.some(r => r.url === result.url)
if (isSelected) {
return prev.filter(r => r.url !== result.url)
} else {
return [...prev, result]
}
})
}
}
const handleLayoutMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setLayoutAnchorEl(event.currentTarget)
}
const handleLayoutMenuClose = () => {
setLayoutAnchorEl(null)
}
const handleLayoutSelect = (layout: LayoutType) => {
setCurrentLayout(layout)
handleLayoutMenuClose()
}
const openSelectedResults = () => {
if (selectedResults.length === 0) return
logger.debug('cc-web-browser', '🌐 Creating web browser shapes', {
count: selectedResults.length,
layout: currentLayout
})
const browsers = selectedResults.map(result => ({
url: result.url,
title: result.title
}))
// Calculate starting position relative to the search shape
const startX = shape.x + shape.props.w + 20
const startY = shape.y
createMultipleWebBrowsers(editor, {
browsers,
layout: currentLayout,
startX,
startY,
spacing: 30
})
// Clear selection after opening
setSelectedResults([])
}
const getLayoutIcon = (layout: LayoutType) => {
switch (layout) {
case 'grid':
return <GridViewIcon />
case 'horizontal':
return <ViewStreamIcon />
case 'vertical':
return <ViewWeekIcon />
case 'cascade':
return <FilterNoneIcon />
}
}
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: '8px',
pointerEvents: 'all', // Ensure we get pointer events
position: 'relative', // Create a new stacking context
zIndex: 1 // Ensure our content is above TLDraw's canvas
}}
onPointerDown={(e) => {
// Prevent TLDraw from handling our pointer events
e.stopPropagation()
}}
>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<TextField
fullWidth
variant="outlined"
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Search..."
disabled={isSearching}
/>
<IconButton
onClick={handleSearch}
disabled={isSearching || !query.trim()}
color="primary"
>
{isSearching ? <CircularProgress size={24} /> : <SearchIcon />}
</IconButton>
</div>
{shape.props.results.length > 0 && (
<div
style={{ display: 'flex', gap: '8px', alignItems: 'center' }}
onPointerDown={(e) => e.stopPropagation()}
>
<ButtonGroup variant="outlined" size="small">
<Tooltip title="Open selected results">
<span>
<Button
onClick={openSelectedResults}
disabled={selectedResults.length === 0}
startIcon={getLayoutIcon(currentLayout)}
>
Open ({selectedResults.length})
</Button>
</span>
</Tooltip>
<Tooltip title="Choose layout">
<span>
<Button
size="small"
onClick={handleLayoutMenuOpen}
disabled={selectedResults.length === 0}
>
</Button>
</span>
</Tooltip>
</ButtonGroup>
<Menu
anchorEl={layoutAnchorEl}
open={Boolean(layoutAnchorEl)}
onClose={handleLayoutMenuClose}
>
<MenuItem onClick={() => handleLayoutSelect('grid')}>
<GridViewIcon sx={{ mr: 1 }} /> Grid
</MenuItem>
<MenuItem onClick={() => handleLayoutSelect('horizontal')}>
<ViewStreamIcon sx={{ mr: 1 }} /> Horizontal
</MenuItem>
<MenuItem onClick={() => handleLayoutSelect('vertical')}>
<ViewWeekIcon sx={{ mr: 1 }} /> Vertical
</MenuItem>
<MenuItem onClick={() => handleLayoutSelect('cascade')}>
<FilterNoneIcon sx={{ mr: 1 }} /> Cascade
</MenuItem>
</Menu>
</div>
)}
<Paper
style={{
flex: 1,
overflow: 'auto',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
position: 'relative', // Create a new stacking context
zIndex: 1 // Ensure our content is above TLDraw's canvas
}}
onPointerDown={(e) => e.stopPropagation()}
>
<List>
{shape.props.results.map((result, index) => (
<ListItem
key={index}
onClick={(e) => handleResultSelect(result, e)}
selected={selectedResults.some(r => r.url === result.url)}
sx={{
cursor: 'pointer',
position: 'relative',
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.08)',
},
'&.Mui-selected': {
backgroundColor: 'rgba(25, 118, 210, 0.12)',
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.16)',
},
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '4px',
backgroundColor: '#1a73e8',
borderRadius: '0 2px 2px 0'
}
},
transition: 'all 0.2s ease'
}}
onPointerDown={(e) => {
// Prevent TLDraw from handling our pointer events
e.stopPropagation()
}}
>
<div style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: '4px'
}}>
<ListItemText
primary={result.title}
secondary={result.content}
primaryTypographyProps={{
style: {
fontWeight: selectedResults.some(r => r.url === result.url) ? 'bold' : 'normal',
fontSize: '0.9rem',
color: '#1a73e8'
}
}}
secondaryTypographyProps={{
style: {
fontSize: '0.8rem',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}
}}
/>
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'center'
}}>
<span style={{
fontSize: '0.8rem',
color: '#006621',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{result.url}
</span>
<Tooltip title="Open in new browser">
<MuiIconButton
size="small"
onClick={(e) => {
e.stopPropagation()
handleResultClick(result)
}}
sx={{
marginLeft: 'auto',
color: '#1a73e8',
'&:hover': {
backgroundColor: 'rgba(26, 115, 232, 0.08)'
}
}}
>
<OpenInNewIcon fontSize="small" />
</MuiIconButton>
</Tooltip>
</div>
</div>
</ListItem>
))}
</List>
</Paper>
</div>
)
}
@@ -0,0 +1,40 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { ccShapeProps, getDefaultCCSettingsProps } from '../cc-props'
import { ccShapeMigrations } from '../cc-migrations'
import { SettingsComponent } from './SettingsComponent'
export interface CCSettingsShape extends CCBaseShape {
type: 'cc-settings'
props: {
title: string
w: number
h: number
headerColor: string
backgroundColor: string
isLocked: boolean
userEmail: string
user_role: string
isTeacher: boolean
}
}
export class CCSettingsShapeUtil extends CCBaseShapeUtil<CCSettingsShape> {
static override type = 'cc-settings' as const
static override props = ccShapeProps.settings
static override migrations = ccShapeMigrations.settings
override getDefaultProps(): CCSettingsShape['props'] {
return getDefaultCCSettingsProps() as CCSettingsShape['props']
}
override canResize = () => false
override isAspectRatioLocked = () => false
override hideResizeHandles = () => true
override hideRotateHandle = () => false
override canEdit = () => false
override renderContent = () => {
return <SettingsComponent />
}
}
@@ -0,0 +1,57 @@
import React from 'react'
import { Container, Typography, Paper, Box, Button } from '@mui/material'
import { useAuth } from '../../../../contexts/AuthContext'
export const SettingsComponent: React.FC = () => {
// Use AuthContext to show real-time user data
const { user, user_role: currentRole } = useAuth()
const currentEmail = user?.email || ''
const currentUserRole = currentRole || ''
const isCurrentTeacher = currentRole?.includes('teacher') || false
return (
<Container>
{/* User Info Section */}
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>
User Information
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="body1">
Email: {currentEmail || 'Not set'}
</Typography>
<Typography variant="body1">
Role: {currentUserRole || 'Not set'}
</Typography>
</Box>
</Paper>
{/* Timetable Upload Section - Only visible for teachers */}
{isCurrentTeacher && (
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>
Timetable Management
</Typography>
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
component="label"
color="secondary"
fullWidth
>
Upload Timetable
<input
type="file"
hidden
accept=".xlsx"
/>
</Button>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Upload your timetable in Excel (.xlsx) format
</Typography>
</Box>
</Paper>
)}
</Container>
)
}
@@ -0,0 +1,453 @@
import { BindingUtil, TLBaseBinding, IndexKey, Vec, TLShapeId } from '@tldraw/tldraw'
import { CCSlideShowShape } from './CCSlideShowShapeUtil'
import { CCSlideShape } from './CCSlideShapeUtil'
import { CC_SLIDESHOW_STYLE_CONSTANTS } from '../cc-styles'
import { logger } from '../../../../debugConfig'
export interface CCSlideLayoutBinding extends TLBaseBinding<'cc-slide-layout', {
index: IndexKey
isMovingWithParent: boolean
placeholder: boolean
}> {}
export class CCSlideLayoutBindingUtil extends BindingUtil<CCSlideLayoutBinding> {
static type = 'cc-slide-layout' as const
getDefaultProps(): CCSlideLayoutBinding['props'] {
return {
index: 'a1' as IndexKey,
isMovingWithParent: false,
placeholder: false
}
}
private updateSlidePosition(binding: CCSlideLayoutBinding) {
const { fromId: slideshowId, toId: slideId } = binding
const slideshow = this.editor.getShape<CCSlideShowShape>(slideshowId)
const slide = this.editor.getShape<CCSlideShape>(slideId)
if (!slideshow || !slide) return
// Get all bindings from the slideshow, sorted by index
const bindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Find position in sorted bindings array
const index = bindings.findIndex(b => b.id === binding.id)
if (index === -1) return
// Get all slides and their dimensions up to the current index
const slidesBeforeCurrent = bindings
.slice(0, index)
.map(b => {
const s = this.editor.getShape<CCSlideShape>(b.toId)
return s ? { width: s.props.w, height: s.props.h } : null
})
.filter(s => s !== null) as { width: number, height: number }[]
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Calculate new position based on pattern
let offset: Vec = new Vec(0, 0)
if (slideshow.props.slidePattern === 'horizontal') {
// Sum widths of all previous slides plus spacing
const totalWidthBefore = slidesBeforeCurrent.reduce((sum, s) => sum + s.width + spacing, 0)
offset = new Vec(
spacing + totalWidthBefore,
headerHeight + contentPadding + spacing
)
} else if (slideshow.props.slidePattern === 'vertical') {
// Sum heights of all previous slides plus spacing
const totalHeightBefore = slidesBeforeCurrent.reduce((sum, s) => sum + s.height + spacing, 0)
offset = new Vec(
spacing,
headerHeight + contentPadding + spacing + totalHeightBefore
)
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(bindings.length))
const row = Math.floor(index / cols)
const col = index % cols
// Get maximum dimensions for each column and row up to current position
const colWidths = new Array(cols).fill(0)
const rowHeights = new Array(Math.ceil(bindings.length / cols)).fill(0)
bindings.forEach((b, i) => {
const s = this.editor.getShape<CCSlideShape>(b.toId)
if (!s) return
const r = Math.floor(i / cols)
const c = i % cols
colWidths[c] = Math.max(colWidths[c], s.props.w)
rowHeights[r] = Math.max(rowHeights[r], s.props.h)
})
// Calculate position based on accumulated column widths and row heights
const xPos = spacing + colWidths.slice(0, col).reduce((sum, w) => sum + w + spacing, 0)
const yPos = headerHeight + contentPadding + spacing +
rowHeights.slice(0, row).reduce((sum, h) => sum + h + spacing, 0)
offset = new Vec(xPos, yPos)
} else if (slideshow.props.slidePattern === 'radial') {
// For radial pattern, calculate position based on index and total slides
const totalSlides = bindings.length
const angle = (2 * Math.PI * index) / totalSlides
// Find the largest slide dimension to determine radius
const maxDimension = Math.max(
...bindings.map(b => {
const s = this.editor.getShape<CCSlideShape>(b.toId)
return s ? Math.max(s.props.w, s.props.h) : 0
})
)
const radius = maxDimension * 0.75 // Adjust radius based on largest slide
// Calculate position on the circle
const x = spacing + radius + (radius * Math.cos(angle))
const y = headerHeight + contentPadding + spacing + radius + (radius * Math.sin(angle))
offset = new Vec(x, y)
}
const point = this.editor.getPointInParentSpace(
slide,
this.editor.getShapePageTransform(slideshow)!.applyToPoint(offset)
)
if (slide.x !== point.x || slide.y !== point.y) {
this.editor.updateShape<CCSlideShape>({
id: slideId,
type: 'cc-slide',
x: point.x,
y: point.y,
})
}
}
private updateSlideshowSize(slideshowId: TLShapeId) {
const slideshow = this.editor.getShape<CCSlideShowShape>(slideshowId)
if (!slideshow) return
// Get all bindings, including placeholders
const bindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Default dimensions for empty slideshow
const defaultWidth = CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_WIDTH + (spacing * 2)
const defaultHeight = CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_HEIGHT +
headerHeight + (contentPadding * 2) + (spacing * 2)
// If no bindings, set to default size
if (bindings.length === 0) {
if (slideshow.props.w !== defaultWidth || slideshow.props.h !== defaultHeight) {
this.editor.updateShape<CCSlideShowShape>({
id: slideshow.id,
type: 'cc-slideshow',
props: {
...slideshow.props,
w: defaultWidth,
h: defaultHeight
}
})
}
return
}
// Get all slides and their dimensions
const slides = bindings.map(binding => {
const slide = this.editor.getShape<CCSlideShape>(binding.toId)
if (!slide) return null
return {
width: slide.props.w,
height: slide.props.h
}
}).filter(slide => slide !== null) as { width: number, height: number }[]
if (slides.length === 0) return
// Calculate dimensions based on pattern
let width = defaultWidth
let height = defaultHeight
if (slideshow.props.slidePattern === 'horizontal') {
// Sum of all widths plus spacing between them
width = Math.max(
spacing + (slides.reduce((sum, slide) => sum + slide.width, 0) +
((slides.length - 1) * spacing)) + spacing,
slides[0].width + (spacing * 2) // Minimum width is first slide plus spacing
)
// Maximum height of slides plus header and spacing
height = headerHeight + contentPadding * 2 + spacing * 2 +
Math.max(...slides.map(slide => slide.height))
} else if (slideshow.props.slidePattern === 'vertical') {
// Maximum width of slides plus spacing
width = Math.max(...slides.map(slide => slide.width)) + (spacing * 2)
// Sum of all heights plus spacing between them
height = Math.max(
headerHeight + contentPadding * 2 + spacing +
(slides.reduce((sum, slide) => sum + slide.height, 0) +
((slides.length - 1) * spacing)) + spacing,
headerHeight + contentPadding * 2 + spacing * 2 + slides[0].height
)
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(slides.length))
const rows = Math.ceil(slides.length / cols)
// Find maximum width and height for grid cells
const maxCellWidth = Math.max(...slides.map(slide => slide.width))
const maxCellHeight = Math.max(...slides.map(slide => slide.height))
width = Math.max(
spacing + (cols * maxCellWidth + (cols - 1) * spacing) + spacing,
maxCellWidth + (spacing * 2)
)
height = Math.max(
headerHeight + contentPadding * 2 + spacing +
(rows * maxCellHeight + (rows - 1) * spacing) + spacing,
headerHeight + contentPadding * 2 + spacing * 2 + maxCellHeight
)
} else if (slideshow.props.slidePattern === 'radial') {
// For radial pattern, use the largest slide dimensions to ensure proper spacing
const maxSlideWidth = Math.max(...slides.map(slide => slide.width))
const maxSlideHeight = Math.max(...slides.map(slide => slide.height))
// Calculate dimensions to fit all slides in a circle
const radius = Math.max(maxSlideWidth, maxSlideHeight) * 1.5 // 1.5x for spacing
width = radius * 2 + (spacing * 2)
height = headerHeight + contentPadding * 2 + radius * 2 + (spacing * 2)
}
if (width !== slideshow.props.w || height !== slideshow.props.h) {
this.editor.updateShape<CCSlideShowShape>({
id: slideshow.id,
type: 'cc-slideshow',
props: {
...slideshow.props,
w: width,
h: height
}
})
}
// Update positions for all bindings
bindings.forEach(binding => this.updateSlidePosition(binding))
}
private getDistance(a: Vec, b: Vec): number {
const dx = a.x - b.x
const dy = a.y - b.y
return Math.sqrt(dx * dx + dy * dy)
}
private getInsertPosition(
slideshow: CCSlideShowShape,
draggedSlide: CCSlideShape,
point: Vec
): { index: number; offset: Vec } {
const bindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Get all existing slides with their dimensions
const existingSlides = bindings.map(b => {
const slide = this.editor.getShape<CCSlideShape>(b.toId)
return slide ? {
width: slide.props.w,
height: slide.props.h,
binding: b
} : null
}).filter(s => s !== null) as { width: number; height: number; binding: CCSlideLayoutBinding }[]
if (slideshow.props.slidePattern === 'horizontal') {
let currentX = spacing
let insertIndex = 0
// Calculate trigger points based on slide widths and the dragged slide
for (const slide of existingSlides) {
// Calculate the gap between slides based on the larger width
const gapWidth = Math.max(slide.width, draggedSlide.props.w)
const triggerPoint = currentX + (gapWidth / 2)
if (point.x < triggerPoint) break
currentX += slide.width + spacing
insertIndex++
}
return {
index: insertIndex,
offset: new Vec(currentX, headerHeight + contentPadding + spacing)
}
} else if (slideshow.props.slidePattern === 'vertical') {
let currentY = headerHeight + contentPadding + spacing
let insertIndex = 0
// Calculate trigger points based on slide heights and the dragged slide
for (const slide of existingSlides) {
// Calculate the gap between slides based on the larger height
const gapHeight = Math.max(slide.height, draggedSlide.props.h)
const triggerPoint = currentY + (gapHeight / 2)
if (point.y < triggerPoint) break
currentY += slide.height + spacing
insertIndex++
}
return {
index: insertIndex,
offset: new Vec(spacing, currentY)
}
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(existingSlides.length + 1)) // +1 for the dragged slide
const colWidths = new Array(cols).fill(draggedSlide.props.w) // Initialize with dragged slide width
const rowHeights = new Array(Math.ceil((existingSlides.length + 1) / cols)).fill(draggedSlide.props.h) // Initialize with dragged slide height
// Calculate maximum dimensions for each column and row, including dragged slide dimensions
existingSlides.forEach((slide, i) => {
const row = Math.floor(i / cols)
const col = i % cols
colWidths[col] = Math.max(colWidths[col], slide.width)
rowHeights[row] = Math.max(rowHeights[row], slide.height)
})
// Calculate grid cell positions with dynamic cell sizes
let bestDistance = Infinity
let bestIndex = 0
let bestOffset = new Vec(0, 0)
for (let i = 0; i <= existingSlides.length; i++) {
const row = Math.floor(i / cols)
const col = i % cols
// Calculate cell position based on accumulated widths and heights
const cellX = spacing + colWidths.slice(0, col).reduce((sum, w) => sum + w + spacing, 0)
const cellY = headerHeight + contentPadding + spacing +
rowHeights.slice(0, row).reduce((sum, h) => sum + h + spacing, 0)
// Calculate distance to cell center
const cellCenterX = cellX + (colWidths[col] / 2)
const cellCenterY = cellY + (rowHeights[row] / 2)
const dx = point.x - cellCenterX
const dy = point.y - cellCenterY
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
bestOffset = new Vec(cellX, cellY)
}
}
return {
index: bestIndex,
offset: bestOffset
}
} else if (slideshow.props.slidePattern === 'radial') {
// Find the largest slide dimension including the dragged slide
const maxDimension = Math.max(
draggedSlide.props.w,
draggedSlide.props.h,
...existingSlides.map(s => Math.max(s.width, s.height))
)
const radius = maxDimension * 0.75
const center = new Vec(
spacing + radius,
headerHeight + contentPadding + spacing + radius
)
// Calculate angle from center to drag point using manual vector calculation
const dx = point.x - center.x
const dy = point.y - center.y
const angleToPoint = Math.atan2(dy, dx)
// Normalize angle to 0-2π range
const normalizedAngle = angleToPoint < 0 ? angleToPoint + 2 * Math.PI : angleToPoint
// Calculate insert index based on angle
const totalPositions = existingSlides.length + 1
const insertIndex = Math.floor((normalizedAngle * totalPositions) / (2 * Math.PI))
// Calculate position on circle for this index
const angle = (2 * Math.PI * insertIndex) / totalPositions
const x = center.x + (radius * Math.cos(angle))
const y = center.y + (radius * Math.sin(angle))
return {
index: insertIndex,
offset: new Vec(x, y)
}
}
// Default to end of slideshow if pattern not recognized
return {
index: existingSlides.length,
offset: new Vec(spacing, headerHeight + contentPadding + spacing)
}
}
onTranslateBinding(binding: CCSlideLayoutBinding, draggedShape: CCSlideShape, point: Vec): boolean {
const slideshow = this.editor.getShape<CCSlideShowShape>(binding.fromId)
if (!slideshow) return false
const { index } = this.getInsertPosition(slideshow, draggedShape, point)
const newIndex = `a${String(index + 1).padStart(3, '0')}` as IndexKey
this.editor.updateBinding({
id: binding.id,
type: 'cc-slide-layout',
fromId: binding.fromId,
toId: binding.toId,
props: { index: newIndex }
})
return true
}
override onAfterCreate({ binding }: { binding: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterCreate', { binding })
this.updateSlidePosition(binding)
this.updateSlideshowSize(binding.fromId)
}
override onAfterChange({ bindingAfter }: { bindingAfter: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterChange', { bindingAfter })
// Check if the slideshow still exists
const slideshow = this.editor.getShape<CCSlideShowShape>(bindingAfter.fromId)
if (!slideshow) return
this.updateSlidePosition(bindingAfter)
this.updateSlideshowSize(bindingAfter.fromId)
}
override onAfterChangeFromShape({ binding }: { binding: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterChangeFromShape', { binding })
// Check if the slideshow still exists
const slideshow = this.editor.getShape<CCSlideShowShape>(binding.fromId)
if (!slideshow) return
this.updateSlidePosition(binding)
this.updateSlideshowSize(binding.fromId)
}
override onAfterDelete({ binding }: { binding: CCSlideLayoutBinding }): void {
logger.debug('binding', '✅ onAfterDelete', { binding })
// Check if the slideshow still exists
const slideshow = this.editor.getShape<CCSlideShowShape>(binding.fromId)
if (!slideshow) return
this.updateSlideshowSize(binding.fromId)
}
}
@@ -0,0 +1,43 @@
import { BaseBoxShapeTool, StateNode } from '@tldraw/tldraw'
export class CCSlideShowShapeTool extends BaseBoxShapeTool {
static override id = 'cc-slideshow'
static override initial = 'idle'
override shapeType = 'cc-slideshow'
override onPointerDown = () => {
return this.transition('pointing')
}
override onPointerUp: StateNode['onPointerUp'] = () => {
const shape = this.editor.getSelectedShapes()[0]
if (shape?.type === 'cc-slideshow') {
// Switch to select tool after creating slideshow
this.editor.setCurrentTool('select')
}
return this.transition('idle')
}
}
export class CCSlideShapeTool extends BaseBoxShapeTool {
static override id = 'cc-slide'
static override initial = 'idle'
override shapeType = 'cc-slide'
override onPointerDown = () => {
// Check if there's a selected slideshow before allowing slide creation
const selectedShapes = this.editor.getSelectedShapes()
const slideshow = selectedShapes.find((s) => s.type === 'cc-slideshow')
if (!slideshow) {
this.editor.setCurrentTool('select')
return this.transition('idle')
}
return this.transition('pointing')
}
override onPointerUp: StateNode['onPointerUp'] = () => {
return this.transition('idle')
}
}
@@ -0,0 +1,338 @@
import { DefaultColorStyle, DefaultDashStyle, DefaultSizeStyle, Vec, getIndexBetween, clamp } from '@tldraw/tldraw'
import { ccShapeProps, getDefaultCCSlideProps, CCBaseProps } from '../cc-props'
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCSlideShowShape } from './CCSlideShowShapeUtil'
import { CCSlideLayoutBinding } from './CCSlideLayoutBindingUtil'
import { CC_BASE_STYLE_CONSTANTS, CC_SLIDESHOW_STYLE_CONSTANTS } from '../cc-styles'
import { ccShapeMigrations } from '../cc-migrations'
import { logger } from '../../../../debugConfig'
import { CCBaseShape } from '../cc-types'
type CCSlideProps = CCBaseProps & {
imageData?: string
meta: {
text: string
format: string
}
}
export interface CCSlideShape extends CCBaseShape {
type: 'cc-slide'
props: CCSlideProps
}
export class CCSlideShapeUtil extends CCBaseShapeUtil<CCSlideShape> {
static override type = 'cc-slide' as const
static override props = ccShapeProps.slide
static override migrations = ccShapeMigrations.slide
static styles = {
color: DefaultColorStyle,
dash: DefaultDashStyle,
size: DefaultSizeStyle,
}
override getDefaultProps(): CCSlideShape['props'] {
return getDefaultCCSlideProps() as CCSlideShape['props']
}
override canResize = () => false
override isAspectRatioLocked = () => true
override hideResizeHandles = () => true
override hideRotateHandle = () => true
override canEdit = () => false
override canBind(args: { fromShapeType: string; toShapeType: string; bindingType: string }): boolean {
return args.fromShapeType === 'cc-slideshow' && args.toShapeType === 'cc-slide' && args.bindingType === 'cc-slide-layout'
}
private getTargetSlideshow(shape: CCSlideShape, pageAnchor: Vec) {
return this.editor.getShapeAtPoint(pageAnchor, {
hitInside: true,
filter: (otherShape) =>
this.editor.canBindShapes({ fromShape: otherShape, toShape: shape, binding: 'cc-slide-layout' }),
}) as CCSlideShowShape | undefined
}
getBindingIndexForPosition(shape: CCSlideShape, slideshow: CCSlideShowShape, pageAnchor: Vec) {
// Get all non-placeholder bindings, sorted by index
const allBindings = this.editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.filter(b => !b.props.placeholder || b.toId === shape.id) // Include our binding if it's placeholder
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const spacing = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING
const headerHeight = CC_BASE_STYLE_CONSTANTS.HEADER.height
const contentPadding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING
// Calculate target order based on position
let order: number
if (slideshow.props.slidePattern === 'horizontal') {
order = clamp(
Math.round((pageAnchor.x - slideshow.x - spacing) / (shape.props.w + spacing)),
0,
allBindings.length
)
} else if (slideshow.props.slidePattern === 'vertical') {
order = clamp(
Math.round((pageAnchor.y - slideshow.y - headerHeight - contentPadding - spacing) / (shape.props.h + spacing)),
0,
allBindings.length
)
} else if (slideshow.props.slidePattern === 'grid') {
const cols = Math.ceil(Math.sqrt(allBindings.length))
const col = clamp(
Math.round((pageAnchor.x - slideshow.x - spacing) / (shape.props.w + spacing)),
0,
cols
)
const row = clamp(
Math.round((pageAnchor.y - slideshow.y - headerHeight - contentPadding - spacing) / (shape.props.h + spacing)),
0,
Math.ceil(allBindings.length / cols)
)
order = clamp(row * cols + col, 0, allBindings.length)
} else {
order = 0
}
// Get the bindings before and after our target position
const belowSib = allBindings[order - 1]
const aboveSib = allBindings[order]
// If we're already at this position, keep our current index
if (belowSib?.toId === shape.id) {
return belowSib.props.index
} else if (aboveSib?.toId === shape.id) {
return aboveSib.props.index
}
// Otherwise, get an index between the two siblings
return getIndexBetween(belowSib?.props.index, aboveSib?.props.index)
}
override onTranslateStart = (shape: CCSlideShape) => {
const bindings = this.editor.getBindingsToShape<CCSlideLayoutBinding>(shape.id, 'cc-slide-layout')
logger.debug('shape', '✅ onTranslateStart', {
shape,
bindings,
hasBindings: bindings.length > 0,
bindingTypes: bindings.map(b => ({
id: b.id,
fromId: b.fromId,
placeholder: b.props.placeholder,
isMovingWithParent: b.props.isMovingWithParent
}))
})
this.editor.updateBindings(
bindings.map((binding) => ({
...binding,
props: { ...binding.props, placeholder: true },
}))
)
}
override onTranslate = (initial: CCSlideShape, current: CCSlideShape) => {
const pageAnchor = this.editor.getShapePageTransform(current).applyToPoint({ x: current.props.w / 2, y: current.props.h / 2 })
const targetSlideshow = this.getTargetSlideshow(current, pageAnchor)
// Get current binding if any
const currentBindings = this.editor.getBindingsToShape<CCSlideLayoutBinding>(current.id, 'cc-slide-layout')
const currentBinding = currentBindings[0]
const currentSlideshow = currentBinding ? this.editor.getShape<CCSlideShowShape>(currentBinding.fromId) : undefined
logger.debug('shape', '✅ onTranslate', {
initial,
current,
hasTargetSlideshow: !!targetSlideshow,
targetSlideshowId: targetSlideshow?.id,
currentBindings: currentBindings.map(b => ({
id: b.id,
fromId: b.fromId,
placeholder: b.props.placeholder,
isMovingWithParent: b.props.isMovingWithParent
})),
isInSlideshow: targetSlideshow ? this.isSlideInSlideshow(current, targetSlideshow) : false
})
// If we're moving out of a slideshow
if (currentBinding && currentSlideshow && !this.isSlideInSlideshow(current, currentSlideshow)) {
logger.debug('shape', '✅ onTranslate: Moving out of slideshow', {
slideId: current.id,
slideshowId: currentSlideshow.id
})
// Delete all bindings
this.editor.deleteBindings(currentBindings)
return current
}
// If we have no target slideshow, return
if (!targetSlideshow) {
return current
}
// Calculate new index
const index = this.getBindingIndexForPosition(current, targetSlideshow, pageAnchor)
// If we have a current binding and it's for this slideshow
if (currentBinding && currentBinding.fromId === targetSlideshow.id) {
// Only update if index changed
if (currentBinding.props.index !== index) {
logger.debug('shape', '✅ onTranslate: Updating binding index', {
slideId: current.id,
slideshowId: targetSlideshow.id,
oldIndex: currentBinding.props.index,
newIndex: index
})
this.editor.updateBinding<CCSlideLayoutBinding>({
id: currentBinding.id,
type: currentBinding.type,
fromId: currentBinding.fromId,
toId: currentBinding.toId,
props: {
...currentBinding.props,
index,
isMovingWithParent: true,
},
})
}
} else if (this.isSlideInSlideshow(current, targetSlideshow)) {
logger.debug('shape', '✅ onTranslate: Creating new placeholder binding', {
slideId: current.id,
slideshowId: targetSlideshow.id,
index
})
// Create new placeholder binding if we're inside the slideshow
this.editor.createBinding<CCSlideLayoutBinding>({
type: 'cc-slide-layout',
fromId: targetSlideshow.id,
toId: current.id,
props: {
index,
isMovingWithParent: true,
placeholder: true,
},
})
}
return current
}
private isSlideInSlideshow(slide: CCSlideShape, slideshow: CCSlideShowShape): boolean {
const slideCenter = this.editor.getShapePageTransform(slide).applyToPoint({
x: slide.props.w / 2,
y: slide.props.h / 2
})
const slideshowBounds = this.editor.getShapeGeometry(slideshow).bounds
// Use smaller padding to be more lenient
const padding = CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING / 4
return (
slideCenter.x >= slideshow.x + padding &&
slideCenter.x <= slideshow.x + slideshowBounds.width - padding &&
slideCenter.y >= slideshow.y + padding &&
slideCenter.y <= slideshow.y + slideshowBounds.height - padding
)
}
override onTranslateEnd = (shape: CCSlideShape) => {
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: shape.props.w / 2, y: shape.props.h / 2 })
const targetSlideshow = this.getTargetSlideshow(shape, pageAnchor)
const bindings = this.editor.getBindingsToShape<CCSlideLayoutBinding>(shape.id, 'cc-slide-layout')
logger.debug('shape', '✅ onTranslateEnd', {
shape,
hasTargetSlideshow: !!targetSlideshow,
targetSlideshowId: targetSlideshow?.id,
bindings: bindings.map(b => ({
id: b.id,
fromId: b.fromId,
placeholder: b.props.placeholder,
isMovingWithParent: b.props.isMovingWithParent
})),
isInSlideshow: targetSlideshow ? this.isSlideInSlideshow(shape, targetSlideshow) : false
})
// If we have a target slideshow and the slide is inside it
if (targetSlideshow && this.isSlideInSlideshow(shape, targetSlideshow)) {
const index = this.getBindingIndexForPosition(shape, targetSlideshow, pageAnchor)
// Instead of deleting and recreating, update existing binding if it exists
const existingBinding = bindings[0]
if (existingBinding && existingBinding.fromId === targetSlideshow.id) {
logger.debug('shape', '✅ onTranslateEnd: Updating existing binding', {
slideId: shape.id,
slideshowId: targetSlideshow.id,
bindingId: existingBinding.id,
index
})
this.editor.updateBinding<CCSlideLayoutBinding>({
id: existingBinding.id,
type: existingBinding.type,
fromId: existingBinding.fromId,
toId: existingBinding.toId,
props: {
index,
isMovingWithParent: true,
placeholder: false,
},
})
} else {
logger.debug('shape', '✅ onTranslateEnd: Creating new binding', {
slideId: shape.id,
slideshowId: targetSlideshow.id,
index
})
// If no existing binding or from different slideshow, delete and create new
this.editor.deleteBindings(bindings)
this.editor.createBinding<CCSlideLayoutBinding>({
type: 'cc-slide-layout',
fromId: targetSlideshow.id,
toId: shape.id,
props: {
index,
isMovingWithParent: true,
placeholder: false,
},
})
}
} else {
logger.debug('shape', '✅ onTranslateEnd: Removing bindings', {
slideId: shape.id,
bindings: bindings.map(b => b.id)
})
// Just delete bindings if we're not in a slideshow
this.editor.deleteBindings(bindings)
}
}
override renderContent = (shape: CCSlideShape) => {
return (
<div style={{
width: '100%',
height: '100%',
position: 'relative',
backgroundColor: 'white',
borderRadius: '4px',
overflow: 'hidden'
}}>
{shape.props.imageData && (
<img
src={shape.props.imageData}
alt={shape.props.title}
style={{
width: '100%',
height: `100%`,
objectFit: 'contain',
position: 'absolute',
top: 0,
left: 0,
}}
/>
)}
</div>
)
}
}
@@ -0,0 +1,51 @@
import { DefaultColorStyle, DefaultDashStyle, DefaultSizeStyle } from '@tldraw/tldraw'
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { ccShapeProps, getDefaultCCSlideShowProps, CCBaseProps } from '../cc-props'
import { ccShapeMigrations } from '../cc-migrations'
import { CCBaseShape } from '../cc-types'
type CCSlideshowProps = CCBaseProps & {
currentSlideIndex: number
slidePattern: string
numSlides: number
slides: string[]
}
export interface CCSlideShowShape extends CCBaseShape {
type: 'cc-slideshow'
props: CCSlideshowProps
}
export class CCSlideShowShapeUtil extends CCBaseShapeUtil<CCSlideShowShape> {
static override type = 'cc-slideshow' as const
static override props = ccShapeProps.slideshow
static override migrations = ccShapeMigrations.slideshow
static styles = {
color: DefaultColorStyle,
dash: DefaultDashStyle,
size: DefaultSizeStyle,
}
getDefaultProps(): CCSlideShowShape['props'] {
return getDefaultCCSlideShowProps() as CCSlideShowShape['props']
}
override canResize = () => false
override isAspectRatioLocked = () => true
override hideResizeHandles = () => false
override hideRotateHandle = () => false
override canEdit = () => false
// eslint-disable-next-line @typescript-eslint/no-unused-vars
override canBind(args: { fromShapeType: string; toShapeType: string; bindingType: string }): boolean {
return true
}
onBeforeCreate(shape: CCSlideShowShape): CCSlideShowShape {
return shape
}
override renderContent = () => {
return <div />
}
}
@@ -0,0 +1,165 @@
import { Editor, atom, useEditor, useValue } from '@tldraw/tldraw'
import { CCSlideShowShape } from './CCSlideShowShapeUtil'
import { CCSlideShape } from './CCSlideShapeUtil'
import { logger } from '../../../../debugConfig'
import { CCSlideLayoutBinding } from './CCSlideLayoutBindingUtil'
// Atoms for tracking current slideshow and slide
export const $currentSlideShow = atom<CCSlideShowShape | null>('current slideshow', null)
export const $currentSlide = atom<CCSlideShape | null>('current slide', null)
// Helper functions for getting slides and slideshows
export function getSlidesFromPage(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s): s is CCSlideShape => s?.type === 'cc-slide')
}
export function getSlideShowsFromPage(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s): s is CCSlideShowShape => s?.type === 'cc-slideshow')
}
// Hooks for accessing slides and slideshows
export function useSlideShows() {
const editor = useEditor()
return useValue<CCSlideShowShape[]>('slideshow shapes', () => getSlideShowsFromPage(editor), [editor])
}
export function useSlides() {
const editor = useEditor()
return useValue<CCSlideShape[]>('slide shapes', () => getSlidesFromPage(editor), [editor])
}
export function useCurrentSlide() {
return useValue($currentSlide)
}
export function useCurrentSlideShow() {
return useValue($currentSlideShow)
}
// Navigation functions
export function moveToSlide(editor: Editor, slide: CCSlideShape, isPresentation: boolean = false) {
logger.info('navigation', '🎯 Moving to slide', {
slideId: slide.id,
currentProps: slide.props,
isPresentation,
timestamp: new Date().toISOString()
})
// Find the parent slideshow through bindings
const binding = editor.getBindingsToShape(slide.id, 'cc-slide-layout')[0]
if (!binding) {
logger.warn('navigation', '⚠️ No binding found for slide', { slideId: slide.id })
return
}
const parentSlideshow = editor.getShape(binding.fromId) as CCSlideShowShape
if (!parentSlideshow) {
logger.warn('navigation', '⚠️ No parent slideshow found for slide', { slideId: slide.id })
return
}
// Get all bindings for this slideshow, sorted by index
const bindings = editor
.getBindingsFromShape(parentSlideshow, 'cc-slide-layout')
.filter((b): b is CCSlideLayoutBinding => b.type === 'cc-slide-layout')
.filter(b => !b.props.placeholder)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Find the index of this slide's binding
const slideIndex = bindings.findIndex(b => b.toId === slide.id)
logger.debug('selection', '📍 Current slide position', {
slideId: slide.id,
slideIndex,
slideshowId: parentSlideshow.id,
totalSlides: bindings.length
})
editor.batch(() => {
logger.debug('navigation', '🔄 Starting slide transition', {
from: parentSlideshow.props.currentSlideIndex,
to: slideIndex
})
// Update the slideshow's currentSlideIndex and slides array
editor.updateShape<CCSlideShowShape>({
id: parentSlideshow.id,
type: 'cc-slideshow',
props: {
...parentSlideshow.props,
currentSlideIndex: slideIndex,
slides: bindings.map(b => b.toId),
numSlides: bindings.length
}
})
// Update UI atoms for state tracking
$currentSlide.set(slide)
$currentSlideShow.set(parentSlideshow)
})
logger.info('navigation', '✅ Slide transition complete', {
slideId: slide.id,
slideIndex,
slideshowId: parentSlideshow.id
})
}
export function moveToSlideShow(editor: Editor, slideshow: CCSlideShowShape, isPresentation: boolean = false) {
logger.info('navigation', '🎯 Moving to slideshow', {
slideshowId: slideshow.id,
currentIndex: slideshow.props.currentSlideIndex,
isPresentation,
timestamp: new Date().toISOString()
})
// Update current slideshow state
$currentSlideShow.set(slideshow)
// Get all bindings for this slideshow, sorted by index
const bindings = editor
.getBindingsFromShape(slideshow, 'cc-slide-layout')
.filter((b): b is CCSlideLayoutBinding => b.type === 'cc-slide-layout')
.filter(b => !b.props.placeholder)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Get the current slide based on currentSlideIndex
const currentBinding = bindings[slideshow.props.currentSlideIndex]
if (currentBinding) {
const currentSlide = editor.getShape(currentBinding.toId) as CCSlideShape
if (currentSlide) {
moveToSlide(editor, currentSlide, isPresentation)
} else {
logger.warn('navigation', '⚠️ Could not find current slide in slideshow', {
slideshowId: slideshow.id,
currentSlideId: currentBinding.toId,
currentIndex: slideshow.props.currentSlideIndex
})
}
}
}
// Helper functions for labels
export function getSlideLabel(slide: CCSlideShape, index: number) {
return `Slide ${index + 1} (${slide.id})`
}
export function getSlideShowLabel(slideshow: CCSlideShowShape, index: number) {
return `Slideshow ${index + 1} (${slideshow.id})`
}
// Current ID getters
export function getCurrentSlideId(): string | undefined {
const currentSlide = $currentSlide.get()
return currentSlide?.id
}
export function getCurrentSlideShowId(): string | undefined {
const currentSlideShow = $currentSlideShow.get()
return currentSlideShow?.id
}
+118
View File
@@ -0,0 +1,118 @@
// Style constants used by all CC shapes
export const CC_BASE_STYLE_CONSTANTS = {
FONT_FAMILY: 'Inter, sans-serif',
FONT_SIZES: {
small: 12,
medium: 14,
large: 16,
},
// Container styles
CONTAINER: {
borderRadius: '4px',
borderWidth: '2px',
borderColor: '#e2e8f0',
boxShadow: '0 2px 4px var(--color-muted-1)',
},
HEADER: {
height: 32,
padding: 8,
borderRadius: 4,
},
CONTENT: {
padding: 8,
borderRadius: 8,
borderWidth: 2,
backgroundColor: 'white',
},
HANDLE: {
width: 8,
},
COLORS: {
primary: '#3e6589',
primary_dark: '#2e4a69',
secondary: '#718096',
secondary_dark: '#5a687a',
background: '#ffffff',
border: '#e2e8f0',
text: '#1a202c',
textLight: '#718096',
},
// Minimum dimensions
MIN_DIMENSIONS: {
width: 100,
height: 100,
},
} as const
// Calendar specific styles
export const CC_CALENDAR_STYLE_CONSTANTS = {
// Common button styles
COMMON_BUTTON: {
border: 'none',
borderRadius: '5px',
padding: '0.4em 1em',
fontSize: '0.95em',
textTransform: 'uppercase',
letterSpacing: '0.05em',
cursor: 'pointer',
transition: 'background-color 0.3s ease',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
},
// Application button styles
APPLICATION_BUTTON: {
backgroundColor: '#4f80ff',
color: '#fff',
},
// Option button styles
OPTION_BUTTON: {
backgroundColor: '#f0f4f9',
color: '#2c3e50',
border: '1px solid #ddd',
},
// Calendar event styles
EVENT: {
mainFrame: {
backgroundColor: 'transparent',
padding: '0px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100%',
borderRadius: '4px',
},
title: {
fontSize: '1.1em',
fontWeight: 'normal',
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 1,
padding: '0px 0px',
width: '100%',
letterSpacing: '0.02em',
margin: '0px 0px',
}
}
} as const
// Slideshow specific styles
export const CC_SLIDESHOW_STYLE_CONSTANTS = {
DEFAULT_SLIDE_WIDTH: 1280,
DEFAULT_SLIDE_HEIGHT: 720,
SLIDE_HEADER_HEIGHT: 32,
SLIDE_HEADER_PADDING: 8,
SLIDE_CONTENT_PADDING: 0,
SLIDE_BORDER_RADIUS: 4,
SLIDE_BORDER_WIDTH: 1,
SLIDE_SPACING: 16,
SLIDE_COLORS: {
background: '#ffffff',
border: '#e2e8f0',
text: '#ffffff',
secondary: '#718096',
},
} as const
@@ -0,0 +1,342 @@
import React from 'react';
import { CCBaseShapeUtil } from '../CCBaseShapeUtil';
import { TLShapeId } from '@tldraw/tldraw';
import { CCBaseShape } from '../cc-types';
import { TranscriptionManager } from '../cc-transcription/TranscriptionManager';
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
import { ccShapeMigrations } from '../cc-migrations';
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
export interface TranscriptionSegment {
id: string
text: string
completed: boolean
start: string
end: string
}
export interface CCLiveTranscriptionShape extends CCBaseShape {
type: 'cc-live-transcription'
props: {
title: string
w: number
h: number
headerColor: string
backgroundColor: string
isLocked: boolean
isRecording: boolean
segments: TranscriptionSegment[]
currentSegment?: TranscriptionSegment
lastProcessedSegment?: string // Track last processed segment to avoid duplicates
}
}
export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscriptionShape> {
static override type = 'cc-live-transcription' as const;
static override props = ccShapeProps.liveTranscription;
static override migrations = ccShapeMigrations.liveTranscription;
override getDefaultProps(): CCLiveTranscriptionShape['props'] {
return getDefaultCCLiveTranscriptionProps() as unknown as CCLiveTranscriptionShape['props'];
}
override renderContent = (shape: CCLiveTranscriptionShape) => {
return this.renderShapeContent(shape)
}
renderShapeContent = (shape: CCLiveTranscriptionShape) => {
const { isRecording, segments, currentSegment } = shape.props;
const contentHeight = shape.props.h - CC_BASE_STYLE_CONSTANTS.HEADER.height - 2 * CC_BASE_STYLE_CONSTANTS.CONTENT.padding;
const controlsHeight = 80;
const transcriptHeight = contentHeight - controlsHeight;
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'all'
}}>
{/* Microphone Controls */}
<div style={{
height: controlsHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
borderBottom: '1px solid #e0e0e0',
padding: '8px'
}}>
<div
role="button"
tabIndex={0}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
this.toggleRecording(shape);
}}
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
border: 'none',
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
color: 'white',
fontSize: '24px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
transition: 'all 0.3s ease',
userSelect: 'none',
}}
>
{isRecording ? '⏹' : '🎤'}
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: isRecording ? '#f44336' : '#4CAF50'
}}>
{isRecording ? 'Recording...' : 'Ready'}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{isRecording ? 'Click to stop' : 'Click to start recording'}
</div>
</div>
</div>
{/* Transcription Content */}
<AutoScrollContainer height={transcriptHeight}>
{/* Completed Segments */}
{segments.map((segment) => (
<div
key={segment.id}
style={{
padding: '8px',
backgroundColor: '#FFF',
borderRadius: '8px',
fontSize: '14px',
lineHeight: '1.4',
color: '#000000',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
width: '100%',
transition: 'color 0.3s ease',
display: 'flex',
flexDirection: 'column',
gap: '2px'
}}
>
<div style={{
fontSize: '11px',
color: '#888',
fontFamily: 'monospace'
}}>
{segment.start}s - {segment.end}s
</div>
<div>
{segment.text}
</div>
</div>
))}
{/* Current Segment */}
{currentSegment && (
<div
style={{
padding: '8px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
fontSize: '14px',
lineHeight: '1.4',
color: '#666666',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
width: '100%',
transition: 'color 0.3s ease',
display: 'flex',
flexDirection: 'column',
gap: '2px'
}}
>
<div style={{
fontSize: '11px',
color: '#888',
fontFamily: 'monospace'
}}>
{currentSegment.start}s - {currentSegment.end}s
</div>
<div>
{currentSegment.text}
</div>
</div>
)}
{/* Initial State */}
{!isRecording && segments.length === 0 && !currentSegment && (
<div
style={{
padding: '8px',
backgroundColor: '#FFF',
borderRadius: '8px',
fontSize: '18px',
lineHeight: '1.5',
color: '#666666',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
width: '100%',
textAlign: 'center'
}}
>
Click the microphone to start
</div>
)}
</AutoScrollContainer>
</div>
)
}
private toggleRecording(shape: CCLiveTranscriptionShape) {
console.log('🎤 Toggle recording clicked');
const { id } = shape;
const { isRecording } = shape.props;
console.log('Current state:', { id, isRecording });
// When starting new recording, preserve existing props but reset segments
const newProps = !isRecording ? {
...shape.props,
isRecording: true,
segments: [],
currentSegment: undefined,
lastProcessedSegment: undefined,
} : {
...shape.props,
isRecording: false,
};
this.editor.updateShape<CCLiveTranscriptionShape>({
id,
type: 'cc-live-transcription',
props: newProps,
});
const manager = TranscriptionManager.getManager(this.editor);
console.log('Got transcription manager');
if (!isRecording) {
console.log('Starting transcription...');
manager.startTranscription(id);
} else {
console.log('Stopping transcription...');
manager.stopTranscription();
}
}
updateText(
id: TLShapeId,
text: string,
isConfirmed: boolean,
metadata?: { start: string | number, end: string | number }
) {
console.log('📝 Updating text:', { id, text, isConfirmed, metadata });
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
if (!shape) {
console.warn('❌ Shape not found for updating text:', id);
return;
}
// Format timestamps consistently
const start = typeof metadata?.start === 'number' ? metadata.start.toFixed(3) : metadata?.start;
const end = typeof metadata?.end === 'number' ? metadata.end.toFixed(3) : metadata?.end;
const segmentId = isConfirmed ? crypto.randomUUID() : 'current';
const newSegment: TranscriptionSegment = {
id: segmentId,
text,
completed: isConfirmed,
start: start || '0.000',
end: end || '0.000'
};
// Handle current (incomplete) segment
if (!isConfirmed) {
// Only update if text has changed
if (shape.props.currentSegment?.text !== text) {
this.editor.updateShape<CCLiveTranscriptionShape>({
id,
type: 'cc-live-transcription',
props: {
...shape.props,
currentSegment: newSegment
},
});
}
return;
}
// Handle completed segment
let segments = [...shape.props.segments];
// Check if this segment is different from the last processed one
// and not already in our segments list
const isDuplicate = segments.some(s => s.text === text);
if (shape.props.lastProcessedSegment !== text && !isDuplicate) {
// Add new completed segment
segments.push(newSegment);
this.editor.updateShape<CCLiveTranscriptionShape>({
id,
type: 'cc-live-transcription',
props: {
...shape.props,
segments,
lastProcessedSegment: text,
// Clear current segment if it matches the completed one
currentSegment: shape.props.currentSegment?.text === text ? undefined : shape.props.currentSegment
},
});
}
console.log('✅ Text updated');
}
}
// Auto-scrolling container component
function AutoScrollContainer({ children, height }: { children: React.ReactNode, height: number }) {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [children]);
return (
<div
ref={containerRef}
style={{
height,
padding: '16px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
gap: '8px',
overflow: 'auto',
scrollBehavior: 'smooth'
}}
>
{children}
</div>
);
}
@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CCLiveTranscriptionShapeUtil } from './CCLiveTranscriptionShapeUtil';
import { TranscriptionService } from './transcriptionService';
import { getDefaultCCLiveTranscriptionProps } from '../cc-props';
import { CCLiveTranscriptionShape } from './CCLiveTranscriptionShapeUtil';
import { TLShapeId, IndexKey, TLParentId } from '@tldraw/tldraw';
import { Editor } from '@tldraw/editor';
const meta: Meta = {
title: 'Components/Transcription',
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof meta>;
// Mock editor
const mockEditor = {
updateShape: () => {},
} as unknown as Editor;
// Mock shape data
const mockShape: CCLiveTranscriptionShape = {
id: 'shape:1' as TLShapeId,
type: 'cc-live-transcription',
x: 0,
y: 0,
rotation: 0,
index: 'a1' as IndexKey,
parentId: 'page:page' as TLParentId,
isLocked: false,
opacity: 1,
meta: {},
typeName: 'shape',
props: getDefaultCCLiveTranscriptionProps(),
};
// Basic story showing the transcription component
export const Default: Story = {
args: {},
render: () => {
const shapeUtil = new CCLiveTranscriptionShapeUtil(mockEditor);
return (
<div style={{ width: '800px', height: '600px', border: '1px solid #ccc' }}>
{shapeUtil.renderShapeContent(mockShape)}
</div>
);
},
};
// Story showing the transcription service in action
export const WithService: Story = {
args: {},
render: () => {
const service = new TranscriptionService('default');
const shapeUtil = new CCLiveTranscriptionShapeUtil(mockEditor);
return (
<div style={{ width: '800px', height: '600px', border: '1px solid #ccc' }}>
<div style={{ padding: '20px' }}>
<h3>Transcription Service Demo</h3>
<button
onClick={() => service.startTranscription()}
style={{ marginRight: '10px', padding: '10px' }}
>
Start Transcription
</button>
<button
onClick={() => service.stopTranscription()}
style={{ padding: '10px' }}
>
Stop Transcription
</button>
</div>
<div style={{ marginTop: '20px' }}>
{shapeUtil.renderShapeContent(mockShape)}
</div>
</div>
);
},
};
@@ -0,0 +1,73 @@
import { Editor, TLShapeId } from '@tldraw/tldraw';
import { TranscriptionService } from './transcriptionService';
import { CCLiveTranscriptionShapeUtil } from './CCLiveTranscriptionShapeUtil';
export class TranscriptionManager {
private static instances = new WeakMap<Editor, TranscriptionManager>();
private transcriptionService?: TranscriptionService;
private currentShapeId?: TLShapeId;
private sameOutputCount = 0;
private lastText = '';
private readonly SAME_OUTPUT_THRESHOLD = 10;
constructor(private editor: Editor) {}
static getManager(editor: Editor): TranscriptionManager {
let manager = TranscriptionManager.instances.get(editor);
if (!manager) {
manager = new TranscriptionManager(editor);
TranscriptionManager.instances.set(editor, manager);
}
return manager;
}
startTranscription(shapeId: TLShapeId) {
console.log('Starting transcription...');
this.currentShapeId = shapeId;
this.transcriptionService = new TranscriptionService();
// Set up callback for transcription updates
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
console.log('📝 Transcription update received:', { text, metadata });
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
if (!util) {
console.warn('❌ Shape util not found');
return;
}
console.log('Found transcription util:', !!util);
// Check if text is stable (same output multiple times)
const isStable = text === this.lastText;
if (isStable) {
this.sameOutputCount++;
} else {
this.sameOutputCount = 0;
this.lastText = text;
}
// Mark as completed if we've seen the same output multiple times or if marked as final
const isCompleted = isFinal || this.sameOutputCount >= this.SAME_OUTPUT_THRESHOLD;
util.updateText(
this.currentShapeId!,
text,
isCompleted,
metadata
);
});
// Start the transcription service
this.transcriptionService.startTranscription();
}
stopTranscription() {
console.log('Stopping transcription...');
if (this.transcriptionService) {
this.transcriptionService.stopTranscription();
this.transcriptionService = undefined;
}
this.currentShapeId = undefined;
this.sameOutputCount = 0;
this.lastText = '';
}
}
@@ -0,0 +1,227 @@
import logger from '../../../../debugConfig';
export interface TranscriptionConfig {
language?: string;
task?: string;
modelSize?: string;
useVad?: boolean;
}
export class TranscriptionService {
private socket: WebSocket | null = null;
private stream: MediaStream | null = null;
private audioContext: AudioContext | null = null;
private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
private workletNode: AudioWorkletNode | null = null;
private selectedDeviceId: string = '';
private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null;
constructor(deviceId: string = '') {
this.selectedDeviceId = deviceId;
}
setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) {
this.onTranscriptionUpdate = callback;
}
async startTranscription(config: TranscriptionConfig = {}) {
console.log('🎙️ Starting transcription service...');
try {
// Get default audio device if none selected
if (!this.selectedDeviceId) {
console.log('No device selected, getting default device...');
const devices = await navigator.mediaDevices.enumerateDevices();
const audioDevice = devices.find(device => device.kind === 'audioinput');
if (audioDevice) {
this.selectedDeviceId = audioDevice.deviceId;
console.log('Found default audio device:', audioDevice.label);
}
}
if (!this.selectedDeviceId) {
logger.error('transcription-service', '⚠️ No audio device available');
return;
}
logger.info('transcription-service', '🔊 Accessing user media...');
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: this.selectedDeviceId },
});
console.log('Got audio stream');
const uuid = crypto.randomUUID();
const wsUrl = import.meta.env.VITE_WHISPERLIVE_URL;
console.log('Connecting to WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
this.socket = ws;
const connectionTimeout = setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) {
logger.error('transcription-service', '⏰ WebSocket connection timed out');
ws.close();
}
}, 20000);
ws.onopen = () => {
clearTimeout(connectionTimeout);
logger.info('transcription-service', '✅ WebSocket connected');
// Send initial configuration message
const message = JSON.stringify({
uid: uuid,
language: config.language || 'en',
task: config.task || 'transcribe',
model: config.modelSize || 'small',
use_vad: config.useVad ?? true,
});
ws.send(message);
this.setupAudioProcessing();
};
ws.onerror = (error) => {
logger.error('transcription-service', '❌ WebSocket error:', error);
};
ws.onclose = () => {
logger.info('transcription-service', '🔌 WebSocket closed');
this.cleanup();
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.uid !== uuid) {
return;
}
if (data.status === 'WAIT') {
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
this.cleanup();
return;
}
if (data.message === 'DISCONNECT') {
logger.info('transcription-service', '🔕 Server requested disconnection');
this.cleanup();
return;
}
if (this.onTranscriptionUpdate && data.segments) {
// Get the last segment which is the current one being updated
const lastSegment = data.segments[data.segments.length - 1];
// Process completed segments
let lastCompletedText = '';
for (let i = 0; i < data.segments.length - 1; i++) {
const segment = data.segments[i];
// Only send update if this segment is different from the last one
if (segment.text.trim() !== lastCompletedText.trim()) {
this.onTranscriptionUpdate(
segment.text,
segment.completed ?? true, // Server marks completed segments
{
start: parseFloat(segment.start),
end: parseFloat(segment.end)
}
);
lastCompletedText = segment.text;
}
}
// Update the current (incomplete) segment only if it's different from the last completed one
if (lastSegment && lastSegment.text.trim() !== lastCompletedText.trim()) {
this.onTranscriptionUpdate(
lastSegment.text,
lastSegment.completed ?? false, // Last segment is typically incomplete unless marked otherwise
{
start: parseFloat(lastSegment.start),
end: parseFloat(lastSegment.end)
}
);
}
}
};
} catch (error) {
logger.error('transcription-service', '❌ Error starting transcription:', error);
this.cleanup();
}
}
private async setupAudioProcessing() {
if (!this.stream || !this.socket) {
return;
}
try {
this.audioContext = new AudioContext();
// Load and register the audio worklet
await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
// Handle audio data from the worklet
this.workletNode.port.onmessage = (event) => {
if (this.socket?.readyState === WebSocket.OPEN) {
const resampledData = this.resampleTo16kHZ(event.data, this.audioContext!.sampleRate);
this.socket.send(resampledData);
}
};
this.mediaStreamSource.connect(this.workletNode);
this.workletNode.connect(this.audioContext.destination);
} catch (error) {
console.error('Error setting up audio processing:', error);
}
}
private resampleTo16kHZ(audioData: Float32Array, origSampleRate: number): Float32Array {
const ratio = origSampleRate / 16000;
const newLength = Math.round(audioData.length / ratio);
const result = new Float32Array(newLength);
for (let i = 0; i < newLength; i++) {
const pos = i * ratio;
const leftPos = Math.floor(pos);
const rightPos = Math.ceil(pos);
const weight = pos - leftPos;
result[i] = audioData[leftPos] * (1 - weight) + (audioData[rightPos] || 0) * weight;
}
return result;
}
stopTranscription() {
this.cleanup();
}
private cleanup() {
if (this.workletNode) {
this.workletNode.disconnect();
this.workletNode = null;
}
if (this.mediaStreamSource) {
this.mediaStreamSource.disconnect();
this.mediaStreamSource = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
import { TLBaseShape } from '@tldraw/tldraw'
import { CCBaseProps } from './cc-props'
export interface CCBaseShape extends TLBaseShape<string, CCBaseProps> {}
@@ -0,0 +1,98 @@
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { ccShapeProps, getDefaultCCWebBrowserProps } from '../cc-props'
import { ccShapeMigrations } from '../cc-migrations'
import { Rectangle2d } from 'tldraw'
import { WebBrowserComponent } from './WebBrowserComponent'
import { customEmbeds } from '../../embeds'
export interface CCWebBrowserShape extends CCBaseShape {
type: 'cc-web-browser'
props: CCBaseShape['props'] & {
url: string
history: string[]
currentHistoryIndex: number
isLoading: boolean
}
}
export class CCWebBrowserShapeUtil extends CCBaseShapeUtil<CCWebBrowserShape> {
static override type = 'cc-web-browser' as const;
static override props = ccShapeProps.webBrowser;
static override migrations = ccShapeMigrations.webBrowser;
override getDefaultProps(): CCWebBrowserShape['props'] {
return getDefaultCCWebBrowserProps() as CCWebBrowserShape['props'];
}
override isAspectRatioLocked = () => false
override canResize = () => true
override canBind = () => false
override hideResizeHandles = () => false
override hideRotateHandle = () => false
override canEdit = () => false
override renderContent = (shape: CCWebBrowserShape) => {
return (
<div style={{ width: '100%', height: '100%', pointerEvents: 'all' }}>
<WebBrowserComponent shape={shape} />
</div>
)
}
override getGeometry(shape: CCWebBrowserShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
override onResize = (
shape: CCWebBrowserShape,
info: { initialShape: CCWebBrowserShape; scaleX: number; scaleY: number }
) => {
const { initialShape, scaleX, scaleY } = info
const newW = Math.max(400, Math.round(initialShape.props.w * scaleX))
const newH = Math.max(300, Math.round(initialShape.props.h * scaleY))
return {
props: {
...shape.props,
w: newW,
h: newH,
},
}
}
shouldRender = (prev: CCWebBrowserShape, next: CCWebBrowserShape) => {
return (
prev.props.w !== next.props.w ||
prev.props.h !== next.props.h ||
prev.props.url !== next.props.url ||
prev.props.isLoading !== next.props.isLoading ||
prev.props.currentHistoryIndex !== next.props.currentHistoryIndex ||
prev.props.history.length !== next.props.history.length
)
}
static isEmbeddableUrl(url: string): { isEmbeddable: boolean; embedType?: string } {
try {
const urlObj = new URL(url);
// Check against our custom embeds
for (const embed of customEmbeds) {
if (embed.hostnames.some(hostname =>
urlObj.hostname === hostname ||
urlObj.hostname.endsWith(`.${hostname}`)
)) {
return { isEmbeddable: true, embedType: embed.type };
}
}
return { isEmbeddable: false };
} catch (e) {
return { isEmbeddable: false };
}
}
}
@@ -0,0 +1,200 @@
import React, { useState } from 'react'
import { useEditor } from '@tldraw/tldraw'
import { IconButton, TextField, CircularProgress } from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
import RefreshIcon from '@mui/icons-material/Refresh'
import { CCWebBrowserShape } from './CCWebBrowserUtil'
interface WebBrowserComponentProps {
shape: CCWebBrowserShape
}
export const WebBrowserComponent: React.FC<WebBrowserComponentProps> = ({ shape }) => {
const editor = useEditor()
const [urlInput, setUrlInput] = useState(shape.props.url)
const [error, setError] = useState<string | null>(null)
const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError(null)
const newUrl = urlInput.startsWith('http') ? urlInput : `https://${urlInput}`
editor.updateShape({
id: shape.id,
type: 'cc-web-browser',
props: {
...shape.props,
url: newUrl,
history: [...shape.props.history.slice(0, shape.props.currentHistoryIndex + 1), newUrl],
currentHistoryIndex: shape.props.currentHistoryIndex + 1,
isLoading: true,
},
})
}
const handleBack = () => {
if (shape.props.currentHistoryIndex > 0) {
setError(null)
const newIndex = shape.props.currentHistoryIndex - 1
editor.updateShape({
id: shape.id,
type: 'cc-web-browser',
props: {
...shape.props,
url: shape.props.history[newIndex],
currentHistoryIndex: newIndex,
isLoading: true,
},
})
}
}
const handleForward = () => {
if (shape.props.currentHistoryIndex < shape.props.history.length - 1) {
setError(null)
const newIndex = shape.props.currentHistoryIndex + 1
editor.updateShape({
id: shape.id,
type: 'cc-web-browser',
props: {
...shape.props,
url: shape.props.history[newIndex],
currentHistoryIndex: newIndex,
isLoading: true,
},
})
}
}
const handleRefresh = () => {
setError(null)
editor.updateShape({
id: shape.id,
type: 'cc-web-browser',
props: {
...shape.props,
isLoading: true,
},
})
}
const handleIframeLoad = () => {
editor.updateShape({
id: shape.id,
type: 'cc-web-browser',
props: {
...shape.props,
isLoading: false,
},
})
}
const handleIframeError = () => {
setError('Failed to load the webpage. This might be due to security restrictions or the website not allowing embedding.')
editor.updateShape({
id: shape.id,
type: 'cc-web-browser',
props: {
...shape.props,
isLoading: false,
},
})
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
display: 'flex',
gap: '8px',
padding: '8px',
alignItems: 'center',
backgroundColor: '#f5f5f5',
borderBottom: '1px solid #ddd'
}}>
<IconButton
size="small"
onClick={handleBack}
disabled={shape.props.currentHistoryIndex <= 0}
>
<ArrowBackIcon />
</IconButton>
<IconButton
size="small"
onClick={handleForward}
disabled={shape.props.currentHistoryIndex >= shape.props.history.length - 1}
>
<ArrowForwardIcon />
</IconButton>
<IconButton
size="small"
onClick={handleRefresh}
>
<RefreshIcon />
</IconButton>
<form onSubmit={handleUrlSubmit} style={{ flex: 1 }}>
<TextField
fullWidth
size="small"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder="Enter URL or search..."
error={!!error}
helperText={error}
/>
</form>
</div>
<div style={{
position: 'relative',
flex: 1,
backgroundColor: '#fff'
}}>
{shape.props.isLoading && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
zIndex: 1
}}>
<CircularProgress />
</div>
)}
{shape.props.url && (
<iframe
src={shape.props.url}
style={{
width: '100%',
height: '100%',
border: 'none'
}}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
onLoad={handleIframeLoad}
onError={handleIframeError}
title="Web Browser"
/>
)}
{error && !shape.props.isLoading && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
color: '#d32f2f',
padding: '16px'
}}>
{error}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,247 @@
import React, { useEffect, useRef, useState, memo } from 'react'
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
import { CCBaseShape } from '../cc-types'
import { CC_YOUTUBE_EMBED_STYLE_CONSTANTS } from './cc-youtube-embed-styles'
import { getYoutubeTranscript, extractVideoId } from './youtubeService'
import { formatTime, Player, OnStateChangeEvent, PlayerState } from './cc-youtube-embed-helpers'
import { ccShapeProps } from '../cc-props'
import { getDefaultCCYoutubeEmbedProps } from '../cc-props'
// Add YouTube types to the global Window interface
declare global {
interface Window {
YT: {
Player: new (
elementId: string,
config: {
events?: {
onReady?: () => void
onStateChange?: (event: OnStateChangeEvent) => void
}
}
) => Player
PlayerState: PlayerState
}
onYouTubeIframeAPIReady: () => void
}
}
interface TranscriptLine {
start: number
duration: number
text: string
}
export interface CCYoutubeEmbedShape extends CCBaseShape {
type: 'cc-youtube-embed'
props: {
title: string
w: number
h: number
headerColor: string
backgroundColor: string
isLocked: boolean
video_url: string
transcript: TranscriptLine[]
transcriptVisible: boolean
}
}
const YoutubeEmbed = memo(({ shape }: { shape: CCYoutubeEmbedShape }) => {
const [transcript, setTranscript] = useState<TranscriptLine[]>(shape.props.transcript)
const [currentTime, setCurrentTime] = useState<number>(0)
const [currentLineIndex, setCurrentLineIndex] = useState<number | null>(null)
const transcriptRef = useRef<HTMLDivElement | null>(null)
const hasFetchedTranscript = useRef<boolean>(false)
const playerRef = useRef<Player | null>(null)
const timeUpdateIntervalRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const fetchTranscript = async () => {
if (hasFetchedTranscript.current) return
try {
const transcriptData = await getYoutubeTranscript(shape.props.video_url)
hasFetchedTranscript.current = true
setTranscript(transcriptData)
} catch (error) {
console.error('Error fetching transcript:', error)
}
}
fetchTranscript()
return () => {
hasFetchedTranscript.current = false
}
}, [shape.props.video_url])
useEffect(() => {
const onYouTubeIframeAPIReady = () => {
if (playerRef.current) return
playerRef.current = new window.YT.Player('youtube-player', {
events: {
'onReady': () => console.log('YouTube player is ready'),
'onStateChange': (event: OnStateChangeEvent) => {
if (event.data === window.YT.PlayerState.PLAYING) {
startTimeUpdates()
} else {
stopTimeUpdates()
}
}
}
})
}
const startTimeUpdates = () => {
timeUpdateIntervalRef.current = setInterval(() => {
if (playerRef.current?.getCurrentTime) {
const time = playerRef.current.getCurrentTime()
setCurrentTime(time)
}
}, 100)
}
const stopTimeUpdates = () => {
if (timeUpdateIntervalRef.current) {
clearInterval(timeUpdateIntervalRef.current)
timeUpdateIntervalRef.current = null
}
}
// Load YouTube iframe API
if (!document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) {
const tag = document.createElement('script')
tag.src = "https://www.youtube.com/iframe_api"
const firstScriptTag = document.getElementsByTagName('script')[0]
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag)
}
window.onYouTubeIframeAPIReady = onYouTubeIframeAPIReady
return () => {
stopTimeUpdates()
if (playerRef.current?.destroy) {
playerRef.current.destroy()
}
playerRef.current = null
}
}, [shape.props.video_url])
useEffect(() => {
const newCurrentLineIndex = transcript.findIndex(
(line) => {
const lineEndTime = line.start + line.duration;
return currentTime >= line.start && currentTime <= lineEndTime;
}
)
if (newCurrentLineIndex !== -1 && newCurrentLineIndex !== currentLineIndex) {
setCurrentLineIndex(newCurrentLineIndex)
// Scroll the active line into view
if (transcriptRef.current) {
const transcriptContainer = transcriptRef.current
const lineElement = transcriptContainer.children[newCurrentLineIndex] as HTMLElement
if (lineElement) {
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
}, [currentTime, transcript, currentLineIndex])
return (
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
<div style={CC_YOUTUBE_EMBED_STYLE_CONSTANTS.VIDEO.container}>
<iframe
id="youtube-player"
style={{
...CC_YOUTUBE_EMBED_STYLE_CONSTANTS.VIDEO.iframe,
pointerEvents: 'all'
}}
src={`https://www.youtube.com/embed/${extractVideoId(shape.props.video_url)}?enablejsapi=1`}
allowFullScreen
/>
</div>
{shape.props.transcriptVisible && (
<div
ref={transcriptRef}
style={{
...CC_YOUTUBE_EMBED_STYLE_CONSTANTS.TRANSCRIPT.container,
pointerEvents: 'all'
}}
>
<h3 style={CC_YOUTUBE_EMBED_STYLE_CONSTANTS.TRANSCRIPT.title}>Transcript</h3>
{transcript.length > 0 ? (
transcript.map((line, index) => (
<div
key={index}
style={{
...CC_YOUTUBE_EMBED_STYLE_CONSTANTS.TRANSCRIPT.line,
...(index === currentLineIndex ? CC_YOUTUBE_EMBED_STYLE_CONSTANTS.TRANSCRIPT.activeLine : {})
}}
>
<span style={CC_YOUTUBE_EMBED_STYLE_CONSTANTS.TRANSCRIPT.timestamp}>
{formatTime(line.start)}:
</span>
{line.text}
</div>
))
) : (
<p style={CC_YOUTUBE_EMBED_STYLE_CONSTANTS.TRANSCRIPT.loading}>Loading transcript...</p>
)}
</div>
)}
</div>
)
})
export class CCYoutubeEmbedShapeUtil extends CCBaseShapeUtil<CCYoutubeEmbedShape> {
static override type = 'cc-youtube-embed'
static override props = ccShapeProps['cc-youtube-embed']
override getDefaultProps(): CCYoutubeEmbedShape['props'] {
return getDefaultCCYoutubeEmbedProps()
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
override isAspectRatioLocked(shape: CCYoutubeEmbedShape) {
return true
}
override getToolbarItems(shape: CCYoutubeEmbedShape) {
return [
{
id: 'toggle-transcript',
icon: shape.props.transcriptVisible ? '📖' : '📕',
label: shape.props.transcriptVisible ? 'Hide Transcript' : 'Show Transcript',
onClick: (e: React.MouseEvent, baseShape: CCBaseShape) => {
console.log('Toggle transcript clicked')
e.preventDefault()
e.stopPropagation()
const youtubeShape = baseShape as CCYoutubeEmbedShape
console.log('Current visibility:', youtubeShape.props.transcriptVisible)
const newProps = {
...youtubeShape.props,
transcriptVisible: !youtubeShape.props.transcriptVisible
}
console.log('New visibility:', newProps.transcriptVisible)
this.editor.updateShape({
id: youtubeShape.id,
type: 'cc-youtube-embed',
props: newProps
})
},
isActive: shape.props.transcriptVisible,
}
]
}
override renderContent = (shape: CCYoutubeEmbedShape) => {
return <YoutubeEmbed shape={shape} />
}
}
@@ -0,0 +1,20 @@
export interface Player {
getCurrentTime(): number;
destroy(): void;
}
export interface OnStateChangeEvent {
data: number;
}
export interface PlayerState {
PLAYING: number;
PAUSED: number;
ENDED: number;
}
export function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
@@ -0,0 +1,78 @@
export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
VIDEO: {
container: {
flex: 2,
padding: '10px',
minWidth: '300px',
},
iframe: {
width: '100%',
height: '100%',
border: 'none',
},
},
TRANSCRIPT: {
container: {
flex: 1,
padding: '10px',
overflowY: 'auto',
maxHeight: '100%',
minWidth: '200px',
backgroundColor: '#f5f5f5',
borderLeft: '1px solid #ddd',
},
title: {
margin: '0 0 10px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333',
},
line: {
padding: '5px',
marginBottom: '5px',
borderRadius: '4px',
fontSize: '14px',
color: '#333',
backgroundColor: '#fff',
border: '1px solid #eee',
},
activeLine: {
backgroundColor: '#e3f2fd',
border: '1px solid #2196f3',
color: '#1565c0',
fontWeight: 'bold',
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
transform: 'scale(1.02)',
transition: 'all 0.2s ease-in-out',
},
timestamp: {
color: '#666',
marginRight: '8px',
fontWeight: 'bold',
},
loading: {
color: '#666',
fontStyle: 'italic',
},
},
TOOLS: {
container: {
padding: '10px',
display: 'flex',
flexDirection: 'column' as const,
gap: '10px',
},
button: {
padding: '8px 12px',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
color: '#333',
fontSize: '14px',
'&:hover': {
backgroundColor: '#e0e0e0',
},
},
},
} as const
@@ -0,0 +1,29 @@
import axios from './../../../../axiosConfig';
export interface TranscriptLine {
start: number
duration: number
text: string
}
export async function getYoutubeTranscript(videoUrl: string): Promise<TranscriptLine[]> {
try {
const videoId = extractVideoId(videoUrl)
if (!videoId) {
throw new Error('Invalid YouTube URL')
}
const response = await axios.get(`/external/youtube-proxy?videoId=${videoId}`)
console.log('Got Youtube video data:', response.data)
return response.data.transcript
} catch (error) {
console.error('Error fetching YouTube video data:', error)
throw error
}
}
export function extractVideoId(url: string): string | null {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/
const match = url.match(regex)
return match ? match[1] : null
}
@@ -0,0 +1,41 @@
import { Editor, TLShapeId, createShapeId } from '@tldraw/tldraw'
import { CC_SHAPE_CONFIGS } from '../cc-configs'
export const createCalendarShape = (
editor: Editor,
baseProps: {
id: TLShapeId
x: number
y: number
rotation: number
isLocked: boolean
}
) => {
const config = CC_SHAPE_CONFIGS['cc-calendar']
editor.createShape({
...baseProps,
type: 'cc-calendar',
props: {
...config.defaultProps,
w: config.width,
h: config.height,
},
})
}
export const createCalendarShapeAtCenter = (editor: Editor) => {
if (!editor) return;
const { x, y } = editor.getViewportScreenCenter();
const config = CC_SHAPE_CONFIGS['cc-calendar'];
const shapeId = createShapeId();
createCalendarShape(editor, {
id: shapeId,
x: x - config.xOffset,
y: y - config.yOffset,
rotation: 0,
isLocked: false,
});
}
@@ -0,0 +1,81 @@
import { Editor, createShapeId, TLShapeId, IndexKey } from '@tldraw/tldraw'
import { ccGraphShapeProps, getDefaultCCUserNodeProps } from '../cc-graph/cc-graph-props'
import { NODE_THEMES, NODE_TYPE_THEMES } from '../cc-graph/cc-graph-styles'
import { GraphShapeType, CCUserNodeProps } from '../cc-graph/cc-graph-types'
import { logger } from '../../../../debugConfig'
// Create a graph shape on the canvas
export const createGraphShape = (
editor: Editor,
shapeType: GraphShapeType,
point = { x: 0, y: 0 }
) => {
if (!ccGraphShapeProps || !ccGraphShapeProps[shapeType]) {
console.warn(`Invalid shape type: ${shapeType}`);
return;
}
editor.createShape({
id: createShapeId(),
type: shapeType,
x: point.x,
y: point.y,
});
}
export const createUserNodeFromProfile = (
editor: Editor,
userNode: CCUserNodeProps,
x: number = 0,
y: number = 0
): TLShapeId | null => {
try {
if (!userNode) {
logger.error('graph-shape-user', '❌ Cannot create user node - no user data')
return null
}
const id = createShapeId()
const theme = NODE_THEMES[NODE_TYPE_THEMES['cc-user-node']]
editor.createShape({
id,
type: 'cc-user-node' as const,
x,
y,
rotation: 0,
index: 'a1' as IndexKey,
isLocked: false,
opacity: 1,
props: {
...getDefaultCCUserNodeProps(),
headerColor: theme.headerColor,
title: userNode.user_email,
unique_id: userNode.unique_id,
user_name: userNode.user_name,
user_email: userNode.user_email,
user_type: userNode.user_type,
user_id: userNode.user_id,
path: userNode.tldraw_snapshot,
worker_node_data: userNode.worker_node_data,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
defaultComponent: true
}
})
logger.debug('graph-shape-user', '🔄 Creating user node', {
userId: userNode.user_id,
email: userNode.user_email,
type: userNode.user_type
})
return id
} catch (error) {
logger.error('graph-shape-user', '❌ Failed to create user node', error)
return null
}
}
@@ -0,0 +1,35 @@
import { Editor } from '@tldraw/tldraw'
import { CCSearchShape } from '../cc-search/CCSearchShapeUtil'
import { SearchService } from '../../../../services/tldraw/searchService'
export const createSearchShape = async (
editor: Editor,
options?: {
x?: number
y?: number
query?: string
}
) => {
const { width, height } = editor.getViewportPageBounds()
const x = options?.x ?? width / 2 - 200
const y = options?.y ?? height / 2 - 250
const results = options?.query ? await SearchService.search(options.query).catch(() => []) : []
return editor.createShape<CCSearchShape>({
type: 'cc-search',
x,
y,
props: {
w: 400,
h: 500,
title: 'Search',
headerColor: '#1a73e8',
backgroundColor: '#ffffff',
isLocked: false,
query: options?.query ?? '',
results,
isSearching: false,
},
})
}
@@ -0,0 +1,41 @@
import { Editor, TLShapeId, createShapeId } from '@tldraw/tldraw'
import { CC_SHAPE_CONFIGS } from '../cc-configs'
const type = 'cc-settings'
const config = CC_SHAPE_CONFIGS[type]
export const createSettingsShape = (
editor: Editor,
baseProps: {
id: TLShapeId
x: number
y: number
rotation: number
isLocked: boolean
}
) => {
editor.createShape({
...baseProps,
type,
props: {
...config.defaultProps,
w: config.width,
h: config.height,
},
})
}
export const createSettingsShapeAtCenter = (editor: Editor) => {
if (!editor) return;
const { x, y } = editor.getViewportScreenCenter();
const shapeId = createShapeId();
createSettingsShape(editor, {
id: shapeId,
x: x - config.xOffset,
y: y - config.yOffset,
rotation: 0,
isLocked: false,
});
}
@@ -0,0 +1,540 @@
import { Editor, TLShapeId, createShapeId, createBindingId } from '@tldraw/tldraw'
import { CC_SHAPE_CONFIGS } from '../cc-configs'
import { CC_BASE_STYLE_CONSTANTS, CC_SLIDESHOW_STYLE_CONSTANTS } from '../cc-styles'
import { CCSlideShowShape } from '../cc-slideshow/CCSlideShowShapeUtil'
import { CCSlideShape } from '../cc-slideshow/CCSlideShapeUtil'
import axios, { isAxiosError } from '../../../../axiosConfig'
import { logger } from '../../../../debugConfig'
export const createSlideshow = (
editor: Editor,
baseProps: {
id: TLShapeId
x: number
y: number
rotation: number
isLocked: boolean
},
slidePattern = 'horizontal',
numSlides = 3
) => {
const config = CC_SHAPE_CONFIGS['cc-slideshow']
// Create slideshow shape
editor.createShape<CCSlideShowShape>({
...baseProps,
type: 'cc-slideshow',
props: {
...config.defaultProps,
w: config.width,
h: config.height,
slidePattern,
currentSlideIndex: 0,
},
})
// Create slides
for (let i = 0; i < numSlides; i++) {
const slideId = createShapeId()
editor.createShape<CCSlideShape>({
id: slideId,
type: 'cc-slide',
x: baseProps.x + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING,
y: baseProps.y + CC_BASE_STYLE_CONSTANTS.HEADER.height + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING * 2,
rotation: 0,
isLocked: false,
props: {
...CC_SHAPE_CONFIGS['cc-slide'].defaultProps,
w: CC_SHAPE_CONFIGS['cc-slide'].width,
h: CC_SHAPE_CONFIGS['cc-slide'].height,
title: `Slide ${i + 1}`,
},
})
// Create binding between slideshow and slide
editor.createBinding({
id: createBindingId(),
type: 'cc-slide-layout',
fromId: baseProps.id,
toId: slideId,
props: {
index: `a${i + 1}`,
isMovingWithParent: true,
placeholder: false,
},
})
}
}
export const createSlideshowAtCenter = (
editor: Editor,
slidePattern = 'horizontal',
numSlides = 3
) => {
if (!editor) return;
const { x, y } = editor.getViewportScreenCenter();
const config = CC_SHAPE_CONFIGS['cc-slideshow'];
const shapeId = createShapeId();
editor.run(() => {
createSlideshow(editor, {
id: shapeId,
x: x - config.xOffset,
y: y - config.yOffset,
rotation: 0,
isLocked: false,
}, slidePattern, numSlides);
});
}
export const createPowerPointSlideshow = async (
editor: Editor,
file: File,
x: number,
y: number
) => {
try {
// Create form data for file upload
const formData = new FormData()
formData.append('file', file, file.name)
logger.debug('slideshow-helpers', 'Uploading PowerPoint file.', {
name: file.name,
size: file.size,
})
const response = await axios.post('/assets/powerpoint/convert', formData)
logger.debug('slideshow-helpers', 'Response received.', {
status: response.status,
data: response.data,
})
const { data } = response
if (!data || typeof data !== 'object') {
throw new Error('Invalid response format from server')
}
if (data.status !== 'success') {
throw new Error(data.message || 'Failed to process PowerPoint')
}
if (!data.slides || !Array.isArray(data.slides) || data.slides.length === 0) {
throw new Error('No slides found in PowerPoint file')
}
// Create slideshow with the slides from PowerPoint
const slideshowId = createShapeId()
const baseProps = {
id: slideshowId,
x,
y,
rotation: 0,
isLocked: false,
}
// Create slideshow in a batch operation
editor.batch(() => {
const config = CC_SHAPE_CONFIGS['cc-slideshow']
// Create slideshow shape
editor.createShape<CCSlideShowShape>({
...baseProps,
type: 'cc-slideshow',
props: {
...config.defaultProps,
w: config.width,
h: CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING * 2 +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING,
slidePattern: 'horizontal',
title: file.name.replace('.pptx', ''),
currentSlideIndex: 0,
},
})
// Create slides with images and meta text
data.slides.forEach((slide: {
index: number,
data: string,
meta?: {
text: string,
format: string
}
}, i: number) => {
const slideId = createShapeId()
editor.createShape<CCSlideShape>({
id: slideId,
type: 'cc-slide',
x: x + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING,
y: y + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING,
rotation: 0,
isLocked: false,
props: {
...CC_SHAPE_CONFIGS['cc-slide'].defaultProps,
w: CC_SHAPE_CONFIGS['cc-slide'].width,
h: CC_SHAPE_CONFIGS['cc-slide'].height,
title: `Slide ${i + 1}`,
imageData: slide.data,
meta: slide.meta || { text: '', format: 'markdown' }
},
})
editor.createBinding({
id: createBindingId(),
type: 'cc-slide-layout',
fromId: slideshowId,
toId: slideId,
props: {
index: `a${String(i + 1).padStart(3, '0')}`,
isMovingWithParent: true,
placeholder: false,
},
})
})
})
return true
} catch (error) {
if (isAxiosError(error)) {
if (!error.response) {
logger.error('slideshow-helpers', 'Network error - Failed to reach the server', { error })
throw new Error('Network error - Failed to reach the server. Please check your connection.')
}
const {status} = error.response
const errorMessage = error.response.data?.message || error.message
if (status === 404) {
logger.error('slideshow-helpers', 'PowerPoint conversion endpoint not found', { error })
throw new Error('PowerPoint conversion service is not available. Please check if the backend service is running.')
}
if (status === 413) {
logger.error('slideshow-helpers', 'File too large', { error })
throw new Error('The PowerPoint file is too large to process.')
}
logger.error('slideshow-helpers', `Server error (${status})`, { error: errorMessage })
throw new Error(`Server error (${status}): ${errorMessage}`)
}
// Handle non-Axios errors
logger.error('slideshow-helpers', 'Unexpected error creating PowerPoint slideshow', { error })
throw new Error('An unexpected error occurred while processing the PowerPoint file.')
}
}
export const createWordSlideshow = async (
editor: Editor,
file: File,
x: number,
y: number
) => {
try {
// Create form data for file upload
const formData = new FormData()
formData.append('file', file, file.name)
logger.debug('slideshow-helpers', 'Uploading Word file.', {
name: file.name,
size: file.size,
})
const response = await axios.post('/assets/word/convert', formData)
logger.debug('slideshow-helpers', 'Response received.', {
status: response.status,
data: response.data,
})
const { data } = response
if (!data || typeof data !== 'object') {
throw new Error('Invalid response format from server')
}
if (data.status !== 'success') {
throw new Error(data.message || 'Failed to process Word document')
}
if (!data.slides || !Array.isArray(data.slides) || data.slides.length === 0) {
throw new Error('No pages found in Word document')
}
// Create slideshow with the pages from Word
const slideshowId = createShapeId()
const baseProps = {
id: slideshowId,
x,
y,
rotation: 0,
isLocked: false,
}
// Create slideshow in a batch operation
editor.batch(() => {
const config = CC_SHAPE_CONFIGS['cc-slideshow']
// Create slideshow shape
editor.createShape<CCSlideShowShape>({
...baseProps,
type: 'cc-slideshow',
props: {
...config.defaultProps,
w: config.width,
h: CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING * 2 +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING,
slidePattern: 'horizontal',
title: file.name.replace('.docx', ''),
currentSlideIndex: 0,
},
})
// Create slides with images and meta text
data.slides.forEach((slide: {
index: number,
data: string,
dimensions?: { width: number, height: number },
meta?: {
text: string,
format: string
}
}, i: number) => {
const slideId = createShapeId()
editor.createShape<CCSlideShape>({
id: slideId,
type: 'cc-slide',
x: x + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING,
y: y + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING,
rotation: 0,
isLocked: false,
props: {
...CC_SHAPE_CONFIGS['cc-slide'].defaultProps,
w: slide.dimensions?.width ?? CC_SHAPE_CONFIGS['cc-slide'].width,
h: slide.dimensions?.height ?? CC_SHAPE_CONFIGS['cc-slide'].height,
title: `Page ${i + 1}`,
imageData: slide.data,
meta: slide.meta || { text: '', format: 'markdown' }
},
})
editor.createBinding({
id: createBindingId(),
type: 'cc-slide-layout',
fromId: slideshowId,
toId: slideId,
props: {
index: `a${String(i + 1).padStart(3, '0')}`,
isMovingWithParent: true,
placeholder: false,
},
})
})
})
return true
} catch (error) {
if (isAxiosError(error)) {
if (!error.response) {
logger.error('slideshow-helpers', 'Network error - Failed to reach the server', { error })
throw new Error('Network error - Failed to reach the server. Please check your connection.')
}
const {status} = error.response
const errorMessage = error.response.data?.message || error.message
if (status === 404) {
logger.error('slideshow-helpers', 'Word conversion endpoint not found', { error })
throw new Error('Word conversion service is not available. Please check if the backend service is running.')
}
if (status === 413) {
logger.error('slideshow-helpers', 'File too large', { error })
throw new Error('The Word file is too large to process.')
}
logger.error('slideshow-helpers', `Server error (${status})`, { error: errorMessage })
throw new Error(`Server error (${status}): ${errorMessage}`)
}
// Handle non-Axios errors
logger.error('slideshow-helpers', 'Unexpected error creating Word slideshow', { error })
throw new Error('An unexpected error occurred while processing the Word file.')
}
}
export const createPDFSlideshow = async (
editor: Editor,
file: File,
x: number,
y: number
) => {
try {
// Create form data for file upload
const formData = new FormData()
formData.append('file', file, file.name)
logger.debug('slideshow-helpers', 'Uploading PDF file.', {
name: file.name,
size: file.size,
})
const response = await axios.post('/assets/pdf/convert', formData)
logger.debug('slideshow-helpers', 'Response received.', {
status: response.status,
data: response.data,
})
const { data } = response
if (!data || typeof data !== 'object') {
throw new Error('Invalid response format from server')
}
if (data.status !== 'success') {
throw new Error(data.message || 'Failed to process PDF document')
}
if (!data.slides || !Array.isArray(data.slides) || data.slides.length === 0) {
throw new Error('No pages found in PDF document')
}
// Create slideshow with the pages from PDF
const slideshowId = createShapeId()
const baseProps = {
id: slideshowId,
x,
y,
rotation: 0,
isLocked: false,
}
// Create slideshow in a batch operation
editor.batch(() => {
const config = CC_SHAPE_CONFIGS['cc-slideshow']
// Create slideshow shape
editor.createShape<CCSlideShowShape>({
...baseProps,
type: 'cc-slideshow',
props: {
...config.defaultProps,
w: config.width,
h: CC_SLIDESHOW_STYLE_CONSTANTS.DEFAULT_SLIDE_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING * 2 +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_CONTENT_PADDING,
slidePattern: 'horizontal',
title: file.name.replace('.pdf', ''),
currentSlideIndex: 0,
},
})
// Create slides with images and meta text
data.slides.forEach((slide: {
index: number,
data: string,
dimensions?: { width: number, height: number },
meta?: {
text: string,
format: string
}
}, i: number) => {
const slideId = createShapeId()
editor.createShape<CCSlideShape>({
id: slideId,
type: 'cc-slide',
x: x + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING,
y: y + CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_HEADER_HEIGHT +
CC_SLIDESHOW_STYLE_CONSTANTS.SLIDE_SPACING,
rotation: 0,
isLocked: false,
props: {
...CC_SHAPE_CONFIGS['cc-slide'].defaultProps,
w: slide.dimensions?.width ?? CC_SHAPE_CONFIGS['cc-slide'].width,
h: slide.dimensions?.height ?? CC_SHAPE_CONFIGS['cc-slide'].height,
title: `Page ${i + 1}`,
imageData: slide.data,
meta: slide.meta || { text: '', format: 'markdown' }
},
})
editor.createBinding({
id: createBindingId(),
type: 'cc-slide-layout',
fromId: slideshowId,
toId: slideId,
props: {
index: `a${String(i + 1).padStart(3, '0')}`,
isMovingWithParent: true,
placeholder: false,
},
})
})
})
return true
} catch (error) {
if (isAxiosError(error)) {
if (!error.response) {
logger.error('slideshow-helpers', 'Network error - Failed to reach the server', { error })
throw new Error('Network error - Failed to reach the server. Please check your connection.')
}
const {status} = error.response
const errorMessage = error.response.data?.message || error.message
if (status === 404) {
logger.error('slideshow-helpers', 'PDF conversion endpoint not found', { error })
throw new Error('PDF conversion service is not available. Please check if the backend service is running.')
}
if (status === 413) {
logger.error('slideshow-helpers', 'File too large', { error })
throw new Error('The PDF file is too large to process.')
}
logger.error('slideshow-helpers', `Server error (${status})`, { error: errorMessage })
throw new Error(`Server error (${status}): ${errorMessage}`)
}
// Handle non-Axios errors
logger.error('slideshow-helpers', 'Unexpected error creating PDF slideshow', { error })
throw new Error('An unexpected error occurred while processing the PDF file.')
}
}
export const handleSlideshowFileUpload = async (
editor: Editor,
file: File,
onComplete?: () => void
) => {
if (!editor) return;
const { x, y } = editor.getViewportScreenCenter();
try {
if (file.name.endsWith('.pptx')) {
await createPowerPointSlideshow(editor, file, x, y);
} else if (file.name.endsWith('.docx')) {
await createWordSlideshow(editor, file, x, y);
} else if (file.name.endsWith('.pdf')) {
await createPDFSlideshow(editor, file, x, y);
} else {
throw new Error('Please select a PowerPoint (.pptx), Word (.docx), or PDF (.pdf) file');
}
onComplete?.();
} catch (error) {
throw error instanceof Error ? error : new Error('An unknown error occurred');
}
};
@@ -0,0 +1,41 @@
import { Editor, TLShapeId, createShapeId } from '@tldraw/tldraw'
import { CC_SHAPE_CONFIGS } from '../cc-configs'
export const createLiveTranscriptionShape = (
editor: Editor,
baseProps: {
id: TLShapeId
x: number
y: number
rotation: number
isLocked: boolean
}
) => {
const config = CC_SHAPE_CONFIGS['cc-live-transcription']
editor.createShape({
...baseProps,
type: 'cc-live-transcription',
props: {
...config.defaultProps,
w: config.width,
h: config.height,
},
})
}
export const createLiveTranscriptionShapeAtCenter = (editor: Editor) => {
if (!editor) return;
const { x, y } = editor.getViewportScreenCenter();
const config = CC_SHAPE_CONFIGS['cc-live-transcription'];
const shapeId = createShapeId();
createLiveTranscriptionShape(editor, {
id: shapeId,
x: x - config.xOffset,
y: y - config.yOffset,
rotation: 0,
isLocked: false,
});
}
@@ -0,0 +1,161 @@
import { Editor, createShapeId, TLShapeId, IndexKey, TLParentId } from '@tldraw/tldraw'
import { CCWebBrowserShape } from '../cc-web-browser/CCWebBrowserUtil'
interface WebBrowserShapeOptions {
url: string
x?: number
y?: number
w?: number
h?: number
title?: string
isLoading?: boolean
}
interface WebBrowserShapeResult {
id: TLShapeId
url: string
x: number
y: number
}
export function createWebBrowserShapeInfo(options: WebBrowserShapeOptions): CCWebBrowserShape {
const shapeId = createShapeId()
return {
id: shapeId,
type: 'cc-web-browser',
typeName: 'shape',
x: options.x ?? 0,
y: options.y ?? 0,
rotation: 0,
index: 'a1' as IndexKey,
parentId: 'page:page' as TLParentId,
isLocked: false,
opacity: 1,
meta: {},
props: {
w: options.w ?? 800,
h: options.h ?? 600,
title: options.title ?? 'Web Browser',
headerColor: '#1a73e8',
backgroundColor: '#ffffff',
isLocked: false,
url: options.url,
history: [options.url],
currentHistoryIndex: 0,
isLoading: options.isLoading ?? true,
},
}
}
export function createWebBrowserShape(editor: Editor, options: WebBrowserShapeOptions): WebBrowserShapeResult {
const shape = createWebBrowserShapeInfo(options)
editor.createShape(shape)
return {
id: shape.id,
url: options.url,
x: shape.x,
y: shape.y
}
}
interface MultipleWebBrowserOptions {
browsers: WebBrowserShapeOptions[]
layout?: 'grid' | 'cascade' | 'horizontal' | 'vertical'
spacing?: number
startX?: number
startY?: number
}
export function createMultipleWebBrowsers(
editor: Editor,
{ browsers, layout = 'cascade', spacing = 20, startX = 0, startY = 0 }: MultipleWebBrowserOptions
): WebBrowserShapeResult[] {
const results: WebBrowserShapeResult[] = []
const baseWidth = 800
const baseHeight = 600
editor.batch(() => {
browsers.forEach((browser, index) => {
let x = startX
let y = startY
switch (layout) {
case 'grid': {
const cols = Math.ceil(Math.sqrt(browsers.length))
x += (index % cols) * (baseWidth + spacing)
y += Math.floor(index / cols) * (baseHeight + spacing)
break
}
case 'cascade':
x += index * spacing
y += index * spacing
break
case 'horizontal':
x += index * (baseWidth + spacing)
break
case 'vertical':
y += index * (baseHeight + spacing)
break
}
const result = createWebBrowserShape(editor, {
...browser,
x,
y,
w: baseWidth,
h: baseHeight
})
results.push(result)
})
})
return results
}
// Helper function to arrange browser shapes in a specific layout
export function arrangeBrowserShapes(
editor: Editor,
shapeIds: TLShapeId[],
layout: 'grid' | 'cascade' | 'horizontal' | 'vertical',
options?: { spacing?: number; startX?: number; startY?: number }
) {
const spacing = options?.spacing ?? 20
const startX = options?.startX ?? 0
const startY = options?.startY ?? 0
const baseWidth = 800
const baseHeight = 600
editor.batch(() => {
shapeIds.forEach((id, index) => {
let x = startX
let y = startY
switch (layout) {
case 'grid': {
const cols = Math.ceil(Math.sqrt(shapeIds.length))
x += (index % cols) * (baseWidth + spacing)
y += Math.floor(index / cols) * (baseHeight + spacing)
break
}
case 'cascade':
x += index * spacing
y += index * spacing
break
case 'horizontal':
x += index * (baseWidth + spacing)
break
case 'vertical':
y += index * (baseHeight + spacing)
break
}
editor.updateShape({
id,
type: 'cc-web-browser',
x,
y,
})
})
})
}
@@ -0,0 +1,43 @@
import { Editor, TLShapeId, createShapeId } from '@tldraw/tldraw'
import { CC_SHAPE_CONFIGS } from '../cc-configs'
export const createYoutubeShape = (
editor: Editor,
baseProps: {
id: TLShapeId
x: number
y: number
rotation: number
isLocked: boolean
},
videoUrl: string
) => {
const config = CC_SHAPE_CONFIGS['cc-youtube-embed']
editor.createShape({
...baseProps,
type: 'cc-youtube-embed',
props: {
...config.defaultProps,
w: config.width,
h: config.height,
video_url: videoUrl,
},
})
}
export const createYoutubeShapeAtCenter = (editor: Editor, videoUrl: string) => {
if (!editor) return;
const { x, y } = editor.getViewportScreenCenter();
const config = CC_SHAPE_CONFIGS['cc-youtube-embed'];
const shapeId = createShapeId();
createYoutubeShape(editor, {
id: shapeId,
x: x - config.xOffset,
y: y - config.yOffset,
rotation: 0,
isLocked: false,
}, videoUrl);
}
+100
View File
@@ -0,0 +1,100 @@
import {
DefaultEmbedDefinitionType,
CustomEmbedDefinition,
DEFAULT_EMBED_DEFINITIONS,
TLEmbedDefinition,
} from '@tldraw/tldraw';
// Define which default embeds we want to keep
const defaultEmbedTypesToKeep: DefaultEmbedDefinitionType[] = [
'tldraw',
'google_slides',
];
// Filter default embeds to keep only the ones we want
export const defaultEmbedsToKeep = DEFAULT_EMBED_DEFINITIONS.filter((embed) =>
defaultEmbedTypesToKeep.includes(embed.type as DefaultEmbedDefinitionType)
) as TLEmbedDefinition[];
// Helper to create custom embeds
const createCustomEmbed = (
type: string,
title: string,
hostnames: string[],
icon: string,
minWidth = 300,
minHeight = 300,
width = 720,
height = 500
): CustomEmbedDefinition => ({
type,
title,
hostnames,
minWidth,
minHeight,
width,
height,
doesResize: true,
toEmbedUrl: (url) => {
const urlObj = new URL(url);
return `${urlObj.origin}/embed${urlObj.pathname}`;
},
fromEmbedUrl: (url) => {
return url.replace('/embed', '');
},
icon,
});
// Define custom embeds
export const pptEmbed = createCustomEmbed(
'ppt',
'PowerPoint',
['office.live.com'],
'https://c1-odc-15.cdn.office.net/start/resources/images/favicon_powerpointcom.ico'
);
export const ccYoutubeEmbed: CustomEmbedDefinition = {
type: 'cc-youtube-embed',
title: 'YouTube Video',
hostnames: ['youtube.com', 'youtu.be'],
width: 800,
height: 450,
doesResize: true,
minWidth: 200,
minHeight: 113,
toEmbedUrl: (url) => {
const videoId = url.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/)?.[1];
return `https://www.youtube.com/embed/${videoId}`;
},
fromEmbedUrl: (url) => {
const videoId = url.split('/').pop();
return `https://www.youtube.com/watch?v=${videoId}`;
},
icon: 'https://www.youtube.com/favicon.ico',
};
export const customEmbeds: TLEmbedDefinition[] = [
pptEmbed,
ccYoutubeEmbed,
];
// Export specific embed sets for different modes
export const multiplayerEmbeds: TLEmbedDefinition[] = [
...defaultEmbedsToKeep,
...customEmbeds,
];
export const singlePlayerEmbeds: TLEmbedDefinition[] = [
...defaultEmbedsToKeep,
...customEmbeds,
];
export const devEmbeds: TLEmbedDefinition[] = [
...defaultEmbedsToKeep,
...customEmbeds,
];
// Helper function to create custom embed sets
export const createCustomEmbedSet = (...embedSets: TLEmbedDefinition[][]): TLEmbedDefinition[] => {
return [...new Set(embedSets.flat())];
};
+53
View File
@@ -0,0 +1,53 @@
import { createTLSchema, defaultShapeSchemas, defaultBindingSchemas } from '@tldraw/tlschema';
import { createTLSchemaFromUtils, defaultBindingUtils, defaultShapeUtils } from '@tldraw/tldraw';
import { ShapeUtils } from './shapes';
import { allBindingUtils } from './bindings';
import { ccGraphShapeProps } from './cc-base/cc-graph/cc-graph-props';
import { ccGraphMigrations } from './cc-base/cc-graph/cc-graph-migrations';
import { GraphShapeType } from './cc-base/cc-graph/cc-graph-types';
// Create schema with shape definitions
export const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
// Dynamically generate shape schemas from ShapeUtils
...Object.values(ShapeUtils).reduce((acc, util) => ({
...acc,
[util.type]: {
props: util.props,
migrations: util.migrations,
}
}), {}),
// Add graph shapes
...(ccGraphShapeProps ? Object.entries(ccGraphShapeProps).reduce((acc, [type, props]) => ({
...acc,
[type]: {
props,
migrations: ccGraphMigrations[type as GraphShapeType],
}
}), {}) : {})
},
bindings: {
...defaultBindingSchemas,
// Add binding schemas from our custom binding utils
...allBindingUtils.reduce((acc, util) => ({
...acc,
[util.type]: {
props: util.props,
migrations: util.migrations,
}
}), {})
},
});
// Create schema from utils (alternative approach)
export const schemaFromUtils = createTLSchemaFromUtils({
shapeUtils: [
...defaultShapeUtils,
...Object.values(ShapeUtils)
],
bindingUtils: [
...defaultBindingUtils,
...allBindingUtils
],
});
+96
View File
@@ -0,0 +1,96 @@
// Custom tldraw utils
import { CCSlideShowShapeUtil } from './cc-base/cc-slideshow/CCSlideShowShapeUtil'
import { CCSlideShapeUtil } from './cc-base/cc-slideshow/CCSlideShapeUtil'
import { CCCalendarShapeUtil } from './cc-base/cc-calendar/CCCalendarShapeUtil'
import { CCSettingsShapeUtil } from './cc-base/cc-settings/CCSettingsShapeUtil'
import { CCLiveTranscriptionShapeUtil } from './cc-base/cc-transcription/CCLiveTranscriptionShapeUtil'
import { CCYoutubeEmbedShapeUtil } from './cc-base/cc-youtube-embed/CCYoutubeEmbedShapeUtil'
import { CCUserNodeShapeUtil } from './cc-base/cc-graph/CCUserNodeShapeUtil'
import { CCTeacherNodeShapeUtil } from './cc-base/cc-graph/CCTeacherNodeShapeUtil'
import { CCStudentNodeShapeUtil } from './cc-base/cc-graph/CCStudentNodeShapeUtil'
import { CCCalendarNodeShapeUtil } from './cc-base/cc-graph/CCCalendarNodeShapeUtil'
import { CCCalendarYearNodeShapeUtil } from './cc-base/cc-graph/CCCalendarYearNodeShapeUtil'
import { CCCalendarMonthNodeShapeUtil } from './cc-base/cc-graph/CCCalendarMonthNodeShapeUtil'
import { CCCalendarWeekNodeShapeUtil } from './cc-base/cc-graph/CCCalendarWeekNodeShapeUtil'
import { CCCalendarDayNodeShapeUtil } from './cc-base/cc-graph/CCCalendarDayNodeShapeUtil'
import { CCCalendarTimeChunkNodeShapeUtil } from './cc-base/cc-graph/CCCalendarTimeChunkNodeShapeUtil'
import { CCSchoolNodeShapeUtil } from './cc-base/cc-graph/CCSchoolNodeShapeUtil'
import { CCDepartmentNodeShapeUtil } from './cc-base/cc-graph/CCDepartmentNodeShapeUtil'
import { CCRoomNodeShapeUtil } from './cc-base/cc-graph/CCRoomNodeShapeUtil'
import { CCSubjectClassNodeShapeUtil } from './cc-base/cc-graph/CCSubjectClassNodeShapeUtil'
import { CCPastoralStructureNodeShapeUtil } from './cc-base/cc-graph/CCPastoralStructureNodeShapeUtil'
import { CCYearGroupNodeShapeUtil } from './cc-base/cc-graph/CCYearGroupNodeShapeUtil'
import { CCCurriculumStructureNodeShapeUtil } from './cc-base/cc-graph/CCCurriculumStructureNodeShapeUtil'
import { CCKeyStageNodeShapeUtil } from './cc-base/cc-graph/CCKeyStageNodeShapeUtil'
import { CCKeyStageSyllabusNodeShapeUtil } from './cc-base/cc-graph/CCKeyStageSyllabusNodeShapeUtil'
import { CCYearGroupSyllabusNodeShapeUtil } from './cc-base/cc-graph/CCYearGroupSyllabusNodeShapeUtil'
import { CCSubjectNodeShapeUtil } from './cc-base/cc-graph/CCSubjectNodeShapeUtil'
import { CCTopicNodeShapeUtil } from './cc-base/cc-graph/CCTopicNodeShapeUtil'
import { CCTopicLessonNodeShapeUtil } from './cc-base/cc-graph/CCTopicLessonNodeShapeUtil'
import { CCLearningStatementNodeShapeUtil } from './cc-base/cc-graph/CCLearningStatementNodeShapeUtil'
import { CCScienceLabNodeShapeUtil } from './cc-base/cc-graph/CCScienceLabNodeShapeUtil'
import { CCTeacherTimetableNodeShapeUtil } from './cc-base/cc-graph/CCTeacherTimetableNodeShapeUtil'
import { CCTimetableLessonNodeShapeUtil } from './cc-base/cc-graph/CCTimetableLessonNodeShapeUtil'
import { CCPlannedLessonNodeShapeUtil } from './cc-base/cc-graph/CCPlannedLessonNodeShapeUtil'
import { CCSchoolTimetableNodeShapeUtil } from './cc-base/cc-graph/CCSchoolTimetableNodeShapeUtil'
import { CCAcademicYearNodeShapeUtil } from './cc-base/cc-graph/CCAcademicYearNodeShapeUtil'
import { CCAcademicTermNodeShapeUtil } from './cc-base/cc-graph/CCAcademicTermNodeShapeUtil'
import { CCAcademicWeekNodeShapeUtil } from './cc-base/cc-graph/CCAcademicWeekNodeShapeUtil'
import { CCAcademicDayNodeShapeUtil } from './cc-base/cc-graph/CCAcademicDayNodeShapeUtil'
import { CCAcademicPeriodNodeShapeUtil } from './cc-base/cc-graph/CCAcademicPeriodNodeShapeUtil'
import { CCRegistrationPeriodNodeShapeUtil } from './cc-base/cc-graph/CCRegistrationPeriodNodeShapeUtil'
import { CCDepartmentStructureNodeShapeUtil } from './cc-base/cc-graph/CCDepartmentStructureNodeShapeUtil'
import { CCUserTeacherTimetableNodeShapeUtil } from './cc-base/cc-graph/CCUserTeacherTimetableNodeShapeUtil'
import { CCUserTimetableLessonNodeShapeUtil } from './cc-base/cc-graph/CCUserTimetableLessonNodeShapeUtil'
import { CCSearchShapeUtil } from './cc-base/cc-search/CCSearchShapeUtil'
import { CCWebBrowserShapeUtil } from './cc-base/cc-web-browser/CCWebBrowserUtil'
// Define all shape utils in a single object for easy maintenance
export const ShapeUtils = {
CCSlideShow: CCSlideShowShapeUtil,
CCSlide: CCSlideShapeUtil,
CCCalendar: CCCalendarShapeUtil,
CCSettings: CCSettingsShapeUtil,
CCLiveTranscription: CCLiveTranscriptionShapeUtil,
CCYoutubeEmbed: CCYoutubeEmbedShapeUtil,
CCUserNode: CCUserNodeShapeUtil,
CCTeacherNode: CCTeacherNodeShapeUtil,
CCStudentNode: CCStudentNodeShapeUtil,
CCCalendarNode: CCCalendarNodeShapeUtil,
CCCalendarYearNode: CCCalendarYearNodeShapeUtil,
CCCalendarMonthNode: CCCalendarMonthNodeShapeUtil,
CCCalendarWeekNode: CCCalendarWeekNodeShapeUtil,
CCCalendarDayNode: CCCalendarDayNodeShapeUtil,
CCCalendarTimeChunkNode: CCCalendarTimeChunkNodeShapeUtil,
CCSchoolNode: CCSchoolNodeShapeUtil,
CCDepartmentNode: CCDepartmentNodeShapeUtil,
CCRoomNode: CCRoomNodeShapeUtil,
CCSubjectClassNode: CCSubjectClassNodeShapeUtil,
CCPastoralStructureNode: CCPastoralStructureNodeShapeUtil,
CCYearGroupNode: CCYearGroupNodeShapeUtil,
CCCurriculumStructureNode: CCCurriculumStructureNodeShapeUtil,
CCKeyStageNode: CCKeyStageNodeShapeUtil,
CCKeyStageSyllabusNode: CCKeyStageSyllabusNodeShapeUtil,
CCYearGroupSyllabusNode: CCYearGroupSyllabusNodeShapeUtil,
CCSubjectNode: CCSubjectNodeShapeUtil,
CCTopicNode: CCTopicNodeShapeUtil,
CCTopicLessonNode: CCTopicLessonNodeShapeUtil,
CCLearningStatementNode: CCLearningStatementNodeShapeUtil,
CCScienceLabNode: CCScienceLabNodeShapeUtil,
CCTeacherTimetableNode: CCTeacherTimetableNodeShapeUtil,
CCTimetableLessonNode: CCTimetableLessonNodeShapeUtil,
CCPlannedLessonNode: CCPlannedLessonNodeShapeUtil,
CCSchoolTimetableNode: CCSchoolTimetableNodeShapeUtil,
CCAcademicYearNode: CCAcademicYearNodeShapeUtil,
CCAcademicTermNode: CCAcademicTermNodeShapeUtil,
CCAcademicWeekNode: CCAcademicWeekNodeShapeUtil,
CCAcademicDayNode: CCAcademicDayNodeShapeUtil,
CCAcademicPeriodNode: CCAcademicPeriodNodeShapeUtil,
CCRegistrationPeriodNode: CCRegistrationPeriodNodeShapeUtil,
CCDepartmentStructureNode: CCDepartmentStructureNodeShapeUtil,
CCUserTeacherTimetableNode: CCUserTeacherTimetableNodeShapeUtil,
CCUserTimetableLessonNode: CCUserTimetableLessonNodeShapeUtil,
CCSearch: CCSearchShapeUtil,
CCWebBrowser: CCWebBrowserShapeUtil,
}
export const allShapeUtils = Object.values(ShapeUtils)
+2
View File
@@ -0,0 +1,2 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap");
@import url("@tldraw/tldraw/tldraw.css");
+31
View File
@@ -0,0 +1,31 @@
import { CCSlideShowShapeTool, CCSlideShapeTool } from './cc-base/cc-slideshow/CCSlideShapeTool';
import { HeartStickerTool, SmileyStickerTool, StarStickerTool } from './tools/sticker-tool';
// Base tools that are common across all modes
export const baseTools = [
CCSlideShowShapeTool,
CCSlideShapeTool,
] as const;
// Sticker tools that can be added to any mode
export const stickerTools = [
HeartStickerTool,
SmileyStickerTool,
StarStickerTool,
] as const;
// Specific tool sets for different modes
export const multiplayerTools = [
...baseTools,
...stickerTools,
] as const;
export const singlePlayerTools = [
...baseTools,
...stickerTools,
] as const;
export const devTools = [
...baseTools,
...stickerTools,
] as const;
+75
View File
@@ -0,0 +1,75 @@
import { StateNode, TLPointerEventInfo } from '@tldraw/tldraw'
const OFFSET = 12
const STICKER_INTERVAL = 20 // Adjust this value to change the spacing between stickers
class BaseStickerTool extends StateNode {
isPointerDown = false
lastStickerPosition = { x: 0, y: 0 }
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onPointerDown(info: TLPointerEventInfo) {
this.isPointerDown = true
this.createSticker(info)
}
override onPointerMove(info: TLPointerEventInfo) {
if (this.isPointerDown) {
const { x, y } = info.point
const dx = x - this.lastStickerPosition.x
const dy = y - this.lastStickerPosition.y
if (Math.sqrt(dx * dx + dy * dy) >= STICKER_INTERVAL) {
this.createSticker(info)
}
}
}
override onPointerUp() {
this.isPointerDown = false
}
createSticker(info: TLPointerEventInfo) {
const { x, y } = info.point
this.editor.createShape({
type: 'text',
x: x - OFFSET,
y: y - OFFSET,
props: { text: this.getStickerEmoji() },
})
this.lastStickerPosition = { x, y }
}
getStickerEmoji(): string {
throw new Error('getStickerEmoji must be implemented in subclasses')
}
}
export class HeartStickerTool extends BaseStickerTool {
static override id = 'heartSticker'
static override name = 'Heart Sticker'
getStickerEmoji() {
return '❤️'
}
}
export class StarStickerTool extends BaseStickerTool {
static override id = 'starSticker'
static override name = 'Star Sticker'
getStickerEmoji() {
return '⭐'
}
}
export class SmileyStickerTool extends BaseStickerTool {
static override id = 'smileySticker'
static override name = 'Smiley Sticker'
getStickerEmoji() {
return '😊'
}
}
+93
View File
@@ -0,0 +1,93 @@
import {
TLComponents,
TLUiOverrides,
TldrawUiToastsProvider,
TLUiToast,
TLUiToastsContextType,
Atom,
atom
} from '@tldraw/tldraw';
import { ReactNode } from 'react';
import { regularComponentsIndex, presentationComponentsIndex } from './ui-overrides/components';
import { presentationUiOverridesIndex, regularUiOverridesIndex } from './ui-overrides/overrides';
// Toast Wrapper Component
const ToastWrapper = ({ children }: { children: ReactNode }) => {
// Create custom toast overrides
const toastOverrides = (): TLUiToastsContextType => {
const toasts: Atom<TLUiToast[]> = atom('toasts', []);
return {
addToast: (toast) => {
const id = toast.id || Math.random().toString();
const newToast: TLUiToast = { ...toast, id };
toasts.set([...toasts.get(), newToast]);
return id;
},
removeToast: (id) => {
toasts.set(toasts.get().filter((t: TLUiToast) => t.id !== id));
return id;
},
clearToasts: () => {
toasts.set([]);
},
toasts
};
};
return (
<TldrawUiToastsProvider overrides={toastOverrides}>
{children}
</TldrawUiToastsProvider>
);
};
// Function to get the appropriate UI overrides
export const getUiOverrides = (presentationMode: boolean): TLUiOverrides => {
return presentationMode ? presentationUiOverridesIndex : regularUiOverridesIndex;
};
// Function to get the appropriate UI configuration
export const getUiComponents = (presentationMode: boolean): TLComponents => {
return presentationMode ? presentationComponents : regularComponents;
};
// Regular components configuration
const regularComponents: TLComponents = {
Toolbar: regularComponentsIndex.Toolbar,
InFrontOfTheCanvas: regularComponentsIndex.InFrontOfTheCanvas,
KeyboardShortcutsDialog: regularComponentsIndex.KeyboardShortcutsDialog,
HelperButtons: regularComponentsIndex.HelperButtons,
ActionsMenu: regularComponentsIndex.ActionsMenu,
ContextMenu: regularComponentsIndex.ContextMenu,
DebugMenu: regularComponentsIndex.DebugMenu,
HelpMenu: regularComponentsIndex.HelpMenu,
MainMenu: regularComponentsIndex.MainMenu,
NavigationPanel: regularComponentsIndex.NavigationPanel,
PageMenu: regularComponentsIndex.PageMenu,
QuickActions: regularComponentsIndex.QuickActions,
StylePanel: regularComponentsIndex.StylePanel,
ZoomMenu: regularComponentsIndex.ZoomMenu
};
// Presentation components configuration
const presentationComponents: TLComponents = {
Toolbar: presentationComponentsIndex.Toolbar,
InFrontOfTheCanvas: presentationComponentsIndex.InFrontOfTheCanvas,
KeyboardShortcutsDialog: presentationComponentsIndex.KeyboardShortcutsDialog,
HelperButtons: presentationComponentsIndex.HelperButtons,
ActionsMenu: presentationComponentsIndex.ActionsMenu,
ContextMenu: presentationComponentsIndex.ContextMenu,
DebugMenu: presentationComponentsIndex.DebugMenu,
HelpMenu: presentationComponentsIndex.HelpMenu,
MainMenu: presentationComponentsIndex.MainMenu,
NavigationPanel: presentationComponentsIndex.NavigationPanel,
PageMenu: presentationComponentsIndex.PageMenu,
QuickActions: presentationComponentsIndex.QuickActions,
StylePanel: presentationComponentsIndex.StylePanel,
ZoomMenu: presentationComponentsIndex.ZoomMenu
};
export { ToastWrapper };
@@ -0,0 +1,29 @@
import { useEditor, TldrawUiButton } from '@tldraw/tldraw';
export function StickerDropdown() {
const editor = useEditor()
const handleToolSelect = (toolId: string) => {
editor.setCurrentTool(toolId)
}
return (
<div style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '8px',
zIndex: 1000,
display: 'flex',
gap: '8px',
}}>
<TldrawUiButton type="tool" onClick={() => handleToolSelect('heartSticker')} style={{ fontSize: '1.5rem' }}></TldrawUiButton>
<TldrawUiButton type="tool" onClick={() => handleToolSelect('starSticker')} style={{ fontSize: '1.5rem' }}></TldrawUiButton>
<TldrawUiButton type="tool" onClick={() => handleToolSelect('smileySticker')} style={{ fontSize: '1.5rem' }}>😊</TldrawUiButton>
</div>
)
}
@@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { BasePanel } from './shared/BasePanel';
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
import { BaseContext, ViewContext } from '../../../../types/navigation';
interface CCPanelProps {
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onPinnedChange?: (pinned: boolean) => void;
}
export const CCPanel: React.FC<CCPanelProps> = ({
examMarkerProps,
isExpanded,
isPinned,
onExpandedChange,
onPinnedChange,
}) => {
const [currentContext, setCurrentContext] = useState<BaseContext>('profile');
const [currentExtendedContext, setCurrentExtendedContext] = useState<ViewContext>('overview');
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Reset menu state when panel is closed
const handleExpandedChange = (expanded: boolean) => {
if (!expanded) {
setIsMenuOpen(false);
}
onExpandedChange?.(expanded);
};
return (
<BasePanel
examMarkerProps={examMarkerProps}
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={handleExpandedChange}
onPinnedChange={onPinnedChange}
currentContext={currentContext}
onContextChange={setCurrentContext}
currentExtendedContext={currentExtendedContext}
onExtendedContextChange={setCurrentExtendedContext}
isMenuOpen={isMenuOpen}
onMenuOpenChange={setIsMenuOpen}
/>
);
};
@@ -0,0 +1,65 @@
import { TLComponents } from '@tldraw/tldraw';
import { RegularToolbar } from './regular/toolbar';
import { RegularHelperButtons } from './regular/helperButton';
// import { RegularHelpMenu } from './regular/helpMenu';
// import { RegularMainMenu } from './regular/mainMenu';
import { RegularNavigationPanel } from './regular/navigationPanel';
// import { RegularPageMenu } from './regular/pageMenu';
// import { RegularQuickActions } from './regular/quickActions';
import { RegularStylePanel } from './regular/stylePanel';
import { RegularZoomMenu } from './regular/zoomMenu';
import { RegularKeyboardShortcutsDialog } from './regular/keyboardShortcutsDialog';
// import { RegularActionsMenu } from './regular/actionsMenu';
import { RegularContextMenu } from './regular/contextMenu';
import { RegularDebugMenu } from './regular/debugMenu';
import { PresentationToolbar } from './presentation/toolbar';
import { PresentationHelperButtons } from './presentation/helperButton';
// import { PresentationHelpMenu } from './presentation/helpMenu';
// import { PresentationMainMenu } from './presentation/mainMenu';
import { PresentationNavigationPanel } from './presentation/navigationPanel';
// import { PresentationPageMenu } from './presentation/pageMenu';
import { PresentationQuickActions } from './presentation/quickActions';
import { PresentationStylePanel } from './presentation/stylePanel';
import { PresentationZoomMenu } from './presentation/zoomMenu';
import { PresentationKeyboardShortcutsDialog } from './presentation/keyboardShortcutsDialog';
import { PresentationActionsMenu } from './presentation/actionsMenu';
import { PresentationContextMenu } from './presentation/contextMenu';
import { PresentationDebugMenu } from './presentation/debugMenu';
import { CCPanel } from './CCPanel';
export const regularComponentsIndex: TLComponents = {
Toolbar: RegularToolbar,
InFrontOfTheCanvas: CCPanel,
HelperButtons: RegularHelperButtons,
// HelpMenu: RegularHelpMenu,
// MainMenu: RegularMainMenu,
NavigationPanel: RegularNavigationPanel,
// PageMenu: RegularPageMenu,
// QuickActions: RegularQuickActions,
StylePanel: RegularStylePanel,
ZoomMenu: RegularZoomMenu,
KeyboardShortcutsDialog: RegularKeyboardShortcutsDialog,
// ActionsMenu: RegularActionsMenu,
ContextMenu: RegularContextMenu,
DebugMenu: RegularDebugMenu,
};
export const presentationComponentsIndex: TLComponents = {
Toolbar: PresentationToolbar,
InFrontOfTheCanvas: CCPanel,
HelperButtons: PresentationHelperButtons,
// HelpMenu: PresentationHelpMenu,
// MainMenu: PresentationMainMenu,
NavigationPanel: PresentationNavigationPanel,
// PageMenu: PresentationPageMenu,
QuickActions: PresentationQuickActions,
StylePanel: PresentationStylePanel,
ZoomMenu: PresentationZoomMenu,
KeyboardShortcutsDialog: PresentationKeyboardShortcutsDialog,
ActionsMenu: PresentationActionsMenu,
ContextMenu: PresentationContextMenu,
DebugMenu: PresentationDebugMenu,
};
@@ -0,0 +1,12 @@
import {
DefaultActionsMenu,
DefaultActionsMenuContent,
} from '@tldraw/tldraw';
export const PresentationActionsMenu = () => {
return (
<DefaultActionsMenu>
<DefaultActionsMenuContent />
</DefaultActionsMenu>
);
};
@@ -0,0 +1,15 @@
import {
DefaultContextMenu,
DefaultContextMenuContent,
TLUiContextMenuProps,
} from '@tldraw/tldraw';
export const PresentationContextMenu = (props: TLUiContextMenuProps) => {
return (
<div>
<DefaultContextMenu {...props}>
<DefaultContextMenuContent />
</DefaultContextMenu>
</div>
);
};
@@ -0,0 +1,15 @@
import {
DefaultDebugMenu,
DefaultDebugMenuContent,
TLUiDebugMenuProps,
} from '@tldraw/tldraw';
export const PresentationDebugMenu = (props: TLUiDebugMenuProps) => {
return (
<div>
<DefaultDebugMenu {...props}>
<DefaultDebugMenuContent />
</DefaultDebugMenu>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More