mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Q & A + journal (#174)
* tasks * task input * community route * added thread and list for community QA * answers thread * journal sidebar section * journal calendar * fix: stuff * fix: stuff * fix: stuff * chore: disable comunitty toggle * fix: typo import header --------- Co-authored-by: marshennikovaolga <marshennikova@gmail.com> Co-authored-by: Aslam H <iupin5212@gmail.com>
This commit is contained in:
50
web/components/custom/GuideCommunityToggle.tsx
Normal file
50
web/components/custom/GuideCommunityToggle.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface GuideCommunityToggleProps {
|
||||
topicName: string
|
||||
}
|
||||
|
||||
export const GuideCommunityToggle: React.FC<GuideCommunityToggleProps> = ({ topicName }) => {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [view, setView] = useState<"guide" | "community">("guide")
|
||||
|
||||
useEffect(() => {
|
||||
setView(pathname.includes("/community/") ? "community" : "guide")
|
||||
}, [pathname])
|
||||
|
||||
const handleToggle = (newView: "guide" | "community") => {
|
||||
setView(newView)
|
||||
router.push(newView === "community" ? `/community/${topicName}` : `/${topicName}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-accent/70 relative flex h-8 w-48 items-center rounded-md">
|
||||
<div
|
||||
className="absolute h-8 w-[calc(50%-4px)] rounded-md transition-all duration-300 ease-in-out"
|
||||
style={{ left: view === "guide" ? "2px" : "calc(50% + 2px)" }}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "guide" ? "text-primary bg-accent" : "text-primary/50"
|
||||
)}
|
||||
onClick={() => handleToggle("guide")}
|
||||
>
|
||||
Guide
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"relative z-10 h-full flex-1 rounded-md text-sm font-medium transition-colors",
|
||||
view === "community" ? "text-primary bg-accent" : "text-primary/50"
|
||||
)}
|
||||
onClick={() => handleToggle("community")}
|
||||
>
|
||||
Community
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
web/components/custom/QuestionList.tsx
Normal file
65
web/components/custom/QuestionList.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Input } from "../ui/input"
|
||||
import { LaIcon } from "./la-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface QuestionListProps {
|
||||
topicName: string
|
||||
onSelectQuestion: (question: Question) => void
|
||||
selectedQuestionId?: string
|
||||
}
|
||||
|
||||
export function QuestionList({ topicName, onSelectQuestion, selectedQuestionId }: QuestionListProps) {
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const mockQuestions: Question[] = Array(10)
|
||||
.fill(null)
|
||||
.map((_, index) => ({
|
||||
id: (index + 1).toString(),
|
||||
title: "What can I do offline in Figma?",
|
||||
author: "Ana",
|
||||
timestamp: "13:35"
|
||||
}))
|
||||
setQuestions(mockQuestions)
|
||||
}, [topicName])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="scrollbar-hide flex-grow overflow-y-auto">
|
||||
{questions.map(question => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col gap-2 rounded p-4",
|
||||
selectedQuestionId === question.id && "bg-red-500"
|
||||
)}
|
||||
onClick={() => onSelectQuestion(question)}
|
||||
>
|
||||
<div className="flex flex-row justify-between opacity-50">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<div className="h-6 w-6 rounded-full bg-slate-500" />
|
||||
<p className="text-sm font-medium">{question.author}</p>
|
||||
</div>
|
||||
<p>{question.timestamp}</p>
|
||||
</div>
|
||||
<h3 className="font-medium">{question.title}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Input className="bg-input py-5 pr-10 focus:outline-none focus:ring-0" placeholder="Ask new question..." />
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-60 hover:opacity-80">
|
||||
<LaIcon name="Send" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
web/components/custom/QuestionThread.tsx
Normal file
167
web/components/custom/QuestionThread.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { LaIcon } from "./la-icon"
|
||||
interface Answer {
|
||||
id: string
|
||||
author: string
|
||||
content: string
|
||||
timestamp: string
|
||||
replies?: Answer[]
|
||||
}
|
||||
|
||||
interface QuestionThreadProps {
|
||||
question: {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QuestionThread({ question, onClose }: QuestionThreadProps) {
|
||||
const [answers, setAnswers] = useState<Answer[]>([])
|
||||
const [newAnswer, setNewAnswer] = useState("")
|
||||
const [replyTo, setReplyTo] = useState<Answer | null>(null)
|
||||
const [replyToAuthor, setReplyToAuthor] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const mockAnswers: Answer[] = [
|
||||
{
|
||||
id: "1",
|
||||
author: "Noone",
|
||||
content:
|
||||
"Just press Command + Just press Command + Just press Command + Just press Command + Just press Command +",
|
||||
timestamp: "14:40"
|
||||
}
|
||||
]
|
||||
setAnswers(mockAnswers)
|
||||
}, [question.id])
|
||||
|
||||
const sendReply = (answer: Answer) => {
|
||||
setReplyTo(answer)
|
||||
setReplyToAuthor(answer.author)
|
||||
setNewAnswer(`@${answer.author} `)
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
const length = inputRef.current.value.length
|
||||
inputRef.current.setSelectionRange(length, length)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const changeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
setNewAnswer(newValue)
|
||||
|
||||
if (replyToAuthor && !newValue.startsWith(`@${replyToAuthor}`)) {
|
||||
setReplyTo(null)
|
||||
setReplyToAuthor(null)
|
||||
}
|
||||
}
|
||||
|
||||
const sendAnswer = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newAnswer.trim()) {
|
||||
const newReply: Answer = {
|
||||
id: Date.now().toString(),
|
||||
author: "Me",
|
||||
content: newAnswer,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
if (replyTo) {
|
||||
setAnswers(prevAnswers =>
|
||||
prevAnswers.map(answer =>
|
||||
answer.id === replyTo.id ? { ...answer, replies: [...(answer.replies || []), newReply] } : answer
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setAnswers(prevAnswers => [...prevAnswers, newReply])
|
||||
}
|
||||
setNewAnswer("")
|
||||
setReplyTo(null)
|
||||
setReplyToAuthor(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAnswers = (answers: Answer[], isReply = false) => (
|
||||
<div>
|
||||
{answers.map(answer => (
|
||||
<div key={answer.id} className={`flex-grow overflow-y-auto p-4 ${isReply ? "ml-3 border-l" : ""}`}>
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-accent mr-2 h-6 w-6 rounded-full"></div>
|
||||
<span className="text-sm">{answer.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
<LaIcon name="Ellipsis" className="mr-2 size-4 shrink-0 opacity-30 hover:opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<div className="w-[15px]">
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => sendReply(answer)}>
|
||||
<div className="mx-auto flex flex-row items-center gap-3">
|
||||
<LaIcon name="Reply" className="size-4 shrink-0" />
|
||||
Reply
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
<span className="text-sm opacity-30">{answer.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<p className="">{answer.content}</p>
|
||||
<LaIcon name="ThumbsUp" className="ml-2 size-4 shrink-0 opacity-70" />
|
||||
</div>
|
||||
{answer.replies && renderAnswers(answer.replies, true)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-accent bg-background fixed bottom-0 right-0 top-0 z-50 flex h-full w-[40%] flex-col border-l">
|
||||
<div className="border-accent flex w-full justify-between border-b p-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-2 flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-accent h-8 w-8 rounded-full"></div>
|
||||
<h2 className="opacity-70">{question.author}</h2>
|
||||
</div>
|
||||
<button className="bg-accent rounded-full p-1.5 opacity-50 hover:opacity-80" onClick={onClose}>
|
||||
<LaIcon name="X" className="text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-md mb-1 font-semibold">{question.title}</p>
|
||||
<p className="text-sm opacity-70">{question.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto">{renderAnswers(answers)}</div>
|
||||
<div className="border-accent border-t p-4">
|
||||
<form className="relative" onSubmit={sendAnswer}>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newAnswer}
|
||||
onChange={changeInput}
|
||||
placeholder="Answer the question..."
|
||||
className="bg-input w-full rounded p-2 text-opacity-70 placeholder:text-opacity-50 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<button className="absolute right-2 top-1/2 -translate-y-1/2 transform opacity-50 hover:opacity-90">
|
||||
<LaIcon name="Send" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
web/components/custom/sidebar/partial/journal-section.tsx
Normal file
113
web/components/custom/sidebar/partial/journal-section.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "../../la-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth, useUser } from "@clerk/nextjs"
|
||||
import { getFeatureFlag } from "@/app/actions"
|
||||
|
||||
export const JournalSection: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const journalEntries = me?.root?.journalEntries
|
||||
const pathname = usePathname()
|
||||
const isActive = pathname === "/journal"
|
||||
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isFeatureActive, setIsFeatureActive] = useState(false)
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFeatureFlag() {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const [data, err] = await getFeatureFlag({ name: "JOURNAL" })
|
||||
|
||||
if (err) {
|
||||
console.error(err)
|
||||
setIsFetching(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (user?.emailAddresses.some(email => data.flag?.emails.includes(email.emailAddress))) {
|
||||
setIsFeatureActive(true)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFeatureFlag()
|
||||
}, [isLoaded, isSignedIn, user])
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return <div className="py-2 text-center text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
if (!me) return null
|
||||
|
||||
if (!isFeatureActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/journal flex flex-col gap-px py-2">
|
||||
<JournalSectionHeader entriesCount={journalEntries?.length || 0} isActive={isActive} />
|
||||
{journalEntries && journalEntries.length > 0 && <JournalEntryList entries={journalEntries} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalHeaderProps {
|
||||
entriesCount: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const JournalSectionHeader: React.FC<JournalHeaderProps> = ({ entriesCount, isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[30px] items-center gap-px rounded-md",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/journal"
|
||||
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<p className="text-xs">
|
||||
Journal
|
||||
{entriesCount > 0 && <span className="text-muted-foreground ml-1">({entriesCount})</span>}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface JournalEntryListProps {
|
||||
entries: any[]
|
||||
}
|
||||
|
||||
const JournalEntryList: React.FC<JournalEntryListProps> = ({ entries }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{entries.map((entry, index) => (
|
||||
<JournalEntryItem key={index} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalEntryItemProps {
|
||||
entry: any
|
||||
}
|
||||
|
||||
const JournalEntryItem: React.FC<JournalEntryItemProps> = ({ entry }) => (
|
||||
<Link href={`/journal/${entry.id}`} className="group/journal-entry relative flex min-w-0 flex-1">
|
||||
<div className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium">
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="opacity-60" />
|
||||
<p className={cn("truncate opacity-95 group-hover/journal-entry:opacity-100")}>{entry.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -16,6 +16,7 @@ import { ProfileSection } from "./partial/profile-section"
|
||||
import { TaskSection } from "./partial/task-section"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "../la-icon"
|
||||
import { JournalSection } from "./partial/journal-section"
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
@@ -123,6 +124,7 @@ const SidebarContent: React.FC = React.memo(() => {
|
||||
<div className="h-2 shrink-0" />
|
||||
{me._type === "Account" && <LinkSection pathname={pathname} />}
|
||||
{me._type === "Account" && <TopicSection pathname={pathname} />}
|
||||
{me._type === "Account" && <JournalSection />}
|
||||
{me._type === "Account" && <TaskSection pathname={pathname} />}
|
||||
{me._type === "Account" && <PageSection pathname={pathname} />}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user