diff --git a/apps/egghead/package.json b/apps/egghead/package.json index 72ad4d3b8..2c19ad9fa 100644 --- a/apps/egghead/package.json +++ b/apps/egghead/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "concurrently --kill-others-on-fail \"npm:dev:*\"", - "dev:next": "next dev", + "dev:next": "NODE_OPTIONS='--inspect' next dev --turbo", "dev:inngest": "pnpx inngest-cli@latest dev --no-discovery -u http://localhost:3000/api/inngest", "dev:party": "pnpx partykit dev", "tunnel": "ngrok http --domain=neatly-diverse-goldfish.ngrok-free.app 3000", @@ -21,6 +21,11 @@ "coupons": "dotenv tsx src/scripts/bootstrap-basic-coupons.ts" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.3", + "@atlaskit/pragmatic-drag-and-drop-flourish": "^1.0.4", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@atlaskit/pragmatic-drag-and-drop-live-region": "^1.0.3", + "@atlaskit/tokens": "^1.42.1", "@auth/core": "^0.37.2", "@aws-sdk/client-s3": "^3.525.0", "@aws-sdk/s3-request-presigner": "^3.441.0", @@ -72,6 +77,7 @@ "inngest": "^3.22.5", "lodash": "^4.17.21", "lucide-react": "^0.288.0", + "memoize-one": "^6.0.0", "nanoid": "^5.0.2", "next": "15.0.3", "next-auth": "5.0.0-beta.25", @@ -99,6 +105,8 @@ "tailwind-merge": "^1.14.0", "tailwind-scrollbar": "^3.0.0", "tailwindcss-animate": "^1.0.7", + "tailwindcss-radix": "^2.8.0", + "tiny-invariant": "^1.3.1", "typesense": "^1.8.2", "uploadthing": "6.13.2", "uuid": "^9.0.1", @@ -112,6 +120,7 @@ "@types/pg": "^8.11.6", "@types/pluralize": "^0.0.33", "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", "@types/react-gravatar": "^2.6.13", "concurrently": "^8.2.2", "dotenv-cli": "^7.3.0", diff --git a/apps/egghead/src/app/(content)/posts/[slug]/edit/page.tsx b/apps/egghead/src/app/(content)/posts/[slug]/edit/page.tsx index 3c2a88dd1..c23e6a408 100644 --- a/apps/egghead/src/app/(content)/posts/[slug]/edit/page.tsx +++ b/apps/egghead/src/app/(content)/posts/[slug]/edit/page.tsx @@ -9,6 +9,26 @@ import { subject } from '@casl/ability' import { EditPostForm } from '../../_components/edit-post-form' +function EditPostSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + export const dynamic = 'force-dynamic' export default async function PostPage(props: { @@ -40,15 +60,19 @@ export default async function PostPage(props: { await getAllEggheadTagsCached() const tagLoader = getTags() + console.log({ videoResourceLoader, tagLoader, post }) + return ( - + }> + + ) } diff --git a/apps/egghead/src/app/(content)/posts/_components/create-post.tsx b/apps/egghead/src/app/(content)/posts/_components/create-post.tsx index b9b0aee6e..1f10ef550 100644 --- a/apps/egghead/src/app/(content)/posts/_components/create-post.tsx +++ b/apps/egghead/src/app/(content)/posts/_components/create-post.tsx @@ -2,6 +2,8 @@ import { useRouter } from 'next/navigation' import { PostUploader } from '@/app/(content)/posts/_components/post-uploader' +import { NewResourceWithVideoForm } from '@/components/resources-crud/new-resource-with-video-form' +import { PostSchema } from '@/lib/posts' import { createPost } from '@/lib/posts-query' import { getVideoResource } from '@/lib/video-resource-query' import { signOut } from 'next-auth/react' @@ -12,7 +14,6 @@ import { type ContentResource, } from '@coursebuilder/core/schemas' import { Card, CardContent, CardFooter, CardHeader } from '@coursebuilder/ui' -import { NewResourceWithVideoForm } from '@coursebuilder/ui/resources-crud/new-resource-with-video-form' export function CreatePost() { const router = useRouter() @@ -25,12 +26,13 @@ export function CreatePost() { `/${pluralize(resource.type)}/${resource.fields?.slug || resource.id}/edit`, ) }} - createResource={async ({ title, videoResourceId }) => { + createResource={async ({ title, videoResourceId, postType }) => { try { return ContentResourceSchema.parse( await createPost({ title, videoResourceId, + postType, }), ) } catch (error) { @@ -41,6 +43,7 @@ export function CreatePost() { } }} getVideoResource={getVideoResource} + availableResourceTypes={['lesson', 'article', 'podcast', 'course']} > {(handleSetVideoResourceId: (id: string) => void) => { return ( diff --git a/apps/egghead/src/app/(content)/posts/_components/edit-post-form-metadata.tsx b/apps/egghead/src/app/(content)/posts/_components/edit-post-form-metadata.tsx index 7a84014ff..4b1eec1c6 100644 --- a/apps/egghead/src/app/(content)/posts/_components/edit-post-form-metadata.tsx +++ b/apps/egghead/src/app/(content)/posts/_components/edit-post-form-metadata.tsx @@ -7,7 +7,12 @@ import { NewLessonVideoForm } from '@/components/resources-crud/new-lesson-video import AdvancedTagSelector from '@/components/resources-crud/tag-selector' import { env } from '@/env.mjs' import { useTranscript } from '@/hooks/use-transcript' -import { Post, PostSchema, PostTypeSchema } from '@/lib/posts' +import { + Post, + POST_TYPES_WITH_VIDEO, + PostSchema, + PostTypeSchema, +} from '@/lib/posts' import { addTagToPost, removeTagFromPost } from '@/lib/posts-query' import { EggheadTag } from '@/lib/tags' import { api } from '@/trpc/react' @@ -109,76 +114,78 @@ export const PostMetadataFormFields: React.FC<{ }) return ( <> -
- -
- video is loading -
- - } - > - {videoResourceId ? ( - replacingVideo ? ( -
- { - setReplacingVideo(false) - setVideoUploadStatus('finalizing upload') - setVideoResourceId(videoResourceId) - }} - onVideoResourceCreated={(videoResourceId) => - setVideoResourceId(videoResourceId) - } - /> - -
- ) : ( + {POST_TYPES_WITH_VIDEO.includes(post.fields.postType) && ( +
+ - {videoResource && videoResource.state === 'ready' ? ( -
- - -
- ) : videoResource ? ( -
- video is {videoResource.state} -
- ) : ( -
- video is {videoUploadStatus} -
- )} +
+ video is loading +
- ) - ) : ( - { - setVideoUploadStatus('finalizing upload') - setVideoResourceId(videoResourceId) - }} - onVideoResourceCreated={(videoResourceId) => - setVideoResourceId(videoResourceId) - } - /> - )} -
-
+ } + > + {videoResourceId ? ( + replacingVideo ? ( +
+ { + setReplacingVideo(false) + setVideoUploadStatus('finalizing upload') + setVideoResourceId(videoResourceId) + }} + onVideoResourceCreated={(videoResourceId) => + setVideoResourceId(videoResourceId) + } + /> + +
+ ) : ( + <> + {videoResource && videoResource.state === 'ready' ? ( +
+ + +
+ ) : videoResource ? ( +
+ video is {videoResource.state} +
+ ) : ( +
+ video is {videoUploadStatus} +
+ )} + + ) + ) : ( + { + setVideoUploadStatus('finalizing upload') + setVideoResourceId(videoResourceId) + }} + onVideoResourceCreated={(videoResourceId) => + setVideoResourceId(videoResourceId) + } + /> + )} +
+
+ )} ( + + ), + toolComponent: ( +
+ +
+ ), + }, { id: 'publish-checklist', label: 'Publish Checklist', @@ -104,7 +122,6 @@ export function EditPostForm({ }, { id: 'assistant' }, ]} - theme={theme} > { + const newPath = showingAll ? '/posts' : '/posts?view=all' + router.push(newPath) + } + + return ( + + ) +} diff --git a/apps/egghead/src/app/(content)/posts/_components/resource-resources-list.tsx b/apps/egghead/src/app/(content)/posts/_components/resource-resources-list.tsx new file mode 100644 index 000000000..3fe99962d --- /dev/null +++ b/apps/egghead/src/app/(content)/posts/_components/resource-resources-list.tsx @@ -0,0 +1,157 @@ +'use client' + +import * as React from 'react' +import { useReducer } from 'react' +import { useRouter } from 'next/navigation' +import { + getInitialTreeState, + treeStateReducer, +} from '@/components/lesson-list/data/tree' +import Tree from '@/components/lesson-list/tree' +import { CreatePostForm } from '@/components/resources-crud/create-post-form' +import { SearchExistingLessons } from '@/components/resources-crud/search-existing-lessons' +import { addResourceToResource, createPost } from '@/lib/posts-query' +import { createResource } from '@/lib/resources/create-resources' + +import type { ContentResource } from '@coursebuilder/core/schemas' +import { Button } from '@coursebuilder/ui' +import { CreateResourceForm } from '@coursebuilder/ui/resources-crud/create-resource-form' + +type FormState = { + activeForm: 'lesson' | 'section' | 'existing_lesson' | null +} + +type FormAction = + | { type: 'SHOW_LESSON_FORM' } + | { type: 'SHOW_SECTION_FORM' } + | { type: 'SHOW_EXISTING_LESSON_FORM' } + | { type: 'HIDE_FORM' } + +function formReducer(state: FormState, action: FormAction): FormState { + switch (action.type) { + case 'SHOW_LESSON_FORM': + return { activeForm: 'lesson' } + case 'SHOW_SECTION_FORM': + return { activeForm: 'section' } + case 'SHOW_EXISTING_LESSON_FORM': + return { activeForm: 'existing_lesson' } + case 'HIDE_FORM': + return { activeForm: null } + default: + return state + } +} + +export function ResourceResourcesList({ + resource, +}: { + resource: ContentResource +}) { + const [formState, formDispatch] = useReducer(formReducer, { + activeForm: null, + }) + + const initialData = [ + ...(resource.resources + ? resource.resources.map((resourceItem) => { + if (!resourceItem.resource) { + throw new Error('resourceItem.resource is required') + } + const resources = resourceItem.resource.resources ?? [] + return { + id: resourceItem.resource.id, + label: resourceItem.resource.fields?.title, + type: resourceItem.resource.type, + children: resources.map((resourceItem: any) => { + if (!resourceItem.resource) { + throw new Error('resourceItem.resource is required') + } + return { + id: resourceItem.resource.id, + label: resourceItem.resource.fields?.title, + type: resourceItem.resource.type, + children: [], + itemData: resourceItem as any, + } + }), + itemData: resourceItem as any, + } + }) + : []), + ] + const [state, updateState] = useReducer( + treeStateReducer, + initialData, + getInitialTreeState, + ) + const router = useRouter() + + const handleResourceCreated = async (resource: ContentResource) => { + const resourceItem = await addResourceToResource({ + resource, + resourceId: resource.id, + }) + + if (resourceItem) { + updateState({ + type: 'add-item', + itemId: resourceItem.resource.id, + item: { + id: resourceItem.resource.id, + label: resourceItem.resource.fields?.title, + type: resourceItem.resource.type, + children: [], + itemData: resourceItem as any, + }, + }) + } + + formDispatch({ type: 'HIDE_FORM' }) + router.refresh() + } + + return ( + <> + Resources + +
+ {formState.activeForm === 'lesson' && ( + formDispatch({ type: 'HIDE_FORM' })} + /> + )} + {formState.activeForm === 'existing_lesson' && ( + formDispatch({ type: 'HIDE_FORM' })} + /> + )} +
+ + +
+
+ + ) +} diff --git a/apps/egghead/src/app/(content)/posts/page.tsx b/apps/egghead/src/app/(content)/posts/page.tsx index 9371c8368..81e0836db 100644 --- a/apps/egghead/src/app/(content)/posts/page.tsx +++ b/apps/egghead/src/app/(content)/posts/page.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { Suspense } from 'react' import Link from 'next/link' +import { redirect } from 'next/navigation' import { CreatePost } from '@/app/(content)/posts/_components/create-post' import { DeletePostButton } from '@/app/(content)/posts/_components/delete-post-button' import { @@ -24,15 +25,26 @@ import { CardFooter, CardHeader, CardTitle, + Checkbox, } from '@coursebuilder/ui' -export default async function PostsListPage() { +import { PostsFilterToggle } from './_components/posts-filter-toggle' + +export default async function PostsListPage(props: { + searchParams: Promise<{ [key: string]: string | undefined }> +}) { + const searchParams = await props.searchParams + const { ability } = await getServerAuthSession() + return (
-

Posts

+
+

Posts

+ +
- +
@@ -42,12 +54,12 @@ export default async function PostsListPage() { ) } -async function PostList() { +async function PostList({ showAllPosts }: { showAllPosts: boolean }) { const { ability, session } = await getServerAuthSession() let postsModule - if (ability.can('manage', 'all')) { + if (ability.can('manage', 'all') && showAllPosts) { postsModule = await getAllPosts() } else { postsModule = await getAllPostsForUser(session?.user?.id) @@ -60,8 +72,10 @@ async function PostList() { return ( -
- {post.fields?.state} +
+
+ {post.fields?.state} {post.fields?.postType} +
{/* posts are presented at the root of the site and not in a sub-route */} @@ -103,10 +117,9 @@ async function PostList() { const InstructorByLine = async ({ userId }: { userId: string }) => { const instructor = await getCachedEggheadInstructorForUser(userId) - return instructor ? ( -
- {instructor?.first_name} {instructor?.last_name} -
+ const fullName = `${instructor?.first_name} ${instructor?.last_name}`.trim() + return Boolean(fullName) ? ( +
{fullName}
) : null } diff --git a/apps/egghead/src/components/lesson-list/data/tree.ts b/apps/egghead/src/components/lesson-list/data/tree.ts new file mode 100644 index 000000000..ae664cfb0 --- /dev/null +++ b/apps/egghead/src/components/lesson-list/data/tree.ts @@ -0,0 +1,376 @@ +import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item' +import invariant from 'tiny-invariant' + +import { ContentResource } from '@coursebuilder/core/schemas' + +export type TreeItem = { + id: string + label?: string + isDraft?: boolean + type?: string + children: TreeItem[] + isOpen?: boolean + itemData?: { + position: number + resource: ContentResource + resourceId: string + resourceOfId: string + } +} + +export type TreeState = { + lastAction: TreeAction | null + data: TreeItem[] +} + +export function getInitialTreeState(initialData?: TreeItem[]): TreeState { + return { data: initialData ?? getInitialData(), lastAction: null } +} + +export function getInitialData(): TreeItem[] { + return [ + { + id: '1', + isOpen: true, + + children: [ + { + id: '1.3', + isOpen: true, + + children: [ + { + id: '1.3.1', + children: [], + }, + { + id: '1.3.2', + isDraft: true, + children: [], + }, + ], + }, + { id: '1.4', children: [] }, + ], + }, + { + id: '2', + isOpen: true, + children: [ + { + id: '2.3', + isOpen: true, + + children: [ + { + id: '2.3.1', + children: [], + }, + { + id: '2.3.2', + children: [], + }, + ], + }, + ], + }, + ] +} + +export type TreeAction = + | { + type: 'instruction' + instruction: Instruction + itemId: string + targetId: string + } + | { + type: 'toggle' + itemId: string + } + | { + type: 'add-item' + item: TreeItem + itemId: string + } + | { + type: 'expand' + itemId: string + } + | { + type: 'collapse' + itemId: string + } + | { type: 'modal-move'; itemId: string; targetId: string; index: number } + | { type: 'remove-item'; itemId: string } + +export const tree = { + remove(data: TreeItem[], id: string): TreeItem[] { + return data + .filter((item) => item.id !== id) + .map((item) => { + if (tree.hasChildren(item)) { + return { + ...item, + children: tree.remove(item.children, id), + } + } + return item + }) + }, + insertBefore( + data: TreeItem[], + targetId: string, + newItem: TreeItem, + ): TreeItem[] { + return data.flatMap((item) => { + if (item.id === targetId) { + return [newItem, item] + } + if (tree.hasChildren(item)) { + return { + ...item, + children: tree.insertBefore(item.children, targetId, newItem), + } + } + return item + }) + }, + insertAfter( + data: TreeItem[], + targetId: string, + newItem: TreeItem, + ): TreeItem[] { + return data.flatMap((item) => { + if (item.id === targetId) { + return [item, newItem] + } + + if (tree.hasChildren(item)) { + return { + ...item, + children: tree.insertAfter(item.children, targetId, newItem), + } + } + + return item + }) + }, + insertChild( + data: TreeItem[], + targetId: string, + newItem: TreeItem, + ): TreeItem[] { + return data.flatMap((item) => { + if (item.id === targetId) { + // already a parent: add as first child + return { + ...item, + // opening item so you can see where item landed + isOpen: true, + children: [newItem, ...item.children], + } + } + + if (!tree.hasChildren(item)) { + return item + } + + return { + ...item, + children: tree.insertChild(item.children, targetId, newItem), + } + }) + }, + find(data: TreeItem[], itemId: string): TreeItem | undefined { + for (const item of data) { + if (item.id === itemId) { + return item + } + + if (tree.hasChildren(item)) { + const result = tree.find(item.children, itemId) + if (result) { + return result + } + } + } + }, + getPathToItem({ + current, + targetId, + parentIds = [], + }: { + current: TreeItem[] + targetId: string + parentIds?: string[] + }): string[] | undefined { + for (const item of current) { + if (item.id === targetId) { + return parentIds + } + const nested = tree.getPathToItem({ + current: item.children, + targetId: targetId, + parentIds: [...parentIds, item.id], + }) + if (nested) { + return nested + } + } + }, + hasChildren(item: TreeItem): boolean { + return item.children.length > 0 + }, +} + +export function treeStateReducer( + state: TreeState, + action: TreeAction, +): TreeState { + return { + data: dataReducer(state.data, action), + lastAction: action, + } +} + +const dataReducer = (data: TreeItem[], action: TreeAction) => { + if (action.type === 'add-item') { + return [...data, action.item] + } + + const item = tree.find(data, action.itemId) + if (!item) { + return data + } + + if (action.type === 'instruction') { + const instruction = action.instruction + + if (instruction.type === 'reparent') { + const path = tree.getPathToItem({ + current: data, + targetId: action.targetId, + }) + invariant(path) + const desiredId = path[instruction.desiredLevel] as string + let result = tree.remove(data, action.itemId) + result = tree.insertAfter(result, desiredId, item) + return result + } + + // the rest of the actions require you to drop on something else + if (action.itemId === action.targetId) { + return data + } + + if (instruction.type === 'reorder-above') { + let result = tree.remove(data, action.itemId) + result = tree.insertBefore(result, action.targetId, item) + return result + } + + if (instruction.type === 'reorder-below') { + let result = tree.remove(data, action.itemId) + result = tree.insertAfter(result, action.targetId, item) + return result + } + + if (instruction.type === 'make-child') { + let result = tree.remove(data, action.itemId) + result = tree.insertChild(result, action.targetId, item) + return result + } + + console.warn('TODO: action not implemented', instruction) + + return data + } + + function toggle(item: TreeItem): TreeItem { + if (!tree.hasChildren(item)) { + return item + } + + if (item.id === action.itemId) { + return { ...item, isOpen: !item.isOpen } + } + + return { ...item, children: item.children.map(toggle) } + } + + if (action.type === 'toggle') { + return data.map(toggle) + } + + if (action.type === 'expand') { + if (tree.hasChildren(item) && !item.isOpen) { + return data.map(toggle) + } + return data + } + + if (action.type === 'collapse') { + if (tree.hasChildren(item) && item.isOpen) { + return data.map(toggle) + } + return data + } + + if (action.type === 'remove-item') { + return tree.remove(data, action.itemId) + } + + if (action.type === 'modal-move') { + let result = tree.remove(data, item.id) + + const siblingItems = getChildItems(result, action.targetId) + + if (siblingItems.length === 0) { + if (action.targetId === '') { + /** + * If the target is the root level, and there are no siblings, then + * the item is the only thing in the root level. + */ + result = [item] + } else { + /** + * Otherwise for deeper levels that have no children, we need to + * use `insertChild` instead of inserting relative to a sibling. + */ + result = tree.insertChild(result, action.targetId, item) + } + } else if (action.index === siblingItems.length) { + const relativeTo = siblingItems[siblingItems.length - 1] as TreeItem + /** + * If the position selected is the end, we insert after the last item. + */ + result = tree.insertAfter(result, relativeTo.id, item) + } else { + const relativeTo = siblingItems[action.index] as TreeItem + /** + * Otherwise we insert before the existing item in the given position. + * This results in the new item being in that position. + */ + result = tree.insertBefore(result, relativeTo.id, item) + } + + return result + } + + return data +} + +function getChildItems(data: TreeItem[], targetId: string) { + /** + * An empty string is representing the root + */ + if (targetId === '') { + return data + } + + const targetItem = tree.find(data, targetId) + invariant(targetItem) + + return targetItem.children +} diff --git a/apps/egghead/src/components/lesson-list/pieces/tree/constants.ts b/apps/egghead/src/components/lesson-list/pieces/tree/constants.ts new file mode 100644 index 000000000..59af80d81 --- /dev/null +++ b/apps/egghead/src/components/lesson-list/pieces/tree/constants.ts @@ -0,0 +1 @@ +export const indentPerLevel = 24 diff --git a/apps/egghead/src/components/lesson-list/pieces/tree/tree-context.tsx b/apps/egghead/src/components/lesson-list/pieces/tree/tree-context.tsx new file mode 100644 index 000000000..b65001e62 --- /dev/null +++ b/apps/egghead/src/components/lesson-list/pieces/tree/tree-context.tsx @@ -0,0 +1,54 @@ +import React, { createContext } from 'react' +import { + attachInstruction, + extractInstruction, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item' + +import { Product } from '@coursebuilder/core/schemas' +import type { ContentResource } from '@coursebuilder/core/schemas' + +import type { TreeAction, TreeItem } from '../../data/tree' + +export type TreeContextValue = { + dispatch: (action: TreeAction) => void + uniqueContextId: Symbol + getPathToItem: (itemId: string) => string[] + getMoveTargets: ({ itemId }: { itemId: string }) => TreeItem[] + getChildrenOfItem: (itemId: string) => TreeItem[] + rootResourceId: string | null + rootResource: ContentResource | Product | null + registerTreeItem: (args: { + itemId: string + element: HTMLElement + actionMenuTrigger: HTMLElement + }) => void +} + +export const TreeContext = createContext({ + dispatch: () => {}, + uniqueContextId: Symbol('uniqueId'), + getPathToItem: () => [], + getMoveTargets: () => [], + getChildrenOfItem: () => [], + registerTreeItem: () => {}, + rootResourceId: null, + rootResource: null, +}) + +export type DependencyContext = { + DropIndicator: React.JSX.Element + attachInstruction: typeof attachInstruction + extractInstruction: typeof extractInstruction +} + +export const DependencyContext = createContext({ + DropIndicator: ( + + â—Ž + + ), + attachInstruction: attachInstruction, + extractInstruction: extractInstruction, +}) diff --git a/apps/egghead/src/components/lesson-list/pieces/tree/tree-item.tsx b/apps/egghead/src/components/lesson-list/pieces/tree/tree-item.tsx new file mode 100644 index 000000000..b2acfbe58 --- /dev/null +++ b/apps/egghead/src/components/lesson-list/pieces/tree/tree-item.tsx @@ -0,0 +1,449 @@ +import React, { + Fragment, + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react' +import { usePathname, useRouter } from 'next/navigation' +import { removeSection } from '@/lib/posts-query' +import { cn } from '@/utils/cn' +import { + Instruction, + ItemMode, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item' +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine' +import { + draggable, + dropTargetForElements, + monitorForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter' +import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview' +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview' +import type { DragLocationHistory } from '@atlaskit/pragmatic-drag-and-drop/types' +import { token } from '@atlaskit/tokens' +import { ChevronDown, ChevronUp, Trash } from 'lucide-react' +import pluralize from 'pluralize' +import { createRoot } from 'react-dom/client' +import invariant from 'tiny-invariant' + +import { Button } from '@coursebuilder/ui' + +import { TreeItem as TreeItemType } from '../../data/tree' +import { indentPerLevel } from './constants' +import { DependencyContext, TreeContext } from './tree-context' + +const iconColor = token('color.icon', '#44546F') + +function ChildIcon() { + return ( + + + + ) +} + +function GroupIcon({ isOpen }: { isOpen: boolean }) { + const Icon = isOpen ? : + return ( + + {Icon} + + ) +} + +function Icon({ item }: { item: TreeItemType }) { + if (item.type !== 'section') { + return null // + } + return +} + +function Preview({ item }: { item: TreeItemType }) { + return ( +
Item {item.id}
+ ) +} + +function getParentLevelOfInstruction(instruction: Instruction): number { + if (instruction.type === 'instruction-blocked') { + return getParentLevelOfInstruction(instruction.desired) + } + if (instruction.type === 'reparent') { + return instruction.desiredLevel - 1 + } + return instruction.currentLevel - 1 +} + +function delay({ + waitMs: timeMs, + fn, +}: { + waitMs: number + fn: () => void +}): () => void { + let timeoutId: number | null = window.setTimeout(() => { + timeoutId = null + fn() + }, timeMs) + return function cancel() { + if (timeoutId) { + window.clearTimeout(timeoutId) + timeoutId = null + } + } +} + +const TreeItem = memo(function TreeItem({ + item, + mode, + level, +}: { + item: TreeItemType + mode: ItemMode + level: number +}) { + const buttonRef = useRef(null) + + const [state, setState] = useState< + 'idle' | 'dragging' | 'preview' | 'parent-of-instruction' + >('idle') + const [instruction, setInstruction] = useState(null) + const cancelExpandRef = useRef<(() => void) | null>(null) + + const { dispatch, uniqueContextId, getPathToItem, rootResource } = + useContext(TreeContext) + const { DropIndicator, attachInstruction, extractInstruction } = + useContext(DependencyContext) + const toggleOpen = useCallback(() => { + dispatch({ type: 'toggle', itemId: item.id }) + }, [dispatch, item]) + + const cancelExpand = useCallback(() => { + cancelExpandRef.current?.() + cancelExpandRef.current = null + }, []) + + const clearParentOfInstructionState = useCallback(() => { + setState((current) => + current === 'parent-of-instruction' ? 'idle' : current, + ) + }, []) + + // When an item has an instruction applied + // we are highlighting it's parent item for improved clarity + const shouldHighlightParent = useCallback( + (location: DragLocationHistory): boolean => { + const target = location.current.dropTargets[0] + + if (!target) { + return false + } + + const instruction = extractInstruction(target.data) + + if (!instruction) { + return false + } + + const targetId = target.data.id + invariant(typeof targetId === 'string') + + const path = getPathToItem(targetId) + const parentLevel: number = getParentLevelOfInstruction(instruction) + const parentId = path[parentLevel] + return parentId === item.id + }, + [getPathToItem, extractInstruction, item], + ) + + useEffect(() => { + invariant(buttonRef.current) + + function updateIsParentOfInstruction({ + location, + }: { + location: DragLocationHistory + }) { + if (shouldHighlightParent(location)) { + setState('parent-of-instruction') + return + } + clearParentOfInstructionState() + } + + return combine( + draggable({ + element: buttonRef.current, + getInitialData: () => ({ + id: item.id, + type: 'tree-item', + isOpenOnDragStart: item.isOpen, + uniqueContextId, + item: item, + }), + onGenerateDragPreview: ({ nativeSetDragImage }) => { + setCustomNativeDragPreview({ + getOffset: pointerOutsideOfPreview({ x: '16px', y: '8px' }), + render: ({ container }) => { + const root = createRoot(container) + root.render() + return () => root.unmount() + }, + nativeSetDragImage, + }) + }, + onDragStart: ({ source }) => { + setState('dragging') + // collapse open items during a drag + if (source.data.isOpenOnDragStart) { + dispatch({ type: 'collapse', itemId: item.id }) + } + }, + onDrop: ({ source }) => { + setState('idle') + if (source.data.isOpenOnDragStart) { + dispatch({ type: 'expand', itemId: item.id }) + } + }, + }), + dropTargetForElements({ + element: buttonRef.current, + getData: ({ input, element, source }) => { + const data = { id: item.id } + + return attachInstruction(data, { + input, + element, + indentPerLevel, + currentLevel: level, + mode, + block: + (source.data.item as any).type === 'section' || + item.type !== 'section' + ? ['make-child'] + : [], + }) + }, + canDrop: (allData) => { + const { source } = allData + return ( + source.data.type === 'tree-item' && + source.data.uniqueContextId === uniqueContextId + ) + }, + + getIsSticky: () => true, + onDrag: ({ self, source }) => { + const instruction = extractInstruction(self.data) + + if (source.data.id !== item.id) { + // expand after 500ms if still merging + if ( + instruction?.type === 'make-child' && + item.children.length && + !item.isOpen && + !cancelExpandRef.current + ) { + cancelExpandRef.current = delay({ + waitMs: 500, + fn: () => dispatch({ type: 'expand', itemId: item.id }), + }) + } + if (instruction?.type !== 'make-child' && cancelExpandRef.current) { + cancelExpand() + } + + setInstruction(instruction) + return + } + if (instruction?.type === 'reparent') { + setInstruction(instruction) + return + } + setInstruction(null) + }, + onDragLeave: () => { + cancelExpand() + setInstruction(null) + }, + onDrop: () => { + cancelExpand() + setInstruction(null) + }, + }), + monitorForElements({ + canMonitor: ({ source }) => + source.data.uniqueContextId === uniqueContextId, + onDragStart: updateIsParentOfInstruction, + onDrag: updateIsParentOfInstruction, + onDrop() { + clearParentOfInstructionState() + }, + }), + ) + }, [ + dispatch, + item, + mode, + level, + cancelExpand, + uniqueContextId, + extractInstruction, + attachInstruction, + getPathToItem, + clearParentOfInstructionState, + shouldHighlightParent, + ]) + + useEffect( + function mount() { + return function unmount() { + cancelExpand() + } + }, + [cancelExpand], + ) + + const aria = (() => { + // if there are no children, we don't need to set aria attributes + + if (!item.children.length) { + return undefined + } + + return { + 'aria-expanded': item.isOpen, + 'aria-controls': `tree-item-${item.id}--subtree`, + } + })() + + const router = useRouter() + const pathname = usePathname() + + return ( + +
+ + {item.type === 'section' && item.children.length === 0 && ( + + )} +
+ {item.children.length && item.isOpen ? ( +
+ {item.children.map((child, index, array) => { + const childType: ItemMode = (() => { + if (child.children.length && child.isOpen) { + return 'expanded' + } + + if (index === array.length - 1) { + return 'last-in-group' + } + + return 'standard' + })() + return ( + + ) + })} +
+ ) : null} +
+ ) +}) + +export default TreeItem diff --git a/apps/egghead/src/components/lesson-list/tree.tsx b/apps/egghead/src/components/lesson-list/tree.tsx new file mode 100644 index 000000000..9bfe46649 --- /dev/null +++ b/apps/egghead/src/components/lesson-list/tree.tsx @@ -0,0 +1,317 @@ +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useParams } from 'next/navigation' +import { updateResourcePositions } from '@/lib/posts-query' +import { triggerPostMoveFlash } from '@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash' +import { + Instruction, + ItemMode, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item' +import * as liveRegion from '@atlaskit/pragmatic-drag-and-drop-live-region' +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine' +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' +import memoizeOne from 'memoize-one' +import invariant from 'tiny-invariant' + +import { Product } from '@coursebuilder/core/schemas' +import type { ContentResource } from '@coursebuilder/core/schemas' + +import { + tree, + TreeAction, + TreeItem as TreeItemType, + TreeState, +} from './data/tree' +import { + DependencyContext, + TreeContext, + TreeContextValue, +} from './pieces/tree/tree-context' +import TreeItem from './pieces/tree/tree-item' + +type CleanupFn = () => void + +function createTreeItemRegistry() { + const registry = new Map< + string, + { element: HTMLElement; actionMenuTrigger: HTMLElement } + >() + + const registerTreeItem = ({ + itemId, + element, + actionMenuTrigger, + }: { + itemId: string + element: HTMLElement + actionMenuTrigger: HTMLElement + }): CleanupFn => { + registry.set(itemId, { element, actionMenuTrigger }) + return () => { + registry.delete(itemId) + } + } + + return { registry, registerTreeItem } +} + +export default function Tree({ + state, + updateState, + rootResourceId, + rootResource, +}: { + state: TreeState + updateState: React.Dispatch + rootResourceId: string + rootResource: ContentResource | Product +}) { + const params = useParams<{ module: string }>() + + const ref = useRef(null) + const { extractInstruction } = useContext(DependencyContext) + + const [{ registry, registerTreeItem }] = useState(createTreeItemRegistry) + + const { data, lastAction } = state + let lastStateRef = useRef(data) + useEffect(() => { + lastStateRef.current = data + }, [data]) + + const saveTreeData = useCallback(async () => { + const currentData = lastStateRef.current + + const resourcePositions = currentData.flatMap((item, index) => { + if (!item.itemData) return [] + + const children = item.children.flatMap((childItem, childIndex) => { + if (!childItem.itemData) return [] + + return { + currentParentResourceId: childItem.itemData.resourceOfId, + parentResourceId: item.itemData?.resourceId as string, + resourceId: childItem.itemData.resourceId, + position: childIndex, + } + }) + + return [ + { + currentParentResourceId: item.itemData.resourceOfId, + parentResourceId: rootResourceId, + resourceId: item.itemData.resourceId, + position: index, + children, + }, + ] + }) + + await updateResourcePositions(resourcePositions) + }, [rootResourceId]) + + useEffect(() => { + if (lastAction === null) { + return + } + + if (lastAction.type === 'toggle') { + return + } + + saveTreeData() + + if (lastAction.type === 'modal-move') { + const parentName = + lastAction.targetId === '' ? 'the root' : `Item ${lastAction.targetId}` + + liveRegion.announce( + `You've moved Item ${lastAction.itemId} to position ${lastAction.index + 1} in ${parentName}.`, + ) + + const { element, actionMenuTrigger } = + registry.get(lastAction.itemId) ?? {} + if (element) { + triggerPostMoveFlash(element) + } + + /** + * Only moves triggered by the modal will result in focus being + * returned to the trigger. + */ + actionMenuTrigger?.focus() + + return + } + + if (lastAction.type === 'instruction') { + const { element } = registry.get(lastAction.itemId) ?? {} + if (element) { + triggerPostMoveFlash(element) + } + + return + } + }, [lastAction, registry, saveTreeData]) + + useEffect(() => { + return () => { + liveRegion.cleanup() + } + }, []) + + /** + * Returns the items that the item with `itemId` can be moved to. + * + * Uses a depth-first search (DFS) to compile a list of possible targets. + */ + const getMoveTargets = useCallback(({ itemId }: { itemId: string }) => { + const data = lastStateRef.current + + const targets = [] + + const searchStack = Array.from(data) + while (searchStack.length > 0) { + const node = searchStack.pop() + + if (!node) { + continue + } + + /** + * If the current node is the item we want to move, then it is not a valid + * move target and neither are its children. + */ + if (node.id === itemId) { + continue + } + + /** + * Draft items cannot have children. + */ + if (node.isDraft) { + continue + } + + targets.push(node) + + node.children.forEach((childNode) => searchStack.push(childNode)) + } + + return targets + }, []) + + const getChildrenOfItem = useCallback((itemId: string) => { + const data = lastStateRef.current + + /** + * An empty string is representing the root + */ + if (itemId === '') { + return data + } + + const item = tree.find(data, itemId) + invariant(item) + return item.children + }, []) + + const context = useMemo( + () => ({ + dispatch: updateState, + uniqueContextId: Symbol('unique-id'), + // memoizing this function as it is called by all tree items repeatedly + // An ideal refactor would be to update our data shape + // to allow quick lookups of parents + getPathToItem: memoizeOne( + (targetId: string) => + tree.getPathToItem({ current: lastStateRef.current, targetId }) ?? [], + ), + getMoveTargets, + getChildrenOfItem, + registerTreeItem, + rootResourceId, + rootResource, + }), + [ + getChildrenOfItem, + getMoveTargets, + registerTreeItem, + updateState, + rootResourceId, + rootResource, + ], + ) + + useEffect(() => { + invariant(ref.current) + return combine( + monitorForElements({ + canMonitor: ({ source }) => + source.data.uniqueContextId === context.uniqueContextId, + onDrop(args) { + const { location, source } = args + // didn't drop on anything + if (!location.current.dropTargets.length) { + return + } + + if (source.data.type === 'tree-item') { + const itemId = source.data.id as string + + const target = location.current.dropTargets[0] + + if (!target?.data) { + throw new Error('target.data is undefined') + } + + const targetId = target.data.id as string + + const instruction: Instruction | null = extractInstruction( + target.data, + ) + + if (instruction !== null) { + updateState({ + type: 'instruction', + instruction, + itemId, + targetId, + }) + } + } + }, + }), + ) + }, [context, extractInstruction, updateState]) + + return ( + +
+
+ {data.map((item, index, array) => { + const type: ItemMode = (() => { + if (item.children.length && item.isOpen) { + return 'expanded' + } + + if (index === array.length - 1) { + return 'last-in-group' + } + + return 'standard' + })() + + return + })} +
+
+
+ ) +} diff --git a/apps/egghead/src/components/resources-crud/create-post-form.tsx b/apps/egghead/src/components/resources-crud/create-post-form.tsx new file mode 100644 index 000000000..6ed0f767a --- /dev/null +++ b/apps/egghead/src/components/resources-crud/create-post-form.tsx @@ -0,0 +1,132 @@ +'use client' + +import * as React from 'react' +import { NewPost, PostTypeSchema } from '@/lib/posts' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import type { ContentResource } from '@coursebuilder/core/schemas' +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@coursebuilder/ui' + +export function CreatePostForm({ + resourceType, + onCreate, + createPost, + restrictToPostType, + onCancel, +}: { + resourceType: string + onCreate: (resource: ContentResource) => Promise + createPost: (values: NewPost) => Promise + restrictToPostType?: string + onCancel?: () => void +}) { + const form = useForm<{ fields: { title: string; postType: string } }>({ + resolver: zodResolver( + z.object({ + fields: z.object({ + title: z.string(), + postType: PostTypeSchema, + }), + }), + ), + defaultValues: { + fields: { + title: '', + postType: restrictToPostType || 'lesson', + }, + }, + }) + + const internalOnSubmit = async (values: { + fields: { title: string; postType: string } + }) => { + const resource = await createPost({ + title: values.fields.title, + postType: PostTypeSchema.parse(values.fields.postType), + }) + form.reset() + if (resource) { + await onCreate(resource) + } + } + + return ( +
+ +
+ ( + + Title + + A title should summarize the {resourceType} and explain what + it is about clearly. + + + + + + + )} + /> + {!restrictToPostType && ( + ( + + Post Type + + Select the type of content you are creating + + + + + + + )} + /> + )} +
+
+ {onCancel && ( + + )} + +
+
+ + ) +} diff --git a/apps/egghead/src/components/resources-crud/create-resource-page.tsx b/apps/egghead/src/components/resources-crud/create-resource-page.tsx index bf495d88a..95758411a 100644 --- a/apps/egghead/src/components/resources-crud/create-resource-page.tsx +++ b/apps/egghead/src/components/resources-crud/create-resource-page.tsx @@ -1,10 +1,13 @@ import * as React from 'react' import { notFound, redirect } from 'next/navigation' +import { createPost } from '@/lib/posts-query' import { createResource } from '@/lib/resources/create-resources' import { getServerAuthSession } from '@/server/auth' import type { ContentResource } from '@coursebuilder/core/schemas' -import { CreateResourceCard } from '@coursebuilder/ui/resources-crud/create-resource-card' +import { Card, CardContent, CardFooter, CardHeader } from '@coursebuilder/ui' + +import { CreatePostForm } from './create-post-form' export const dynamic = 'force-dynamic' @@ -21,14 +24,20 @@ export default async function CreateResourcePage({ return (
- { - 'use server' - redirect(`/${resource.fields?.slug}`) - }} - createResource={createResource} - /> + + + + { + 'use server' + redirect(`/${resource.fields?.slug}`) + }} + createPost={createPost} + /> + + +
) } diff --git a/apps/egghead/src/components/resources-crud/new-resource-with-video-form.tsx b/apps/egghead/src/components/resources-crud/new-resource-with-video-form.tsx new file mode 100644 index 000000000..3ba5a1e8f --- /dev/null +++ b/apps/egghead/src/components/resources-crud/new-resource-with-video-form.tsx @@ -0,0 +1,297 @@ +'use client' + +import * as React from 'react' +import { + NewPost, + POST_TYPES_WITH_VIDEO, + PostType, + PostTypeSchema, +} from '@/lib/posts' +import { zodResolver } from '@hookform/resolvers/zod' +import { FileVideo } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { + type ContentResource, + type VideoResource, +} from '@coursebuilder/core/schemas' +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@coursebuilder/ui' +import { cn } from '@coursebuilder/ui/utils/cn' + +const NewResourceWithVideoSchema = z.object({ + title: z.string().min(2).max(90), + videoResourceId: z.string().min(4, 'Please upload a video'), +}) + +type NewResourceWithVideo = z.infer + +const FormValuesSchema = NewResourceWithVideoSchema.extend({ + postType: PostTypeSchema, + videoResourceId: z.string().optional(), +}) + +type FormValues = z.infer + +export function NewResourceWithVideoForm({ + getVideoResource, + createResource, + onResourceCreated, + availableResourceTypes, + className, + children, +}: { + getVideoResource: (idOrSlug?: string) => Promise + createResource: (values: NewPost) => Promise + onResourceCreated: (resource: ContentResource, title: string) => Promise + availableResourceTypes?: PostType[] | undefined + className?: string + children: ( + handleSetVideoResourceId: (value: string) => void, + ) => React.ReactNode +}) { + const [videoResourceId, setVideoResourceId] = React.useState< + string | undefined + >() + const [videoResourceValid, setVideoResourceValid] = + React.useState(false) + const [isValidatingVideoResource, setIsValidatingVideoResource] = + React.useState(false) + + const form = useForm({ + resolver: zodResolver(FormValuesSchema), + defaultValues: { + title: '', + videoResourceId: undefined, + postType: availableResourceTypes?.[0] || 'lesson', + }, + }) + + async function* pollVideoResource( + videoResourceId: string, + maxAttempts = 30, + initialDelay = 250, + delayIncrement = 250, + ) { + let delay = initialDelay + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const videoResource = await getVideoResource(videoResourceId) + if (videoResource) { + yield videoResource + return + } + + await new Promise((resolve) => setTimeout(resolve, delay)) + delay += delayIncrement + } + + throw new Error('Video resource not found after maximum attempts') + } + + const [isSubmitting, setIsSubmitting] = React.useState(false) + + const onSubmit = async (values: FormValues) => { + try { + setIsSubmitting(true) + if (values.videoResourceId) { + await pollVideoResource(values.videoResourceId).next() + } + const resource = await createResource(values as any) + console.log({ resource }) + if (!resource) { + // Handle edge case, e.g., toast an error message + console.log('no resource in onSubmit') + return + } + + onResourceCreated(resource, form.watch('title')) + } catch (error) { + console.error('Error polling video resource:', error) + // handle error, e.g. toast an error message + } finally { + form.reset() + setVideoResourceId(videoResourceId) + form.setValue('videoResourceId', '') + setIsSubmitting(false) + } + } + + async function handleSetVideoResourceId(videoResourceId: string) { + try { + console.log('inside handleSetVideoResourceId') + setVideoResourceId(videoResourceId) + setIsValidatingVideoResource(true) + form.setValue('videoResourceId', videoResourceId) + console.log('setValue videoResourceId: ', videoResourceId) + await pollVideoResource(videoResourceId).next() + + setVideoResourceValid(true) + console.log('setting video resource valid') + setIsValidatingVideoResource(false) + } catch (error) { + setVideoResourceValid(false) + console.log('setting video resource INVALID') + form.setError('videoResourceId', { message: 'Video resource not found' }) + setVideoResourceId('') + form.setValue('videoResourceId', '') + setIsValidatingVideoResource(false) + } + } + + const selectedPostType = form.watch('postType') + + return ( +
+ + ( + + Title + + A title should summarize the resource and explain what it is + about clearly. + + + + + + + )} + /> + {availableResourceTypes && ( + { + const descriptions = { + lesson: 'A traditional egghead lesson video', + article: 'A standard article', + podcast: + 'A podcast episode that will be distributed across podcast networks via the egghead podcast', + course: + 'A collection of lessons that will be distributed as a course', + } + + return ( + + Type + + Select the type of resource you are creating. + + + + + {field.value && ( +
+ {descriptions[field.value as keyof typeof descriptions]} +
+ )} + +
+ ) + }} + /> + )} + {POST_TYPES_WITH_VIDEO.includes(selectedPostType) && ( + ( + + + {videoResourceId ? 'Video' : 'Upload a Video'} + + + + You can upload a video later if needed. + + Your video will be uploaded and then transcribed + automatically. + + + + + {!videoResourceId ? ( + children(handleSetVideoResourceId) + ) : ( +
+
+ + + {videoResourceId} + +
+ +
+ )} + {isValidatingVideoResource && !videoResourceValid ? ( + Processing Upload + ) : null} + + +
+ )} + /> + )} + + + + ) +} diff --git a/apps/egghead/src/components/resources-crud/search-existing-lessons.tsx b/apps/egghead/src/components/resources-crud/search-existing-lessons.tsx new file mode 100644 index 000000000..7e80a9bcf --- /dev/null +++ b/apps/egghead/src/components/resources-crud/search-existing-lessons.tsx @@ -0,0 +1,111 @@ +'use client' + +import * as React from 'react' +import { useDebounce } from '@/lib/hooks/use-debounce' +import { searchLessons } from '@/lib/posts-query' +import { ChevronsUpDown } from 'lucide-react' + +import type { ContentResource } from '@coursebuilder/core/schemas' +import { + Button, + Command, + CommandGroup, + CommandInput, + CommandItem, + Popover, + PopoverContent, + PopoverTrigger, +} from '@coursebuilder/ui' + +type SearchExistingLessonsProps = { + onSelect: (resource: ContentResource) => void + onCancel: () => void +} + +export function SearchExistingLessons({ + onSelect, + onCancel, +}: SearchExistingLessonsProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState('') + const [results, setResults] = React.useState([]) + const [isSearching, setIsSearching] = React.useState(false) + const debouncedSearchTerm = useDebounce(searchTerm, 300) + + React.useEffect(() => { + async function search() { + if (!debouncedSearchTerm) { + setResults([]) + return + } + + setIsSearching(true) + try { + const searchResults = await searchLessons(debouncedSearchTerm) + setResults(searchResults) + } catch (error) { + console.error('Search error:', error) + setResults([]) + } finally { + setIsSearching(false) + } + } + + search() + }, [debouncedSearchTerm]) + + return ( +
+
+ + + + + + + + {isSearching ? ( +
+ Searching... +
+ ) : results.length > 0 ? ( + + {results.map((result) => ( + { + onSelect(result) + setOpen(false) + }} + > + {result.fields?.title || 'Untitled'} + + ))} + + ) : searchTerm.length >= 2 ? ( +
+ No lessons found +
+ ) : null} +
+
+
+ +
+
+ ) +} diff --git a/apps/egghead/src/inngest/functions/migrate-tips-to-posts.ts b/apps/egghead/src/inngest/functions/migrate-tips-to-posts.ts index 9137eed7d..7d9867589 100644 --- a/apps/egghead/src/inngest/functions/migrate-tips-to-posts.ts +++ b/apps/egghead/src/inngest/functions/migrate-tips-to-posts.ts @@ -14,7 +14,7 @@ import { Post, PostSchema } from '@/lib/posts' import { createNewPostVersion, getPost } from '@/lib/posts-query' import { EggheadTagSchema } from '@/lib/tags' import { guid } from '@/utils/guid' -import { createClient } from '@sanity/client' +// import { createClient } from '@sanity/client' import slugify from '@sindresorhus/slugify' import { eq, or, sql } from 'drizzle-orm' import { z } from 'zod' @@ -59,29 +59,29 @@ export const TipsUpdatedEventSchema = z.object({ export type TipsUpdatedEvent = z.infer -export const sanityWriteClient = createClient({ - projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, - dataset: process.env.NEXT_PUBLIC_SANITY_DATASET_ID || 'production', - useCdn: false, // `false` if you want to ensure fresh data - apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION, - token: process.env.SANITY_EDITOR_TOKEN, -}) +// export const sanityWriteClient = createClient({ +// projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, +// dataset: process.env.NEXT_PUBLIC_SANITY_DATASET_ID || 'production', +// useCdn: false, // `false` if you want to ensure fresh data +// apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION, +// token: process.env.SANITY_EDITOR_TOKEN, +// }) export const migrateTipsToPosts = inngest.createFunction( { id: 'migrate-tips-to-posts', name: 'Migrate Tips to Posts' }, { event: TIPS_UPDATED_EVENT }, async ({ event, step }) => { const tips = await step.run('Load tips from sanity', async () => { - const tips = await sanityWriteClient.fetch(`*[_type == "tip"]{ - ..., - "resources": resources[]->, - "collaborators": collaborators[]->, - "softwareLibraries": softwareLibraries[]{ - ..., - "library": library-> - } - }`) - return tips + // const tips = await sanityWriteClient.fetch(`*[_type == "tip"]{ + // ..., + // "resources": resources[]->, + // "collaborators": collaborators[]->, + // "softwareLibraries": softwareLibraries[]{ + // ..., + // "library": library-> + // } + // }`) + return [] as any }) for (const loadedTip of tips) { diff --git a/apps/egghead/src/lib/hooks/use-debounce.ts b/apps/egghead/src/lib/hooks/use-debounce.ts new file mode 100644 index 000000000..01419802c --- /dev/null +++ b/apps/egghead/src/lib/hooks/use-debounce.ts @@ -0,0 +1,17 @@ +import * as React from 'react' + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value) + + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/apps/egghead/src/lib/posts-query.ts b/apps/egghead/src/lib/posts-query.ts index 0ed7dcf76..f59cde7a1 100644 --- a/apps/egghead/src/lib/posts-query.ts +++ b/apps/egghead/src/lib/posts-query.ts @@ -26,7 +26,7 @@ import { getServerAuthSession } from '@/server/auth' import { guid } from '@/utils/guid' import { subject } from '@casl/ability' import slugify from '@sindresorhus/slugify' -import { and, asc, desc, eq, or, sql } from 'drizzle-orm' +import { and, asc, desc, eq, like, or, sql } from 'drizzle-orm' import readingTime from 'reading-time' import Typesense from 'typesense' import { z } from 'zod' @@ -35,8 +35,13 @@ import 'server-only' import { POST_CREATED_EVENT } from '@/inngest/events/post-created' import { inngest } from '@/inngest/inngest.server' +import { last } from 'lodash' import { getMuxAsset } from '@coursebuilder/core/lib/mux' +import { + ContentResource, + ContentResourceSchema, +} from '@coursebuilder/core/schemas' import { createEggheadLesson, @@ -49,18 +54,45 @@ import { import { EggheadTag, EggheadTagSchema } from './tags' import { upsertPostToTypeSense } from './typesense' +export async function searchLessons(searchTerm: string) { + const { session } = await getServerAuthSession() + const userId = session?.user?.id + + const lessons = await db.query.contentResource.findMany({ + where: and( + eq(contentResource.type, 'post'), + sql`JSON_EXTRACT(${contentResource.fields}, '$.postType') = 'lesson'`, + or( + sql`LOWER(JSON_EXTRACT(${contentResource.fields}, '$.title')) LIKE ${`%${searchTerm.toLowerCase()}%`}`, + sql`LOWER(JSON_EXTRACT(${contentResource.fields}, '$.body')) LIKE ${`%${searchTerm.toLowerCase()}%`}`, + ), + ), + orderBy: [ + // Sort by createdById matching current user (if logged in) + sql`CASE + WHEN ${contentResource.createdById} = ${userId} THEN 0 + ELSE 1 + END`, + // Secondary sort by title match (prioritize title matches) + sql`CASE + WHEN LOWER(JSON_EXTRACT(${contentResource.fields}, '$.title')) LIKE ${`%${searchTerm.toLowerCase()}%`} THEN 0 + ELSE 1 + END`, + // Then sort by title alphabetically + sql`JSON_EXTRACT(${contentResource.fields}, '$.title')`, + ], + }) + + return ContentResourceSchema.array().parse(lessons) +} + export async function deletePost(id: string) { + console.log('deleting post', id) const { session, ability } = await getServerAuthSession() const user = session?.user + const postData = await getPost(id) - const post = PostSchema.nullish().parse( - await db.query.contentResource.findFirst({ - where: eq(contentResource.id, id), - with: { - resources: true, - }, - }), - ) + const post = PostSchema.nullish().parse(postData) if (!post) { throw new Error(`Post with id ${id} not found.`) @@ -200,6 +232,8 @@ export async function getAllPostsForUser(userId?: string): Promise { } export async function createPost(input: NewPost) { + console.log('createPost', input) + const { session, ability } = await getServerAuthSession() if (!session?.user?.id || !ability.can('create', 'Content')) { @@ -315,13 +349,15 @@ export async function writeNewPostToDatabase(input: { const newPostId = `post_${postGuid}` const videoResource = await courseBuilderAdapter.getVideoResource(videoResourceId) - - const eggheadLessonId = await createEggheadLesson({ - title: title, - slug: `${slugify(title)}~${postGuid}`, - instructorId: eggheadInstructorId, - guid: postGuid, - }) + const TYPES_WITH_LESSONS = ['lesson', 'podcast', 'tip'] + const eggheadLessonId = TYPES_WITH_LESSONS.includes(input.newPost.postType) + ? await createEggheadLesson({ + title: title, + slug: `${slugify(title)}~${postGuid}`, + instructorId: eggheadInstructorId, + guid: postGuid, + }) + : null await db .insert(contentResource) @@ -333,8 +369,9 @@ export async function writeNewPostToDatabase(input: { title, state: 'draft', visibility: 'unlisted', + postType: input.newPost.postType, slug: `${slugify(title)}~${postGuid}`, - eggheadLessonId, + ...(eggheadLessonId ? { eggheadLessonId } : {}), }, }) .catch((error) => { @@ -450,14 +487,7 @@ export async function writePostUpdateToDatabase(input: { } export async function deletePostFromDatabase(id: string) { - const post = PostSchema.nullish().parse( - await db.query.contentResource.findFirst({ - where: eq(contentResource.id, id), - with: { - resources: true, - }, - }), - ) + const post = PostSchema.nullish().parse(await getPost(id)) if (!post) { throw new Error(`Post with id ${id} not found.`) @@ -544,3 +574,128 @@ export async function getVideoDuration( } return 0 } + +export const addResourceToResource = async ({ + resource, + resourceId, +}: { + resource: ContentResource + resourceId: string +}) => { + const parentResource = await db.query.contentResource.findFirst({ + where: like(contentResource.id, `%${last(resourceId.split('-'))}%`), + with: { + resources: true, + }, + }) + + if (!parentResource) { + throw new Error(`Workshop with id ${resourceId} not found`) + } + await db.insert(contentResourceResource).values({ + resourceOfId: parentResource.id, + resourceId: resource.id, + position: parentResource.resources.length, + }) + + const resourceResource = db.query.contentResourceResource.findFirst({ + where: and( + eq(contentResourceResource.resourceOfId, parentResource.id), + eq(contentResourceResource.resourceId, resource.id), + ), + with: { + resource: true, + }, + }) + + revalidateTag('posts') + + return resourceResource +} + +export const updateResourcePosition = async ({ + currentParentResourceId, + parentResourceId, + resourceId, + position, +}: { + currentParentResourceId: string + parentResourceId: string + resourceId: string + position: number +}) => { + const result = await db + .update(contentResourceResource) + .set({ position, resourceOfId: parentResourceId }) + .where( + and( + eq(contentResourceResource.resourceOfId, currentParentResourceId), + eq(contentResourceResource.resourceId, resourceId), + ), + ) + + revalidateTag('posts') + + return result +} + +type positionInputIten = { + currentParentResourceId: string + parentResourceId: string + resourceId: string + position: number + children?: positionInputIten[] +} + +export const updateResourcePositions = async (input: positionInputIten[]) => { + const result = await db.transaction(async (trx) => { + for (const { + currentParentResourceId, + parentResourceId, + resourceId, + position, + children, + } of input) { + await trx + .update(contentResourceResource) + .set({ position, resourceOfId: parentResourceId }) + .where( + and( + eq(contentResourceResource.resourceOfId, currentParentResourceId), + eq(contentResourceResource.resourceId, resourceId), + ), + ) + for (const child of children || []) { + await trx + .update(contentResourceResource) + .set({ + position: child.position, + resourceOfId: child.parentResourceId, + }) + .where( + and( + eq( + contentResourceResource.resourceOfId, + child.currentParentResourceId, + ), + eq(contentResourceResource.resourceId, child.resourceId), + ), + ) + } + } + }) + + return result +} + +export async function removeSection( + sectionId: string, + pathToRevalidate: string, +) { + await db.delete(contentResource).where(eq(contentResource.id, sectionId)) + + revalidatePath(pathToRevalidate) + return await db + .delete(contentResourceResource) + .where(eq(contentResourceResource.resourceId, sectionId)) +} diff --git a/apps/egghead/src/lib/posts.ts b/apps/egghead/src/lib/posts.ts index fe0086104..547b2aa0d 100644 --- a/apps/egghead/src/lib/posts.ts +++ b/apps/egghead/src/lib/posts.ts @@ -5,6 +5,8 @@ import { z } from 'zod' import { ContentResourceSchema } from '@coursebuilder/core/schemas/content-resource-schema' +export const POST_TYPES_WITH_VIDEO = ['lesson', 'podcast', 'tip'] + export const PostActionSchema = z.union([ z.literal('publish'), z.literal('unpublish'), @@ -65,6 +67,7 @@ export type Post = z.infer export const NewPostSchema = z.object({ title: z.string().min(2).max(90), + postType: PostTypeSchema.default('lesson'), videoResourceId: z.string().min(4, 'Please upload a video').nullish(), }) diff --git a/apps/egghead/src/lib/tags-query.ts b/apps/egghead/src/lib/tags-query.ts index 86e86bc77..f99a75ef3 100644 --- a/apps/egghead/src/lib/tags-query.ts +++ b/apps/egghead/src/lib/tags-query.ts @@ -1,3 +1,5 @@ +'use server' + import { revalidateTag, unstable_cache } from 'next/cache' import { db } from '@/db' import { eggheadPgQuery } from '@/db/eggheadPostgres' diff --git a/apps/egghead/src/styles/globals.css b/apps/egghead/src/styles/globals.css index 51102920a..5b6ce5702 100644 --- a/apps/egghead/src/styles/globals.css +++ b/apps/egghead/src/styles/globals.css @@ -1,9 +1,9 @@ +@import './login.css'; + @tailwind base; @tailwind components; @tailwind utilities; -@import './login.css'; - @layer base { :root { --nav-height: 3.5rem; diff --git a/apps/egghead/tsconfig.json b/apps/egghead/tsconfig.json index c77745a6e..beeba99ed 100644 --- a/apps/egghead/tsconfig.json +++ b/apps/egghead/tsconfig.json @@ -34,7 +34,8 @@ "**/*.cjs", "**/*.mjs", ".next/types/**/*.ts", - "./node_modules/y-codemirror.next/dist/src/index.d.ts" + "./node_modules/y-codemirror.next/dist/src/index.d.ts", + "tailwind.config.ts" ], "exclude": ["node_modules"] } diff --git a/packages/ui/package.json b/packages/ui/package.json index 84c76d583..ec47b3d0e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -40,7 +40,7 @@ "@radix-ui/react-tooltip": "^1.1.3", "@types/md5": "^2.3.5", "@uiw/codemirror-themes": "^4.21.24", - "@uiw/react-codemirror": "^4.23.0", + "@uiw/react-codemirror": "^4.23.6", "@uiw/react-markdown-editor": "^6.1.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aab24f4de..075b4019e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2178,6 +2178,21 @@ importers: apps/egghead: dependencies: + '@atlaskit/pragmatic-drag-and-drop': + specifier: ^1.1.3 + version: 1.1.3 + '@atlaskit/pragmatic-drag-and-drop-flourish': + specifier: ^1.0.4 + version: 1.0.4(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) + '@atlaskit/pragmatic-drag-and-drop-hitbox': + specifier: ^1.0.3 + version: 1.0.3 + '@atlaskit/pragmatic-drag-and-drop-live-region': + specifier: ^1.0.3 + version: 1.0.3 + '@atlaskit/tokens': + specifier: ^1.42.1 + version: 1.42.1(react@19.0.0-rc-02c0e824-20241028) '@auth/core': specifier: ^0.37.2 version: 0.37.2(nodemailer@6.9.11) @@ -2331,6 +2346,9 @@ importers: lucide-react: specifier: ^0.288.0 version: 0.288.0(react@19.0.0-rc-02c0e824-20241028) + memoize-one: + specifier: ^6.0.0 + version: 6.0.0 nanoid: specifier: ^5.0.2 version: 5.0.6 @@ -2412,6 +2430,12 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.1) + tailwindcss-radix: + specifier: ^2.8.0 + version: 2.9.0 + tiny-invariant: + specifier: ^1.3.1 + version: 1.3.3 typesense: specifier: ^1.8.2 version: 1.8.2(@babel/runtime@7.26.0) @@ -2446,6 +2470,9 @@ importers: '@types/react': specifier: npm:types-react@19.0.0-rc.1 version: /types-react@19.0.0-rc.1 + '@types/react-dom': + specifier: npm:types-react-dom@19.0.0-rc.1 + version: /types-react-dom@19.0.0-rc.1 '@types/react-gravatar': specifier: ^2.6.13 version: 2.6.14 @@ -6482,8 +6509,8 @@ importers: specifier: ^4.21.24 version: 4.21.24(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0) '@uiw/react-codemirror': - specifier: ^4.23.0 - version: 4.23.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.26.0)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028) + specifier: ^4.23.6 + version: 4.23.6(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.26.0)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028) '@uiw/react-markdown-editor': specifier: ^6.1.2 version: 6.1.2(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.26.0)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) @@ -6997,7 +7024,7 @@ packages: peerDependencies: react: ^16.8.0 dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 bind-event-listener: 2.1.1 react: 19.0.0-rc-02c0e824-20241028 dev: false @@ -7110,7 +7137,7 @@ packages: react: ^16.8.0 dependencies: '@atlaskit/ds-lib': 2.2.4(react@19.0.0-rc-02c0e824-20241028) - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 '@emotion/react': 11.11.4(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) bind-event-listener: 2.1.1 react: 19.0.0-rc-02c0e824-20241028 @@ -7122,7 +7149,7 @@ packages: /@atlaskit/platform-feature-flags@0.2.5: resolution: {integrity: sha512-0fD2aDxn2mE59D4acUhVib+YF2HDYuuPH50aYwpQdcV/CsVkAaJsMKy8WhWSulcRFeMYp72kfIfdy0qGdRB7Uw==} dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 dev: false /@atlaskit/portal@4.4.0(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028): @@ -8239,7 +8266,7 @@ packages: resolution: {integrity: sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.26.0 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 @@ -8300,7 +8327,7 @@ packages: resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.26.0 dev: false /@babel/helper-function-name@7.23.0: @@ -8308,27 +8335,27 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.24.0 - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 /@babel/helper-function-name@7.24.7: resolution: {integrity: sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==} engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.24.7 - '@babel/types': 7.24.9 + '@babel/types': 7.26.0 dev: false /@babel/helper-hoist-variables@7.22.5: resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 /@babel/helper-hoist-variables@7.24.7: resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.26.0 dev: false /@babel/helper-module-imports@7.22.15: @@ -8406,7 +8433,7 @@ packages: resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 /@babel/helper-simple-access@7.24.7: resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} @@ -8422,13 +8449,13 @@ packages: resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 /@babel/helper-split-export-declaration@7.24.7: resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.26.0 dev: false /@babel/helper-string-parser@7.23.4: @@ -8475,8 +8502,8 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.24.0 - '@babel/traverse': 7.24.0 - '@babel/types': 7.24.0 + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 transitivePeerDependencies: - supports-color @@ -8533,7 +8560,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.26.0 dev: false /@babel/parser@7.26.2: @@ -8744,7 +8771,7 @@ packages: dependencies: '@babel/code-frame': 7.23.5 '@babel/parser': 7.24.0 - '@babel/types': 7.24.0 + '@babel/types': 7.26.0 /@babel/template@7.24.7: resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} @@ -8752,7 +8779,7 @@ packages: dependencies: '@babel/code-frame': 7.26.2 '@babel/parser': 7.24.8 - '@babel/types': 7.24.9 + '@babel/types': 7.26.0 dev: false /@babel/template@7.25.9: @@ -13299,7 +13326,7 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@19.0.0-rc-02c0e824-20241028) '@radix-ui/react-context': 1.0.0(react@19.0.0-rc-02c0e824-20241028) @@ -20367,7 +20394,7 @@ packages: '@ucast/core': 1.10.2 dev: false - /@uiw/codemirror-extensions-basic-setup@4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0): + /@uiw/codemirror-extensions-basic-setup@4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.28.5): resolution: {integrity: sha512-+k5nkRpUWGaHr1JWT8jcKsVewlXw5qBgSopm9LW8fZ6KnSNZBycz8kHxh0+WSvckmXEESGptkIsb7dlkmJT/hQ==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' @@ -20378,17 +20405,17 @@ packages: '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' dependencies: - '@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.28.5)(@lezer/common@1.2.3) '@codemirror/commands': 6.6.0 '@codemirror/language': 6.10.3 '@codemirror/lint': 6.8.2 '@codemirror/search': 6.5.6 '@codemirror/state': 6.4.1 - '@codemirror/view': 6.26.0 + '@codemirror/view': 6.28.5 dev: false - /@uiw/codemirror-extensions-basic-setup@4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.28.5): - resolution: {integrity: sha512-+k5nkRpUWGaHr1JWT8jcKsVewlXw5qBgSopm9LW8fZ6KnSNZBycz8kHxh0+WSvckmXEESGptkIsb7dlkmJT/hQ==} + /@uiw/codemirror-extensions-basic-setup@4.23.6(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0): + resolution: {integrity: sha512-bvtq8IOvdkLJMhoJBRGPEzU51fMpPDwEhcAHp9xCR05MtbIokQgsnLXrmD1aZm6e7s/3q47H+qdSfAAkR5MkLA==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' '@codemirror/commands': '>=6.0.0' @@ -20398,17 +20425,17 @@ packages: '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' dependencies: - '@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.28.5)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0)(@lezer/common@1.2.3) '@codemirror/commands': 6.6.0 '@codemirror/language': 6.10.3 '@codemirror/lint': 6.8.2 '@codemirror/search': 6.5.6 '@codemirror/state': 6.4.1 - '@codemirror/view': 6.28.5 + '@codemirror/view': 6.26.0 dev: false - /@uiw/codemirror-extensions-basic-setup@4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1): - resolution: {integrity: sha512-+k5nkRpUWGaHr1JWT8jcKsVewlXw5qBgSopm9LW8fZ6KnSNZBycz8kHxh0+WSvckmXEESGptkIsb7dlkmJT/hQ==} + /@uiw/codemirror-extensions-basic-setup@4.23.6(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1): + resolution: {integrity: sha512-bvtq8IOvdkLJMhoJBRGPEzU51fMpPDwEhcAHp9xCR05MtbIokQgsnLXrmD1aZm6e7s/3q47H+qdSfAAkR5MkLA==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' '@codemirror/commands': '>=6.0.0' @@ -20491,7 +20518,7 @@ packages: resolution: {integrity: sha512-O2GUHV90Iw2VrSLVLK0OmNIMdZ5fgEg4NhvtwINsX+eZ/Wf6DWD0TdsK9xwV7dNRnK/UI2mQtl0a2/kRgm1m1A==} dev: false - /@uiw/react-codemirror@4.23.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.26.0)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028): + /@uiw/react-codemirror@4.23.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.28.5)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028): resolution: {integrity: sha512-MnqTXfgeLA3fsUUQjqjJgemEuNyoGALgsExVm0NQAllAAi1wfj+IoKFeK+h3XXMlTFRCFYOUh4AHDv0YXJLsOg==} peerDependencies: '@babel/runtime': '>=7.11.0' @@ -20506,8 +20533,8 @@ packages: '@codemirror/commands': 6.6.0 '@codemirror/state': 6.4.1 '@codemirror/theme-one-dark': 6.1.2 - '@codemirror/view': 6.26.0 - '@uiw/codemirror-extensions-basic-setup': 4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0) + '@codemirror/view': 6.28.5 + '@uiw/codemirror-extensions-basic-setup': 4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.28.5) codemirror: 6.0.1(@lezer/common@1.2.3) react: 19.0.0-rc-02c0e824-20241028 react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) @@ -20518,8 +20545,8 @@ packages: - '@codemirror/search' dev: false - /@uiw/react-codemirror@4.23.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.28.5)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028): - resolution: {integrity: sha512-MnqTXfgeLA3fsUUQjqjJgemEuNyoGALgsExVm0NQAllAAi1wfj+IoKFeK+h3XXMlTFRCFYOUh4AHDv0YXJLsOg==} + /@uiw/react-codemirror@4.23.6(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.26.0)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028): + resolution: {integrity: sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==} peerDependencies: '@babel/runtime': '>=7.11.0' '@codemirror/state': '>=6.0.0' @@ -20533,8 +20560,8 @@ packages: '@codemirror/commands': 6.6.0 '@codemirror/state': 6.4.1 '@codemirror/theme-one-dark': 6.1.2 - '@codemirror/view': 6.28.5 - '@uiw/codemirror-extensions-basic-setup': 4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.28.5) + '@codemirror/view': 6.26.0 + '@uiw/codemirror-extensions-basic-setup': 4.23.6(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0) codemirror: 6.0.1(@lezer/common@1.2.3) react: 19.0.0-rc-02c0e824-20241028 react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) @@ -20545,8 +20572,8 @@ packages: - '@codemirror/search' dev: false - /@uiw/react-codemirror@4.23.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.1)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028): - resolution: {integrity: sha512-MnqTXfgeLA3fsUUQjqjJgemEuNyoGALgsExVm0NQAllAAi1wfj+IoKFeK+h3XXMlTFRCFYOUh4AHDv0YXJLsOg==} + /@uiw/react-codemirror@4.23.6(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.1)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028): + resolution: {integrity: sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==} peerDependencies: '@babel/runtime': '>=7.11.0' '@codemirror/state': '>=6.0.0' @@ -20561,7 +20588,7 @@ packages: '@codemirror/state': 6.4.1 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.34.1 - '@uiw/codemirror-extensions-basic-setup': 4.23.0(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1) + '@uiw/codemirror-extensions-basic-setup': 4.23.6(@codemirror/autocomplete@6.18.2)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1) codemirror: 6.0.1(@lezer/common@1.2.3) react: 19.0.0-rc-02c0e824-20241028 react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) @@ -20584,7 +20611,7 @@ packages: '@codemirror/language-data': 6.4.1(@codemirror/view@6.26.0) '@uiw/codemirror-extensions-events': 4.23.0(@codemirror/view@6.26.0) '@uiw/codemirror-themes': 4.21.24(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0) - '@uiw/react-codemirror': 4.23.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.26.0)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028) + '@uiw/react-codemirror': 4.23.6(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.26.0)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028) '@uiw/react-markdown-preview': 5.1.2(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) react: 19.0.0-rc-02c0e824-20241028 react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) @@ -20642,7 +20669,7 @@ packages: '@codemirror/language-data': 6.4.1(@codemirror/view@6.34.1) '@uiw/codemirror-extensions-events': 4.23.0(@codemirror/view@6.34.1) '@uiw/codemirror-themes': 4.21.24(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1) - '@uiw/react-codemirror': 4.23.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.1)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028) + '@uiw/react-codemirror': 4.23.6(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2)(@codemirror/language@6.10.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.34.1)(codemirror@6.0.1)(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028) '@uiw/react-markdown-preview': 5.1.2(react-dom@19.0.0-rc-02c0e824-20241028)(react@19.0.0-rc-02c0e824-20241028)(types-react@19.0.0-rc.1) react: 19.0.0-rc-02c0e824-20241028 react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) @@ -20665,7 +20692,7 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 '@uiw/copy-to-clipboard': 1.0.17 react: 19.0.0-rc-02c0e824-20241028 react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) @@ -30342,7 +30369,7 @@ packages: resolution: {integrity: sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q==} hasBin: true dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 chokidar: 3.6.0 glob: 10.3.10 html-minifier: 4.0.0 @@ -30541,7 +30568,7 @@ packages: /mjml-preset-core@4.15.3(encoding@0.1.13): resolution: {integrity: sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw==} dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 mjml-accordion: 4.15.3(encoding@0.1.13) mjml-body: 4.15.3(encoding@0.1.13) mjml-button: 4.15.3(encoding@0.1.13) @@ -30634,7 +30661,7 @@ packages: /mjml-validator@4.15.3: resolution: {integrity: sha512-Xb72KdqRwjv/qM2rJpV22syyP2N3cRQ9VVDrN6u2FSzLq02buFNxmSPJ7CKhat3PrUNdVHU75KZwOf/tz4UEhA==} dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.26.0 dev: false /mjml-wrapper@4.15.3(encoding@0.1.13):