From 0960e39811d473707cd9ce1e20a43a4ec450c66b Mon Sep 17 00:00:00 2001 From: Ezhil Shanmugham Date: Sat, 28 Sep 2024 16:51:07 +0530 Subject: [PATCH] feat: connect questions to video --- pnpm-lock.yaml | 58 ++++++++++++++++- .../migration.sql | 8 +++ prisma/schema.prisma | 4 ++ src/actions/question/index.ts | 3 +- src/actions/question/schema.ts | 2 + src/actions/question/types.ts | 15 ++++- src/actions/types.ts | 1 + .../question/[slug]/@question/page.tsx | 6 ++ src/app/(main)/(pages)/question/page.tsx | 32 +++++++--- src/components/NewPostDialog.tsx | 62 +++++++++++++++++++ .../admin/ContentRendererClient.tsx | 16 +++-- src/components/posts/PostCard.tsx | 59 ++++++++++-------- src/components/posts/form/form-input.tsx | 3 + src/components/search/SearchBar.tsx | 24 +++++-- src/components/search/VideoSearchCard.tsx | 6 +- 15 files changed, 247 insertions(+), 52 deletions(-) create mode 100644 prisma/migrations/20240928105907_add_optional_videoid_to_question/migration.sql diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24f477ce4..857a0e4ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: specifier: ^9.4.0 version: 9.7.0(react@18.3.1) '@prisma/client': - specifier: ^5.6.0 + specifier: ^5.18.0 version: 5.18.0(prisma@5.18.0) '@radix-ui/react-accordion': specifier: ^1.1.2 @@ -47,6 +47,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.2.0 + version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -54,7 +57,7 @@ importers: specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.0.2 + specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.3)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.0 @@ -62,6 +65,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.0.7 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tabler/icons-react': + specifier: ^3.14.0 + version: 3.19.0(react@18.3.1) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -289,7 +295,7 @@ importers: specifier: ^0.6.1 version: 0.6.6(prettier@3.3.3) prisma: - specifier: ^5.17.0 + specifier: ^5.18.0 version: 5.18.0 tailwindcss: specifier: ^3.3.0 @@ -1858,6 +1864,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.2.0': + resolution: {integrity: sha512-yv+oiLaicYMBpqgfpSPw6q+RyXlLdIpQWDHZbUKURxe+nEh53hFXPPlfhfQQtYkS5MMK/5IWIa76SksleQZSzw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.0': resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} peerDependencies: @@ -2331,6 +2350,14 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@tabler/icons-react@3.19.0': + resolution: {integrity: sha512-AqEWGI0tQWgqo6ZjMO5yJ9sYT8oXLuAM/up0hN9iENS6IdtNZryKrkNSiMgpwweNTpl8wFFG/dAZ959S91A/uQ==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.19.0': + resolution: {integrity: sha512-A4WEWqpdbTfnpFEtwXqwAe9qf9sp1yRPvzppqAuwcoF0q5YInqB+JkJtSFToCyBpPVeLxJUxxkapLvt2qQgnag==} + '@testing-library/dom@10.1.0': resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} engines: {node: '>=18'} @@ -8932,6 +8959,24 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-radio-group@1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -9580,6 +9625,13 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tabler/icons-react@3.19.0(react@18.3.1)': + dependencies: + '@tabler/icons': 3.19.0 + react: 18.3.1 + + '@tabler/icons@3.19.0': {} + '@testing-library/dom@10.1.0': dependencies: '@babel/code-frame': 7.24.7 diff --git a/prisma/migrations/20240928105907_add_optional_videoid_to_question/migration.sql b/prisma/migrations/20240928105907_add_optional_videoid_to_question/migration.sql new file mode 100644 index 000000000..10519f0a6 --- /dev/null +++ b/prisma/migrations/20240928105907_add_optional_videoid_to_question/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "Question" ADD COLUMN "videoId" INTEGER; + +-- CreateIndex +CREATE INDEX "Question_videoId_idx" ON "Question"("videoId"); + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Content"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f21792668..9f7dca591 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,7 @@ model Content { comments Comment[] commentsCount Int @default(0) bookmark Bookmark[] + questions Question[] } model CourseContent { @@ -266,9 +267,12 @@ model Question { answers Answer[] votes Vote[] tags String[] + video Content? @relation(fields: [videoId], references: [id]) + videoId Int? updatedAt DateTime @updatedAt @@index([authorId]) + @@index([videoId]) } model Answer { diff --git a/src/actions/question/index.ts b/src/actions/question/index.ts index 0d78f52f3..df673da3d 100644 --- a/src/actions/question/index.ts +++ b/src/actions/question/index.ts @@ -32,7 +32,7 @@ const createQuestionHandler = async ( }; } - const { title, content, tags } = data; + const { title, content, tags, videoId } = data; // Create initial slug let slug = generateHandle(title); @@ -61,6 +61,7 @@ const createQuestionHandler = async ( title, content, tags, + videoId, authorId: session.user.id, slug, // Include the slug }, diff --git a/src/actions/question/schema.ts b/src/actions/question/schema.ts index 8e42bbd50..e517b0578 100644 --- a/src/actions/question/schema.ts +++ b/src/actions/question/schema.ts @@ -4,12 +4,14 @@ export const QuestionInsertSchema = z.object({ title: z.string().min(5, 'Question title too short'), content: z.string().min(0, 'Question content too short'), tags: z.array(z.string()).optional(), + videoId: z.number().optional(), }); export const QuestionUpdateSchema = z.object({ title: z.string().min(5, 'Question title too short'), content: z.string().min(0, 'Question content too short'), tags: z.array(z.string()).optional(), + videoId: z.number().optional(), questionId: z.number(), }); export const QuestionDeleteSchema = z.object({ diff --git a/src/actions/question/types.ts b/src/actions/question/types.ts index abaf24c8e..18acdf4e9 100644 --- a/src/actions/question/types.ts +++ b/src/actions/question/types.ts @@ -45,6 +45,12 @@ export interface QuestionQuery { name: boolean; }; }; + video: { + select: { + id: boolean; + title: boolean; + }; + }; votes: { where: { userId: string | undefined; @@ -57,7 +63,7 @@ export interface QuestionQuery { }; where?: { authorId?: string; - + videoId?: number; title?: { contains: string; }; @@ -76,13 +82,20 @@ export interface Author { email?: string | null; } +export interface Video { + id: number; + title: string; +} + export interface ExtendedQuestion extends Question { author: Author; + video: Video; votes: any[]; } export interface ExtendedAnswer extends Answer { author: Author; + video: Video; votes: any[]; responses: ExtendedAnswer[]; } diff --git a/src/actions/types.ts b/src/actions/types.ts index ac252c469..6d8ca0868 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -5,6 +5,7 @@ export interface QueryParams { page?: number; tabtype?: TabType; search?: string; + videoId?: number; date?: string; type?: CommentType; parentId?: number; diff --git a/src/app/(main)/(pages)/question/[slug]/@question/page.tsx b/src/app/(main)/(pages)/question/[slug]/@question/page.tsx index c75eae92e..5c4560ec1 100644 --- a/src/app/(main)/(pages)/question/[slug]/@question/page.tsx +++ b/src/app/(main)/(pages)/question/[slug]/@question/page.tsx @@ -35,6 +35,12 @@ const SingleQuestionPage = async ({ name: true, }, }, + video: { + select: { + id: true, + title: true, + }, + }, votes: { where: { userId: sessionId, diff --git a/src/app/(main)/(pages)/question/page.tsx b/src/app/(main)/(pages)/question/page.tsx index e050f3a00..f477a11a8 100644 --- a/src/app/(main)/(pages)/question/page.tsx +++ b/src/app/(main)/(pages)/question/page.tsx @@ -57,20 +57,32 @@ const getQuestionsWithQuery = async ( select: { userId: true, voteType: true }, }, author: { select: { id: true, name: true } }, + video: { + select: { + id: true, + title: true, + }, + }, }, }; - const searchQuery = searchParams.search - ? { - where: { - ...additionalQuery.where, - title: { - contains: searchParams.search, - mode: 'insensitive', + const searchQuery = + searchParams.search || searchParams.videoId + ? { + where: { + ...additionalQuery.where, + ...(searchParams.search && { + title: { + contains: searchParams.search, + mode: 'insensitive', + }, + }), + ...(searchParams.videoId && { + videoId: parseInt(searchParams.videoId.toString(), 10), + }), }, - }, - } - : {}; + } + : {}; const dateFilter = searchParams.date; if (dateFilter) { diff --git a/src/components/NewPostDialog.tsx b/src/components/NewPostDialog.tsx index 020d9a6f5..cb071ec19 100644 --- a/src/components/NewPostDialog.tsx +++ b/src/components/NewPostDialog.tsx @@ -18,6 +18,7 @@ import { getUpdatedUrl, searchParamsToObject } from '@/lib/utils'; import { FormPostInput } from './posts/form/form-input'; import { FormPostErrors } from './posts/form/form-errors'; import { X } from 'lucide-react'; +import SearchBar from './search/SearchBar'; export const NewPostDialog = () => { const { theme } = useTheme(); @@ -29,6 +30,8 @@ export const NewPostDialog = () => { const tagInputRef = useRef(null); const [value, setValue] = useState('**Hello world!!!**'); const [tags, setTags] = useState([]); + const [videoId, setVideoId] = useState(''); + const [videoTitle, setVideoTitle] = useState(''); const containerRef = useRef(null); const { ref, onOpen, onClose } = useModal(); const handleMarkdownChange = (newValue?: string) => { @@ -55,6 +58,8 @@ export const NewPostDialog = () => { toast.success(`Question "${data.title}" created`); formRef?.current?.reset(); setValue(''); + setVideoId(''); + setVideoTitle(''); router.push(`/question/${data.slug}`); setTags([]); handleOnCloseClick(); @@ -76,10 +81,12 @@ export const NewPostDialog = () => { event.preventDefault(); const formData = new FormData(event.currentTarget); const title = formData.get('title'); + const videoId = formData.get('videoId'); execute({ title: title?.toString() || '', content: value, tags, + videoId: videoId ? parseInt(videoId.toString(), 10) : undefined, }); }; @@ -106,6 +113,19 @@ export const NewPostDialog = () => { setTags(tags.filter((t) => t !== tag)); }; + const handleSearch = ( + videoUrl?: string, + videoId?: number, + videoTitle?: string, + ) => { + if (videoUrl && videoId && videoTitle) { + setVideoId(videoId.toString()); + setVideoTitle(videoTitle); + } else { + toast.error('Something went wrong while selecting the video'); + } + }; + return (
@@ -180,6 +200,48 @@ export const NewPostDialog = () => { />
+
+

+ Link to a video +

+ + {videoId ? ( +
+ + +
+ ) : ( + + )} + + {videoId && ( + + )} +
{content.title} - {metadata.slides ? ( - - +
+ + - ) : null} + + + + {metadata.slides ? ( + + + + ) : null} +
{!showChapters && metadata.segments?.length > 0 && ( diff --git a/src/components/posts/PostCard.tsx b/src/components/posts/PostCard.tsx index 3b35f0ec0..07c94b193 100644 --- a/src/components/posts/PostCard.tsx +++ b/src/components/posts/PostCard.tsx @@ -179,34 +179,41 @@ const PostCard: React.FC = ({ )} -
- - {reply && ( - - )} - {!isAnswer && ( -

- • {formatNumber(post.totalanswers)}{' '} - {post.totalanswers === 1 ? 'reply' : 'replies'} -

+
+
+ + {reply && ( + + )} + {!isAnswer && ( +

+ • {formatNumber(post.totalanswers)}{' '} + {post.totalanswers === 1 ? 'reply' : 'replies'} +

+ )} +
+ {post.video && ( +
+ + +
)}
- {enableReply && (
diff --git a/src/components/posts/form/form-input.tsx b/src/components/posts/form/form-input.tsx index 577bd81a9..e47a4e925 100644 --- a/src/components/posts/form/form-input.tsx +++ b/src/components/posts/form/form-input.tsx @@ -12,6 +12,7 @@ import { FormPostErrors } from './form-errors'; interface FormInputProps { id: string; label?: string; + value?: string; type?: string; placeholder?: string; required?: boolean; @@ -28,6 +29,7 @@ export const FormPostInput = forwardRef( { id, label, + value, type, placeholder, required, @@ -61,6 +63,7 @@ export const FormPostInput = forwardRef( name={id} id={id} onKeyUp={onKeyUp} + value={value} placeholder={placeholder} type={type} disabled={pending || disabled} diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index 5cf5ad881..e9856f60f 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -10,7 +10,17 @@ import VideoSearchInfo from './VideoSearchInfo'; import { toast } from 'sonner'; import VideoSearchLoading from './VideoSearchLoading'; -const SearchBar = ({ onCardClick }: { onCardClick?: () => void }) => { +const SearchBar = ({ + onCardClick, + shouldRedirect = true, +}: { + onCardClick?: ( + videoUrl?: string, + videoId?: number, + videoTitle?: string, + ) => void; + shouldRedirect?: boolean; +}) => { const [searchTerm, setSearchTerm] = useState(''); const [searchedVideos, setSearchedVideos] = useState< TSearchedVideos[] | null @@ -59,12 +69,18 @@ const SearchBar = ({ onCardClick }: { onCardClick?: () => void }) => { setSearchTerm(''); }; - const handleCardClick = (videoUrl: string) => { + const handleCardClick = ( + videoUrl: string, + videoId: number, + videoTitle: string, + ) => { if (onCardClick !== undefined) { - onCardClick(); + onCardClick(videoUrl, videoId, videoTitle); } clearSearchTerm(); - router.push(videoUrl); + if (shouldRedirect) { + router.push(videoUrl); + } }; const handleClearInput = () => { diff --git a/src/components/search/VideoSearchCard.tsx b/src/components/search/VideoSearchCard.tsx index 9f4e16aa7..866d5ef71 100644 --- a/src/components/search/VideoSearchCard.tsx +++ b/src/components/search/VideoSearchCard.tsx @@ -7,9 +7,9 @@ const VideoSearchCard = ({ onCardClick, }: { video: TSearchedVideos; - onCardClick: (videoUrl: string) => void; + onCardClick: (videoUrl: string, videoId: number, videoTitle: string) => void; }) => { - const { id: videoId, parentId, parent } = video; + const { id: videoId, parentId, parent, title: videoTitle } = video; if (parentId && parent) { const courseId = parent.courses[0].courseId; @@ -17,7 +17,7 @@ const VideoSearchCard = ({ return (
onCardClick(videoUrl)} + onClick={() => onCardClick(videoUrl, videoId, videoTitle)} >