diff --git a/bun.lockb b/bun.lockb index dc8827a8..2f631d96 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 42acf914..58c90a90 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,35 @@ { - "name": "learn-anything", - "scripts": { - "dev": "bun web", - "web": "cd web && bun dev", - "web:build": "bun run --filter '*' build", - "ts": "bun run --watch scripts/run.ts", - "seed": "bun --watch scripts/seed.ts", - "tauri": "tauri", - "app": "tauri dev", - "app:build": "bun tauri build -b dmg -v" - }, - "workspaces": [ - "web" - ], - "dependencies": { - "@clerk/themes": "^2.1.30", - "@tauri-apps/cli": "^2.0.0-rc.16", - "@tauri-apps/plugin-fs": "^2.0.0-rc.2", - "jazz-nodejs": "0.8.0" - }, - "devDependencies": { - "bun-types": "^1.1.29" - }, - "prettier": { - "plugins": [ - "prettier-plugin-tailwindcss" - ], - "useTabs": true, - "semi": false, - "trailingComma": "none", - "printWidth": 120, - "arrowParens": "avoid" - }, - "license": "MIT" + "name": "learn-anything", + "scripts": { + "dev": "bun web", + "web": "cd web && bun dev", + "web:build": "bun run --filter '*' build", + "ts": "bun run --watch scripts/run.ts", + "seed": "bun --watch scripts/seed.ts", + "tauri": "tauri", + "app": "tauri dev", + "app:build": "bun tauri build -b dmg -v" + }, + "workspaces": [ + "web" + ], + "dependencies": { + "@tauri-apps/cli": "^2.0.0-rc.17", + "@tauri-apps/plugin-fs": "^2.0.0-rc.2", + "jazz-nodejs": "0.8.0" + }, + "devDependencies": { + "bun-types": "^1.1.29" + }, + "prettier": { + "plugins": [ + "prettier-plugin-tailwindcss" + ], + "useTabs": true, + "semi": false, + "trailingComma": "none", + "printWidth": 120, + "arrowParens": "avoid" + }, + "license": "MIT" } diff --git a/q&a b/q&a new file mode 100644 index 00000000..e69de29b diff --git a/web/app/(pages)/community/[topicName]/page.tsx b/web/app/(pages)/community/[topicName]/page.tsx new file mode 100644 index 00000000..62e69371 --- /dev/null +++ b/web/app/(pages)/community/[topicName]/page.tsx @@ -0,0 +1,5 @@ +import { CommunityTopicRoute } from "@/components/routes/community/CommunityTopicRoute" + +export default function CommunityTopicPage({ params }: { params: { topicName: string } }) { + return +} diff --git a/web/app/(pages)/journal/page.tsx b/web/app/(pages)/journal/page.tsx new file mode 100644 index 00000000..0c2cd038 --- /dev/null +++ b/web/app/(pages)/journal/page.tsx @@ -0,0 +1,15 @@ +import { JournalRoute } from "@/components/routes/journal/JournalRoute" +import { currentUser } from "@clerk/nextjs/server" +import { notFound } from "next/navigation" +import { get } from "ronin" + +export default async function JournalPage() { + const user = await currentUser() + const flag = await get.featureFlag.with.name("JOURNAL") + + if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { + notFound() + } + + return +} diff --git a/web/app/globals.css b/web/app/globals.css index 0b181a0d..24faef84 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -74,3 +74,12 @@ @import "./command-palette.css"; @import "./custom.css"; + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} diff --git a/web/components/custom/GuideCommunityToggle.tsx b/web/components/custom/GuideCommunityToggle.tsx new file mode 100644 index 00000000..70adc389 --- /dev/null +++ b/web/components/custom/GuideCommunityToggle.tsx @@ -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 = ({ 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 ( +
+
+ + +
+ ) +} diff --git a/web/components/custom/QuestionList.tsx b/web/components/custom/QuestionList.tsx new file mode 100644 index 00000000..979c8af3 --- /dev/null +++ b/web/components/custom/QuestionList.tsx @@ -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([]) + + 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 ( +
+
+ {questions.map(question => ( +
onSelectQuestion(question)} + > +
+
+
+

{question.author}

+
+

{question.timestamp}

+
+

{question.title}

+
+ ))} +
+
+ + +
+
+ ) +} diff --git a/web/components/custom/QuestionThread.tsx b/web/components/custom/QuestionThread.tsx new file mode 100644 index 00000000..69e13df0 --- /dev/null +++ b/web/components/custom/QuestionThread.tsx @@ -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([]) + const [newAnswer, setNewAnswer] = useState("") + const [replyTo, setReplyTo] = useState(null) + const [replyToAuthor, setReplyToAuthor] = useState(null) + const inputRef = useRef(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) => { + 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) => ( +
+ {answers.map(answer => ( +
+
+
+
+ {answer.author} +
+
+ + + + +
+ + sendReply(answer)}> +
+ + Reply +
+
+
+
+
+ {answer.timestamp} +
+
+
+

{answer.content}

+ +
+ {answer.replies && renderAnswers(answer.replies, true)} +
+ ))} +
+ ) + + return ( +
+
+
+
+
+
+

{question.author}

+
+ +
+

{question.title}

+

{question.timestamp}

+
+
+
{renderAnswers(answers)}
+
+
+
+ +
+ +
+
+
+ ) +} diff --git a/web/components/custom/sidebar/partial/journal-section.tsx b/web/components/custom/sidebar/partial/journal-section.tsx new file mode 100644 index 00000000..19879f84 --- /dev/null +++ b/web/components/custom/sidebar/partial/journal-section.tsx @@ -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
Loading...
+ } + + if (!me) return null + + if (!isFeatureActive) { + return null + } + + return ( +
+ + {journalEntries && journalEntries.length > 0 && } +
+ ) +} + +interface JournalHeaderProps { + entriesCount: number + isActive: boolean +} + +const JournalSectionHeader: React.FC = ({ entriesCount, isActive }) => ( +
+ +

+ Journal + {entriesCount > 0 && ({entriesCount})} +

+ +
+) + +interface JournalEntryListProps { + entries: any[] +} + +const JournalEntryList: React.FC = ({ entries }) => { + return ( +
+ {entries.map((entry, index) => ( + + ))} +
+ ) +} + +interface JournalEntryItemProps { + entry: any +} + +const JournalEntryItem: React.FC = ({ entry }) => ( + +
+
+ +

{entry.title}

+
+
+ +) diff --git a/web/components/custom/sidebar/sidebar.tsx b/web/components/custom/sidebar/sidebar.tsx index b1f70df1..dabc3e3a 100644 --- a/web/components/custom/sidebar/sidebar.tsx +++ b/web/components/custom/sidebar/sidebar.tsx @@ -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(() => {
{me._type === "Account" && } {me._type === "Account" && } + {me._type === "Account" && } {me._type === "Account" && } {me._type === "Account" && }
diff --git a/web/components/routes/community/CommunityTopicRoute.tsx b/web/components/routes/community/CommunityTopicRoute.tsx new file mode 100644 index 00000000..cc9fa28b --- /dev/null +++ b/web/components/routes/community/CommunityTopicRoute.tsx @@ -0,0 +1,74 @@ +"use client" + +import { useMemo, useState } from "react" +import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider" +import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" +import { GuideCommunityToggle } from "@/components/custom/GuideCommunityToggle" +import { QuestionList } from "@/components/custom/QuestionList" +import { QuestionThread } from "@/components/custom/QuestionThread" +import { Topic } from "@/lib/schema" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" + +interface CommunityTopicRouteProps { + topicName: string +} + +interface Question { + id: string + title: string + author: string + timestamp: string +} + +export function CommunityTopicRoute({ topicName }: CommunityTopicRouteProps) { + const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) + const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } }) + + const [selectedQuestion, setSelectedQuestion] = useState(null) + + if (!topic) { + return null + } + + return ( +
+ +
+ +
+

Topic

+ {topic.prettyName} +
+
+
+ + +
+
+ setSelectedQuestion(question)} + /> +
+ {selectedQuestion && ( +
+ setSelectedQuestion(null)} + /> +
+ )} +
+
+ ) +} diff --git a/web/components/routes/journal/JournalRoute.tsx b/web/components/routes/journal/JournalRoute.tsx new file mode 100644 index 00000000..4dcb644e --- /dev/null +++ b/web/components/routes/journal/JournalRoute.tsx @@ -0,0 +1,114 @@ +"use client" + +import { useState, useEffect } from "react" +import { JournalEntry, JournalEntryLists } from "@/lib/schema/journal" +import { useAccount } from "@/lib/providers/jazz-provider" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { calendarFormatDate } from "@/lib/utils" +import { Calendar } from "@/components/ui/calendar" + +export function JournalRoute() { + const [date, setDate] = useState(new Date()) + const { me } = useAccount({ root: { journalEntries: [] } }) + const [newNote, setNewNote] = useState(null) + + const notes = me?.root?.journalEntries || (me ? JournalEntryLists.create([], { owner: me }) : []) + + useEffect(() => { + console.log("me:", me) + }, [me]) + + const selectDate = (selectedDate: Date | undefined) => { + if (selectedDate) { + setDate(selectedDate) + } + } + + const createNewNote = () => { + if (me) { + const newEntry = JournalEntry.create( + { + title: "", + content: "", + date: date, + createdAt: new Date(), + updatedAt: new Date() + }, + { owner: me._owner } + ) + setNewNote(newEntry) + } + } + + const handleNewNoteChange = (field: keyof JournalEntry, value: string) => { + if (newNote) { + setNewNote(prevNote => { + if (prevNote) { + return JournalEntry.create({ ...prevNote, [field]: value }, { owner: me!._owner }) + } + return prevNote + }) + } + } + + const saveNewNote = () => { + if (newNote && me?.root?.journalEntries) { + me.root.journalEntries.push(newNote) + setNewNote(null) + } + } + + return ( +
+
+
+ {newNote ? ( +
+ handleNewNoteChange("title", e.target.value)} + className="mb-2 w-full text-xl font-semibold" + /> +