Spaces:
Running
Running
Commit
·
5081fcb
1
Parent(s):
e0d6b69
feat: optimize for-students and for-teachers page
Browse files- .eslintrc.json +1 -1
- .gitignore +2 -1
- .prettierignore +3 -0
- .prettierrc +1 -0
- app/(audiences)/components/chat-bot-card.tsx +58 -0
- components/custom/ChatBotGridSkeleton.tsx → app/(audiences)/components/chat-bot-grid-skeleton.tsx +2 -2
- app/(audiences)/components/chat-bot-grid.tsx +156 -0
- components/custom/SearchBar.tsx → app/(audiences)/components/search-bar.tsx +11 -6
- app/(audiences)/components/subject-faceted-filter.tsx +145 -0
- app/(audiences)/components/subject-filter-view-options.tsx +59 -0
- app/(audiences)/components/subject-filter.tsx +59 -0
- app/(audiences)/for-students/data/chatbots.ts +142 -0
- app/(audiences)/for-students/data/data.tsx +49 -0
- app/{for-students → (audiences)/for-students}/page.tsx +38 -12
- app/(audiences)/for-teachers/data/chatbots.ts +66 -0
- app/(audiences)/for-teachers/page.tsx +62 -0
- app/components/chatbot/chat-bot-card.tsx +2 -0
- app/components/chatbot/chat-bot-grid.tsx +152 -0
- components/custom/LandingPageChatBot.tsx → app/components/landing-page-chat-bot.tsx +83 -61
- app/components/theme-provider.tsx +38 -0
- app/components/top-nav.tsx +127 -0
- app/globals.css +32 -32
- app/layout.tsx +12 -9
- app/page.tsx +174 -42
- app/types/chatbot.ts +10 -0
- components.json +1 -1
- components/custom/ChatBotCard.tsx +0 -43
- components/custom/ChatBotGrid.tsx +0 -60
- components/custom/SubjectFilter.tsx +0 -19
- components/custom/theme-provider.tsx +0 -22
- components/custom/top-nav.tsx +0 -96
- components/ui/badge.tsx +36 -0
- components/ui/button.tsx +14 -14
- components/ui/card.tsx +23 -16
- components/ui/command.tsx +153 -0
- components/ui/dialog.tsx +122 -0
- components/ui/dropdown-menu.tsx +41 -41
- components/ui/input.tsx +22 -0
- components/ui/popover.tsx +33 -0
- components/ui/separator.tsx +31 -0
- lib/utils.ts +3 -3
- next.config.ts +5 -5
- package.json +7 -0
- pnpm-lock.yaml +0 -0
- public/chatbots/code-tutor.webp +0 -0
- public/chatbots/whimsical.svg +1 -0
- tailwind.config.ts +71 -71
.eslintrc.json
CHANGED
@@ -1,3 +1,3 @@
|
|
1 |
{
|
2 |
-
"extends": ["next/core-web-vitals", "next/typescript"]
|
3 |
}
|
|
|
1 |
{
|
2 |
+
"extends": ["next/core-web-vitals", "next/typescript", "prettier"]
|
3 |
}
|
.gitignore
CHANGED
@@ -50,4 +50,5 @@ venv/
|
|
50 |
node_modules
|
51 |
|
52 |
# local files
|
53 |
-
/local_files
|
|
|
|
50 |
node_modules
|
51 |
|
52 |
# local files
|
53 |
+
/local_files
|
54 |
+
/prompts
|
.prettierignore
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
# Ignore artifacts:
|
2 |
+
build
|
3 |
+
coverage
|
.prettierrc
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
{}
|
app/(audiences)/components/chat-bot-card.tsx
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Card, CardContent } from "@/components/ui/card";
|
2 |
+
import Image from "next/image";
|
3 |
+
import { MessageSquareShare } from "lucide-react";
|
4 |
+
import { ChatBot } from "@/app/(audiences)/for-students/data/chatbots";
|
5 |
+
|
6 |
+
interface ChatBotCardProps {
|
7 |
+
chatbot: ChatBot;
|
8 |
+
}
|
9 |
+
|
10 |
+
export default function ChatBotCard({ chatbot }: ChatBotCardProps) {
|
11 |
+
const baseIconName = chatbot.icon.split(".")[0];
|
12 |
+
|
13 |
+
return (
|
14 |
+
<Card className="group relative overflow-hidden bg-background-primary/50 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 backdrop-blur-sm border-2 border-border/40 hover:border-primary/20 duration-300 aspect-square">
|
15 |
+
<CardContent className="p-8 h-full flex flex-col">
|
16 |
+
{/* Avatar with WebP and PNG fallback */}
|
17 |
+
<div className="flex-shrink-0 mb-6">
|
18 |
+
<picture>
|
19 |
+
<source
|
20 |
+
srcSet={`/chatbots/${baseIconName}.webp`}
|
21 |
+
type="image/webp"
|
22 |
+
/>
|
23 |
+
<Image
|
24 |
+
src={`/chatbots/${baseIconName}.png`}
|
25 |
+
alt={chatbot.title}
|
26 |
+
width={64}
|
27 |
+
height={64}
|
28 |
+
className="relative rounded-lg transform group-hover:scale-105 transition duration-300"
|
29 |
+
/>
|
30 |
+
</picture>
|
31 |
+
</div>
|
32 |
+
|
33 |
+
{/* Content */}
|
34 |
+
<div className="flex-1 flex flex-col">
|
35 |
+
<h3 className="text-xl font-bold text-text-primary truncate bg-gradient-to-r from-[#FF6B6B] via-[#4ECDC4] to-[#45B7D1] bg-clip-text text-transparent group-hover:tracking-wide transition-all duration-300 mb-3">
|
36 |
+
{chatbot.title}
|
37 |
+
</h3>
|
38 |
+
|
39 |
+
<p className="text-base text-text-secondary line-clamp-3 mb-6 flex-1 leading-relaxed">
|
40 |
+
{chatbot.description}
|
41 |
+
</p>
|
42 |
+
|
43 |
+
<button
|
44 |
+
className="flex-shrink-0 inline-flex items-center justify-center rounded-full bg-primary w-12 h-12 text-white transition-all duration-300 hover:scale-110 active:scale-95 hover:bg-primary/90 ml-auto"
|
45 |
+
aria-label="開始對話"
|
46 |
+
>
|
47 |
+
<MessageSquareShare className="w-6 h-6 group-hover:animate-pulse" />
|
48 |
+
</button>
|
49 |
+
</div>
|
50 |
+
</CardContent>
|
51 |
+
|
52 |
+
{/* Decorative corner accent */}
|
53 |
+
<div className="absolute top-0 right-0 w-20 h-20 overflow-hidden">
|
54 |
+
<div className="absolute top-0 right-0 w-16 h-16 bg-gradient-to-bl from-primary/10 via-primary/5 to-transparent transform rotate-45 translate-x-10 -translate-y-10 group-hover:translate-x-8 group-hover:-translate-y-8 transition-transform duration-300" />
|
55 |
+
</div>
|
56 |
+
</Card>
|
57 |
+
);
|
58 |
+
}
|
components/custom/ChatBotGridSkeleton.tsx → app/(audiences)/components/chat-bot-grid-skeleton.tsx
RENAMED
@@ -21,5 +21,5 @@ export default function ChatBotGridSkeleton() {
|
|
21 |
</div>
|
22 |
))}
|
23 |
</div>
|
24 |
-
)
|
25 |
-
}
|
|
|
21 |
</div>
|
22 |
))}
|
23 |
</div>
|
24 |
+
);
|
25 |
+
}
|
app/(audiences)/components/chat-bot-grid.tsx
ADDED
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import { useState, useEffect } from "react";
|
4 |
+
import ChatBotCard from "./chat-bot-card";
|
5 |
+
import { ChatBot } from "@/app/(audiences)/for-students/data/chatbots";
|
6 |
+
|
7 |
+
interface SectionProps {
|
8 |
+
title: string;
|
9 |
+
subtitle: string;
|
10 |
+
bots: ChatBot[];
|
11 |
+
columns?: number;
|
12 |
+
}
|
13 |
+
|
14 |
+
function Section({ title, subtitle, bots, columns = 4 }: SectionProps) {
|
15 |
+
if (bots.length === 0) return null;
|
16 |
+
|
17 |
+
return (
|
18 |
+
<div className="mb-16">
|
19 |
+
<div className="mb-8">
|
20 |
+
<h2 className="text-2xl font-bold text-text-primary mb-2">{title}</h2>
|
21 |
+
<p className="text-text-secondary">{subtitle}</p>
|
22 |
+
</div>
|
23 |
+
<div className={`grid gap-4 sm:grid-cols-2 lg:grid-cols-${columns}`}>
|
24 |
+
{bots.map((chatbot) => (
|
25 |
+
<ChatBotCard key={chatbot.id} chatbot={chatbot} />
|
26 |
+
))}
|
27 |
+
</div>
|
28 |
+
</div>
|
29 |
+
);
|
30 |
+
}
|
31 |
+
|
32 |
+
interface ChatBotGridProps {
|
33 |
+
selectedCategories: string[];
|
34 |
+
selectedSubjects: string[];
|
35 |
+
searchQuery: string;
|
36 |
+
getPopularBots: () => ChatBot[];
|
37 |
+
getTrendingBots: () => ChatBot[];
|
38 |
+
getAllBots: () => ChatBot[];
|
39 |
+
}
|
40 |
+
|
41 |
+
export default function ChatBotGrid({
|
42 |
+
selectedCategories,
|
43 |
+
selectedSubjects,
|
44 |
+
searchQuery,
|
45 |
+
getPopularBots,
|
46 |
+
getTrendingBots,
|
47 |
+
getAllBots,
|
48 |
+
}: ChatBotGridProps) {
|
49 |
+
// Initialize state with the passed-in functions
|
50 |
+
const [filteredPopularBots, setFilteredPopularBots] = useState<ChatBot[]>(
|
51 |
+
() => getPopularBots(),
|
52 |
+
);
|
53 |
+
const [filteredTrendingBots, setFilteredTrendingBots] = useState<ChatBot[]>(
|
54 |
+
() => getTrendingBots(),
|
55 |
+
);
|
56 |
+
const [filteredAllBots, setFilteredAllBots] = useState<ChatBot[]>(() =>
|
57 |
+
getAllBots(),
|
58 |
+
);
|
59 |
+
|
60 |
+
useEffect(() => {
|
61 |
+
const filterBots = (bots: ChatBot[]) => {
|
62 |
+
let filtered = bots;
|
63 |
+
|
64 |
+
if (searchQuery) {
|
65 |
+
const query = searchQuery.toLowerCase();
|
66 |
+
filtered = filtered.filter(
|
67 |
+
(bot) =>
|
68 |
+
bot.title.toLowerCase().includes(query) ||
|
69 |
+
bot.description.toLowerCase().includes(query) ||
|
70 |
+
bot.subject.toLowerCase().includes(query) ||
|
71 |
+
bot.category.toLowerCase().includes(query),
|
72 |
+
);
|
73 |
+
}
|
74 |
+
|
75 |
+
if (selectedCategories.length > 0) {
|
76 |
+
filtered = filtered.filter(
|
77 |
+
(bot) => bot.category && selectedCategories.includes(bot.category),
|
78 |
+
);
|
79 |
+
}
|
80 |
+
|
81 |
+
if (selectedSubjects.length > 0) {
|
82 |
+
filtered = filtered.filter((bot) =>
|
83 |
+
selectedSubjects.includes(bot.subject),
|
84 |
+
);
|
85 |
+
}
|
86 |
+
|
87 |
+
return filtered;
|
88 |
+
};
|
89 |
+
|
90 |
+
setFilteredPopularBots(filterBots(getPopularBots()));
|
91 |
+
setFilteredTrendingBots(filterBots(getTrendingBots()));
|
92 |
+
setFilteredAllBots(filterBots(getAllBots()));
|
93 |
+
}, [
|
94 |
+
selectedCategories,
|
95 |
+
selectedSubjects,
|
96 |
+
searchQuery,
|
97 |
+
getPopularBots,
|
98 |
+
getTrendingBots,
|
99 |
+
getAllBots,
|
100 |
+
]);
|
101 |
+
|
102 |
+
const hasFiltersOrSearch =
|
103 |
+
selectedCategories.length > 0 || selectedSubjects.length > 0 || searchQuery;
|
104 |
+
|
105 |
+
if (hasFiltersOrSearch) {
|
106 |
+
return (
|
107 |
+
<Section
|
108 |
+
title="搜尋結果"
|
109 |
+
subtitle={`符合 ${searchQuery ? '"' + searchQuery + '" ' : ""}的工具`}
|
110 |
+
bots={filteredAllBots}
|
111 |
+
columns={4}
|
112 |
+
/>
|
113 |
+
);
|
114 |
+
}
|
115 |
+
|
116 |
+
const tutorBots = filteredAllBots.filter((bot) => bot.category === "教育");
|
117 |
+
const teachingBots = filteredAllBots.filter((bot) => bot.subject === "教學");
|
118 |
+
const assessmentBots = filteredAllBots.filter(
|
119 |
+
(bot) => bot.subject === "評量",
|
120 |
+
);
|
121 |
+
|
122 |
+
return (
|
123 |
+
<div className="space-y-8">
|
124 |
+
<Section
|
125 |
+
title="精選"
|
126 |
+
subtitle="歷來最多人使用的工具"
|
127 |
+
bots={filteredPopularBots}
|
128 |
+
columns={4}
|
129 |
+
/>
|
130 |
+
<Section
|
131 |
+
title="熱門"
|
132 |
+
subtitle="近期最多人使用的工具"
|
133 |
+
bots={filteredTrendingBots}
|
134 |
+
columns={4}
|
135 |
+
/>
|
136 |
+
<Section
|
137 |
+
title="教學輔助"
|
138 |
+
subtitle="教學規劃與課程設計工具"
|
139 |
+
bots={teachingBots}
|
140 |
+
columns={4}
|
141 |
+
/>
|
142 |
+
<Section
|
143 |
+
title="評量工具"
|
144 |
+
subtitle="作業與評量相關工具"
|
145 |
+
bots={assessmentBots}
|
146 |
+
columns={4}
|
147 |
+
/>
|
148 |
+
<Section
|
149 |
+
title="教育資源"
|
150 |
+
subtitle="其他教育相關工具"
|
151 |
+
bots={tutorBots}
|
152 |
+
columns={4}
|
153 |
+
/>
|
154 |
+
</div>
|
155 |
+
);
|
156 |
+
}
|
components/custom/SearchBar.tsx → app/(audiences)/components/search-bar.tsx
RENAMED
@@ -1,13 +1,18 @@
|
|
1 |
-
|
2 |
|
3 |
-
|
|
|
|
|
|
|
|
|
4 |
return (
|
5 |
<div className="relative max-w-full mx-auto">
|
6 |
<input
|
7 |
type="text"
|
8 |
-
placeholder="搜尋
|
|
|
9 |
className="w-full rounded-2xl border border-border/50 bg-background-primary/50 px-4 py-6 pl-12 pr-36
|
10 |
-
backdrop-blur-sm shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/50
|
11 |
text-lg transition-all duration-200 placeholder:text-text-secondary/50"
|
12 |
/>
|
13 |
<svg
|
@@ -24,5 +29,5 @@ export default function SearchBar() {
|
|
24 |
/>
|
25 |
</svg>
|
26 |
</div>
|
27 |
-
)
|
28 |
-
}
|
|
|
1 |
+
"use client";
|
2 |
|
3 |
+
interface SearchBarProps {
|
4 |
+
onSearch: (query: string) => void;
|
5 |
+
}
|
6 |
+
|
7 |
+
export default function SearchBar({ onSearch }: SearchBarProps) {
|
8 |
return (
|
9 |
<div className="relative max-w-full mx-auto">
|
10 |
<input
|
11 |
type="text"
|
12 |
+
placeholder="搜尋 PlayGO AI..."
|
13 |
+
onChange={(e) => onSearch(e.target.value)}
|
14 |
className="w-full rounded-2xl border border-border/50 bg-background-primary/50 px-4 py-6 pl-12 pr-36
|
15 |
+
backdrop-blur-sm shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/50
|
16 |
text-lg transition-all duration-200 placeholder:text-text-secondary/50"
|
17 |
/>
|
18 |
<svg
|
|
|
29 |
/>
|
30 |
</svg>
|
31 |
</div>
|
32 |
+
);
|
33 |
+
}
|
app/(audiences)/components/subject-faceted-filter.tsx
ADDED
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import { Check, PlusCircle } from "lucide-react";
|
5 |
+
import { cn } from "@/lib/utils";
|
6 |
+
import { Badge } from "@/components/ui/badge";
|
7 |
+
import { Button } from "@/components/ui/button";
|
8 |
+
import {
|
9 |
+
Command,
|
10 |
+
CommandEmpty,
|
11 |
+
CommandGroup,
|
12 |
+
CommandInput,
|
13 |
+
CommandItem,
|
14 |
+
CommandList,
|
15 |
+
CommandSeparator,
|
16 |
+
} from "@/components/ui/command";
|
17 |
+
import {
|
18 |
+
Popover,
|
19 |
+
PopoverContent,
|
20 |
+
PopoverTrigger,
|
21 |
+
} from "@/components/ui/popover";
|
22 |
+
import { Separator } from "@/components/ui/separator";
|
23 |
+
|
24 |
+
interface DataTableFacetedFilterProps {
|
25 |
+
title: string;
|
26 |
+
options: {
|
27 |
+
label: string;
|
28 |
+
value: string;
|
29 |
+
}[];
|
30 |
+
selectedValues: string[];
|
31 |
+
onSelectionChange: (values: string[]) => void;
|
32 |
+
}
|
33 |
+
|
34 |
+
export function DataTableFacetedFilter({
|
35 |
+
title,
|
36 |
+
options,
|
37 |
+
selectedValues,
|
38 |
+
onSelectionChange,
|
39 |
+
}: DataTableFacetedFilterProps) {
|
40 |
+
return (
|
41 |
+
<Popover>
|
42 |
+
<PopoverTrigger asChild>
|
43 |
+
<Button
|
44 |
+
variant="outline"
|
45 |
+
size="lg"
|
46 |
+
className="h-16 text-lg border-2 hover:bg-accent/50 min-w-[160px]"
|
47 |
+
>
|
48 |
+
<PlusCircle className="mr-3 h-6 w-6" />
|
49 |
+
{title}
|
50 |
+
{selectedValues?.length > 0 && (
|
51 |
+
<>
|
52 |
+
<Separator orientation="vertical" className="mx-4 h-8" />
|
53 |
+
<Badge
|
54 |
+
variant="secondary"
|
55 |
+
className="rounded-md px-3 py-1.5 text-base font-normal lg:hidden"
|
56 |
+
>
|
57 |
+
{selectedValues.length}
|
58 |
+
</Badge>
|
59 |
+
<div className="hidden space-x-2 lg:flex">
|
60 |
+
{selectedValues.length > 2 ? (
|
61 |
+
<Badge
|
62 |
+
variant="secondary"
|
63 |
+
className="rounded-md px-3 py-1.5 text-base font-normal"
|
64 |
+
>
|
65 |
+
已選擇 {selectedValues.length} 項
|
66 |
+
</Badge>
|
67 |
+
) : (
|
68 |
+
options
|
69 |
+
.filter((option) => selectedValues.includes(option.value))
|
70 |
+
.map((option) => (
|
71 |
+
<Badge
|
72 |
+
variant="secondary"
|
73 |
+
key={option.value}
|
74 |
+
className="rounded-md px-3 py-1.5 text-base font-normal"
|
75 |
+
>
|
76 |
+
{option.label}
|
77 |
+
</Badge>
|
78 |
+
))
|
79 |
+
)}
|
80 |
+
</div>
|
81 |
+
</>
|
82 |
+
)}
|
83 |
+
</Button>
|
84 |
+
</PopoverTrigger>
|
85 |
+
<PopoverContent className="w-[320px] p-0" align="start">
|
86 |
+
<Command>
|
87 |
+
<CommandInput
|
88 |
+
placeholder={`搜尋${title}...`}
|
89 |
+
className="h-14 text-lg"
|
90 |
+
/>
|
91 |
+
<CommandList>
|
92 |
+
<CommandEmpty>找不到結果</CommandEmpty>
|
93 |
+
<CommandGroup>
|
94 |
+
{options.map((option) => {
|
95 |
+
const isSelected = selectedValues.includes(option.value);
|
96 |
+
return (
|
97 |
+
<CommandItem
|
98 |
+
key={option.value}
|
99 |
+
onSelect={() => {
|
100 |
+
if (isSelected) {
|
101 |
+
onSelectionChange(
|
102 |
+
selectedValues.filter(
|
103 |
+
(value) => value !== option.value,
|
104 |
+
),
|
105 |
+
);
|
106 |
+
} else {
|
107 |
+
onSelectionChange([...selectedValues, option.value]);
|
108 |
+
}
|
109 |
+
}}
|
110 |
+
className="p-4 text-lg"
|
111 |
+
>
|
112 |
+
<div
|
113 |
+
className={cn(
|
114 |
+
"mr-4 flex h-6 w-6 items-center justify-center rounded-sm border-2 border-primary",
|
115 |
+
isSelected
|
116 |
+
? "bg-primary text-primary-foreground"
|
117 |
+
: "opacity-50 [&_svg]:invisible",
|
118 |
+
)}
|
119 |
+
>
|
120 |
+
<Check className="h-5 w-5" />
|
121 |
+
</div>
|
122 |
+
<span>{option.label}</span>
|
123 |
+
</CommandItem>
|
124 |
+
);
|
125 |
+
})}
|
126 |
+
</CommandGroup>
|
127 |
+
{selectedValues.length > 0 && (
|
128 |
+
<>
|
129 |
+
<CommandSeparator />
|
130 |
+
<CommandGroup>
|
131 |
+
<CommandItem
|
132 |
+
onSelect={() => onSelectionChange([])}
|
133 |
+
className="justify-center p-4 text-lg"
|
134 |
+
>
|
135 |
+
清除篩選
|
136 |
+
</CommandItem>
|
137 |
+
</CommandGroup>
|
138 |
+
</>
|
139 |
+
)}
|
140 |
+
</CommandList>
|
141 |
+
</Command>
|
142 |
+
</PopoverContent>
|
143 |
+
</Popover>
|
144 |
+
);
|
145 |
+
}
|
app/(audiences)/components/subject-filter-view-options.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
4 |
+
import { Table } from "@tanstack/react-table";
|
5 |
+
import { Settings2 } from "lucide-react";
|
6 |
+
|
7 |
+
import { Button } from "@/components/ui/button";
|
8 |
+
import {
|
9 |
+
DropdownMenu,
|
10 |
+
DropdownMenuCheckboxItem,
|
11 |
+
DropdownMenuContent,
|
12 |
+
DropdownMenuLabel,
|
13 |
+
DropdownMenuSeparator,
|
14 |
+
} from "@/components/ui/dropdown-menu";
|
15 |
+
|
16 |
+
interface DataTableViewOptionsProps<TData> {
|
17 |
+
table: Table<TData>;
|
18 |
+
}
|
19 |
+
|
20 |
+
export function DataTableViewOptions<TData>({
|
21 |
+
table,
|
22 |
+
}: DataTableViewOptionsProps<TData>) {
|
23 |
+
return (
|
24 |
+
<DropdownMenu>
|
25 |
+
<DropdownMenuTrigger asChild>
|
26 |
+
<Button
|
27 |
+
variant="outline"
|
28 |
+
size="sm"
|
29 |
+
className="ml-auto hidden h-8 lg:flex"
|
30 |
+
>
|
31 |
+
<Settings2 />
|
32 |
+
View
|
33 |
+
</Button>
|
34 |
+
</DropdownMenuTrigger>
|
35 |
+
<DropdownMenuContent align="end" className="w-[150px]">
|
36 |
+
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
37 |
+
<DropdownMenuSeparator />
|
38 |
+
{table
|
39 |
+
.getAllColumns()
|
40 |
+
.filter(
|
41 |
+
(column) =>
|
42 |
+
typeof column.accessorFn !== "undefined" && column.getCanHide(),
|
43 |
+
)
|
44 |
+
.map((column) => {
|
45 |
+
return (
|
46 |
+
<DropdownMenuCheckboxItem
|
47 |
+
key={column.id}
|
48 |
+
className="capitalize"
|
49 |
+
checked={column.getIsVisible()}
|
50 |
+
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
51 |
+
>
|
52 |
+
{column.id}
|
53 |
+
</DropdownMenuCheckboxItem>
|
54 |
+
);
|
55 |
+
})}
|
56 |
+
</DropdownMenuContent>
|
57 |
+
</DropdownMenu>
|
58 |
+
);
|
59 |
+
}
|
app/(audiences)/components/subject-filter.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import { categories, subjects } from "@/app/(audiences)/for-students/data/data";
|
5 |
+
import { DataTableFacetedFilter } from "@/app/(audiences)/components/subject-faceted-filter";
|
6 |
+
import { Button } from "@/components/ui/button";
|
7 |
+
import { X } from "lucide-react";
|
8 |
+
|
9 |
+
interface SubjectFilterProps {
|
10 |
+
selectedCategories: string[];
|
11 |
+
setSelectedCategories: (categories: string[]) => void;
|
12 |
+
selectedSubjects: string[];
|
13 |
+
setSelectedSubjects: (subjects: string[]) => void;
|
14 |
+
}
|
15 |
+
|
16 |
+
export function SubjectFilter({
|
17 |
+
selectedCategories,
|
18 |
+
setSelectedCategories,
|
19 |
+
selectedSubjects,
|
20 |
+
setSelectedSubjects,
|
21 |
+
}: SubjectFilterProps) {
|
22 |
+
const isFiltered =
|
23 |
+
selectedCategories.length > 0 || selectedSubjects.length > 0;
|
24 |
+
|
25 |
+
const handleReset = () => {
|
26 |
+
setSelectedCategories([]);
|
27 |
+
setSelectedSubjects([]);
|
28 |
+
};
|
29 |
+
|
30 |
+
return (
|
31 |
+
<div className="flex items-center justify-between">
|
32 |
+
<div className="flex flex-1 items-center space-x-6">
|
33 |
+
<DataTableFacetedFilter
|
34 |
+
title="科目"
|
35 |
+
options={subjects}
|
36 |
+
selectedValues={selectedSubjects}
|
37 |
+
onSelectionChange={setSelectedSubjects}
|
38 |
+
/>
|
39 |
+
<DataTableFacetedFilter
|
40 |
+
title="類別"
|
41 |
+
options={categories}
|
42 |
+
selectedValues={selectedCategories}
|
43 |
+
onSelectionChange={setSelectedCategories}
|
44 |
+
/>
|
45 |
+
{isFiltered && (
|
46 |
+
<Button
|
47 |
+
variant="ghost"
|
48 |
+
size="lg"
|
49 |
+
onClick={handleReset}
|
50 |
+
className="h-16 px-6 text-lg"
|
51 |
+
>
|
52 |
+
重設
|
53 |
+
<X className="ml-3 h-6 w-6" />
|
54 |
+
</Button>
|
55 |
+
)}
|
56 |
+
</div>
|
57 |
+
</div>
|
58 |
+
);
|
59 |
+
}
|
app/(audiences)/for-students/data/chatbots.ts
ADDED
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface ChatBot {
|
2 |
+
id: string;
|
3 |
+
title: string;
|
4 |
+
description: string;
|
5 |
+
subject: string;
|
6 |
+
icon: string;
|
7 |
+
category: string;
|
8 |
+
popular?: boolean;
|
9 |
+
trending?: boolean;
|
10 |
+
}
|
11 |
+
|
12 |
+
export const CHATBOTS: ChatBot[] = [
|
13 |
+
// 精選 (最多人用)
|
14 |
+
{
|
15 |
+
id: "1",
|
16 |
+
title: "Code Tutor",
|
17 |
+
description: "Let's code together! I'm Khanmigo Lite, by Khan Academy.",
|
18 |
+
subject: "電腦科學",
|
19 |
+
category: "教育",
|
20 |
+
icon: "code-tutor.webp",
|
21 |
+
popular: true,
|
22 |
+
},
|
23 |
+
{
|
24 |
+
id: "2",
|
25 |
+
title: "Math Solver",
|
26 |
+
description: "數學解題助手,從基礎數學到高等微積分都能協助理解和解題。",
|
27 |
+
subject: "數學",
|
28 |
+
category: "教育",
|
29 |
+
icon: "code-tutor.webp",
|
30 |
+
popular: true,
|
31 |
+
},
|
32 |
+
{
|
33 |
+
id: "3",
|
34 |
+
title: "Essay Writer",
|
35 |
+
description: "協助改進寫作技巧,提供建議和修改意見,適用於各種文體。",
|
36 |
+
subject: "國語文",
|
37 |
+
category: "寫作",
|
38 |
+
icon: "code-tutor.webp",
|
39 |
+
popular: true,
|
40 |
+
},
|
41 |
+
{
|
42 |
+
id: "4",
|
43 |
+
title: "English Master",
|
44 |
+
description: "全方位英語學習助手,提供聽說讀寫全面訓練。",
|
45 |
+
subject: "英文",
|
46 |
+
category: "語言",
|
47 |
+
icon: "code-tutor.webp",
|
48 |
+
popular: true,
|
49 |
+
},
|
50 |
+
|
51 |
+
// 熱門 (近期熱門)
|
52 |
+
{
|
53 |
+
id: "5",
|
54 |
+
title: "Math Practice",
|
55 |
+
description: "針對各年級數學題目練習,提供詳細解答和步驟說明。",
|
56 |
+
subject: "數學",
|
57 |
+
category: "教育",
|
58 |
+
icon: "code-tutor.webp",
|
59 |
+
trending: true,
|
60 |
+
},
|
61 |
+
{
|
62 |
+
id: "6",
|
63 |
+
title: "English Conversation",
|
64 |
+
description: "實用英語對話練習,提升口語和聽力能力。",
|
65 |
+
subject: "英文",
|
66 |
+
category: "語言",
|
67 |
+
icon: "code-tutor.webp",
|
68 |
+
trending: true,
|
69 |
+
},
|
70 |
+
{
|
71 |
+
id: "7",
|
72 |
+
title: "Physics Tutor",
|
73 |
+
description: "物理概念解說和題目解析,讓物理學習更輕鬆。",
|
74 |
+
subject: "自然科學",
|
75 |
+
category: "教育",
|
76 |
+
icon: "code-tutor.webp",
|
77 |
+
trending: true,
|
78 |
+
},
|
79 |
+
{
|
80 |
+
id: "8",
|
81 |
+
title: "Chemistry Lab",
|
82 |
+
description: "虛擬化學實驗室,安全地探索化學反應和概念。",
|
83 |
+
subject: "自然科學",
|
84 |
+
category: "教育",
|
85 |
+
icon: "code-tutor.webp",
|
86 |
+
trending: true,
|
87 |
+
},
|
88 |
+
|
89 |
+
// 家教、數學、英文相關
|
90 |
+
{
|
91 |
+
id: "9",
|
92 |
+
title: "Personal Tutor",
|
93 |
+
description: "個人化學習規劃和指導,適應每個學生的學習步調。",
|
94 |
+
subject: "教學",
|
95 |
+
category: "教育",
|
96 |
+
icon: "code-tutor.webp",
|
97 |
+
},
|
98 |
+
{
|
99 |
+
id: "10",
|
100 |
+
title: "Math Concepts",
|
101 |
+
description: "深入淺出講解數學概念,建立紮實的數學基礎。",
|
102 |
+
subject: "數學",
|
103 |
+
category: "教育",
|
104 |
+
icon: "code-tutor.webp",
|
105 |
+
},
|
106 |
+
{
|
107 |
+
id: "11",
|
108 |
+
title: "English Grammar",
|
109 |
+
description: "系統性學習英語文法,從基礎到進階全面掌握。",
|
110 |
+
subject: "英文",
|
111 |
+
category: "語言",
|
112 |
+
icon: "code-tutor.webp",
|
113 |
+
},
|
114 |
+
{
|
115 |
+
id: "12",
|
116 |
+
title: "English Writing",
|
117 |
+
description: "英文寫作指導,從句子到文章的全方位訓練。",
|
118 |
+
subject: "英文",
|
119 |
+
category: "語言",
|
120 |
+
icon: "code-tutor.webp",
|
121 |
+
},
|
122 |
+
{
|
123 |
+
id: "13",
|
124 |
+
title: "Math Problems",
|
125 |
+
description: "豐富的數學題庫,配合詳細解說和練習。",
|
126 |
+
subject: "數學",
|
127 |
+
category: "教育",
|
128 |
+
icon: "code-tutor.webp",
|
129 |
+
},
|
130 |
+
{
|
131 |
+
id: "14",
|
132 |
+
title: "TOEIC Prep",
|
133 |
+
description: "針對多益考試的專業培訓和模擬測驗。",
|
134 |
+
subject: "英文",
|
135 |
+
category: "語言",
|
136 |
+
icon: "code-tutor.webp",
|
137 |
+
},
|
138 |
+
];
|
139 |
+
|
140 |
+
export const getPopularBots = () => CHATBOTS.filter((bot) => bot.popular);
|
141 |
+
export const getTrendingBots = () => CHATBOTS.filter((bot) => bot.trending);
|
142 |
+
export const getAllBots = () => CHATBOTS;
|
app/(audiences)/for-students/data/data.tsx
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const categories = [
|
2 |
+
{
|
3 |
+
value: "熱門精選",
|
4 |
+
label: "熱門精選",
|
5 |
+
},
|
6 |
+
{
|
7 |
+
value: "寫作",
|
8 |
+
label: "寫作",
|
9 |
+
},
|
10 |
+
{
|
11 |
+
value: "生產力",
|
12 |
+
label: "生產力",
|
13 |
+
},
|
14 |
+
{
|
15 |
+
value: "研究與分析",
|
16 |
+
label: "研究與分析",
|
17 |
+
},
|
18 |
+
{
|
19 |
+
value: "教育",
|
20 |
+
label: "教育",
|
21 |
+
},
|
22 |
+
];
|
23 |
+
|
24 |
+
export const subjects = [
|
25 |
+
{
|
26 |
+
value: "國語文",
|
27 |
+
label: "國語文",
|
28 |
+
},
|
29 |
+
{
|
30 |
+
value: "英文",
|
31 |
+
label: "英文",
|
32 |
+
},
|
33 |
+
{
|
34 |
+
value: "數學",
|
35 |
+
label: "數學",
|
36 |
+
},
|
37 |
+
{
|
38 |
+
value: "自然科學",
|
39 |
+
label: "自然科學",
|
40 |
+
},
|
41 |
+
{
|
42 |
+
value: "社會科學",
|
43 |
+
label: "社會科學",
|
44 |
+
},
|
45 |
+
{
|
46 |
+
value: "電腦科學",
|
47 |
+
label: "電腦科學",
|
48 |
+
},
|
49 |
+
];
|
app/{for-students → (audiences)/for-students}/page.tsx
RENAMED
@@ -1,36 +1,62 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
import
|
4 |
-
import
|
5 |
-
import
|
|
|
|
|
|
|
|
|
6 |
|
7 |
export default function ForStudentsPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
return (
|
9 |
<main className="min-h-screen bg-background-primary">
|
10 |
{/* Hero Section */}
|
11 |
<section className="mb-12 pt-16 text-center">
|
12 |
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
13 |
-
|
14 |
</h1>
|
15 |
<p className="mx-auto max-w-2xl text-lg text-text-secondary">
|
16 |
-
|
17 |
</p>
|
18 |
</section>
|
19 |
|
20 |
{/* Search and Filter Section */}
|
21 |
<div className="container mx-auto px-4">
|
22 |
<div className="mb-8">
|
23 |
-
<SearchBar />
|
24 |
</div>
|
25 |
<div className="mb-12">
|
26 |
-
<SubjectFilter
|
|
|
|
|
|
|
|
|
|
|
27 |
</div>
|
28 |
|
29 |
{/* Chatbots Grid */}
|
30 |
<Suspense fallback={<ChatBotGridSkeleton />}>
|
31 |
-
<ChatBotGrid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
</Suspense>
|
33 |
</div>
|
34 |
</main>
|
35 |
-
)
|
36 |
-
}
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import { Suspense } from "react";
|
5 |
+
import ChatBotGrid from "@/app/(audiences)/components/chat-bot-grid";
|
6 |
+
import { SubjectFilter } from "@/app/(audiences)/components/subject-filter";
|
7 |
+
import SearchBar from "@/app/(audiences)/components/search-bar";
|
8 |
+
import ChatBotGridSkeleton from "@/app/(audiences)/components/chat-bot-grid-skeleton";
|
9 |
+
import { getPopularBots, getTrendingBots, getAllBots } from "./data/chatbots";
|
10 |
|
11 |
export default function ForStudentsPage() {
|
12 |
+
const [selectedCategories, setSelectedCategories] = React.useState<string[]>(
|
13 |
+
[],
|
14 |
+
);
|
15 |
+
const [selectedSubjects, setSelectedSubjects] = React.useState<string[]>([]);
|
16 |
+
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
17 |
+
|
18 |
+
const handleSearch = (query: string) => {
|
19 |
+
setSearchQuery(query);
|
20 |
+
};
|
21 |
+
|
22 |
return (
|
23 |
<main className="min-h-screen bg-background-primary">
|
24 |
{/* Hero Section */}
|
25 |
<section className="mb-12 pt-16 text-center">
|
26 |
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
27 |
+
PlayGO for Students
|
28 |
</h1>
|
29 |
<p className="mx-auto max-w-2xl text-lg text-text-secondary">
|
30 |
+
探索適合學習的 AI 聊天機器人,提升學習效率!
|
31 |
</p>
|
32 |
</section>
|
33 |
|
34 |
{/* Search and Filter Section */}
|
35 |
<div className="container mx-auto px-4">
|
36 |
<div className="mb-8">
|
37 |
+
<SearchBar onSearch={handleSearch} />
|
38 |
</div>
|
39 |
<div className="mb-12">
|
40 |
+
<SubjectFilter
|
41 |
+
selectedCategories={selectedCategories}
|
42 |
+
setSelectedCategories={setSelectedCategories}
|
43 |
+
selectedSubjects={selectedSubjects}
|
44 |
+
setSelectedSubjects={setSelectedSubjects}
|
45 |
+
/>
|
46 |
</div>
|
47 |
|
48 |
{/* Chatbots Grid */}
|
49 |
<Suspense fallback={<ChatBotGridSkeleton />}>
|
50 |
+
<ChatBotGrid
|
51 |
+
selectedCategories={selectedCategories}
|
52 |
+
selectedSubjects={selectedSubjects}
|
53 |
+
searchQuery={searchQuery}
|
54 |
+
getPopularBots={getPopularBots}
|
55 |
+
getTrendingBots={getTrendingBots}
|
56 |
+
getAllBots={getAllBots}
|
57 |
+
/>
|
58 |
</Suspense>
|
59 |
</div>
|
60 |
</main>
|
61 |
+
);
|
62 |
+
}
|
app/(audiences)/for-teachers/data/chatbots.ts
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ChatBot } from "@/app/(audiences)/for-students/data/chatbots";
|
2 |
+
|
3 |
+
export const CHATBOTS: ChatBot[] = [
|
4 |
+
// 精選 (最多人用)
|
5 |
+
{
|
6 |
+
id: "1",
|
7 |
+
title: "Lesson Planner",
|
8 |
+
description: "智能課程規劃助手,協助教師設計有效的教學計劃。",
|
9 |
+
subject: "教學",
|
10 |
+
category: "教育",
|
11 |
+
icon: "lesson-planner.webp",
|
12 |
+
popular: true,
|
13 |
+
},
|
14 |
+
{
|
15 |
+
id: "2",
|
16 |
+
title: "Assignment Creator",
|
17 |
+
description: "自動生成作業和測驗,節省備課時間。",
|
18 |
+
subject: "教學",
|
19 |
+
category: "教育",
|
20 |
+
icon: "assignment-creator.webp",
|
21 |
+
popular: true,
|
22 |
+
},
|
23 |
+
{
|
24 |
+
id: "3",
|
25 |
+
title: "Grading Assistant",
|
26 |
+
description: "智能評分助手,提供客觀的評分建議和回饋。",
|
27 |
+
subject: "評量",
|
28 |
+
category: "教育",
|
29 |
+
icon: "grading-assistant.webp",
|
30 |
+
popular: true,
|
31 |
+
},
|
32 |
+
{
|
33 |
+
id: "4",
|
34 |
+
title: "Student Analytics",
|
35 |
+
description: "學生學習數據分析,掌握學習成效。",
|
36 |
+
subject: "分析",
|
37 |
+
category: "研究與分析",
|
38 |
+
icon: "student-analytics.webp",
|
39 |
+
popular: true,
|
40 |
+
},
|
41 |
+
|
42 |
+
// 熱門 (近期熱門)
|
43 |
+
{
|
44 |
+
id: "5",
|
45 |
+
title: "Content Creator",
|
46 |
+
description: "教材內容生成助手,快速製作教學素材。",
|
47 |
+
subject: "教學",
|
48 |
+
category: "生產力",
|
49 |
+
icon: "content-creator.webp",
|
50 |
+
trending: true,
|
51 |
+
},
|
52 |
+
{
|
53 |
+
id: "6",
|
54 |
+
title: "Classroom Manager",
|
55 |
+
description: "課堂管理助手,提升教學效率。",
|
56 |
+
subject: "管理",
|
57 |
+
category: "教育",
|
58 |
+
icon: "classroom-manager.webp",
|
59 |
+
trending: true,
|
60 |
+
},
|
61 |
+
// Add more teacher-specific chatbots...
|
62 |
+
];
|
63 |
+
|
64 |
+
export const getPopularBots = () => CHATBOTS.filter((bot) => bot.popular);
|
65 |
+
export const getTrendingBots = () => CHATBOTS.filter((bot) => bot.trending);
|
66 |
+
export const getAllBots = () => CHATBOTS;
|
app/(audiences)/for-teachers/page.tsx
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import { Suspense } from "react";
|
5 |
+
import ChatBotGrid from "@/app/(audiences)/components/chat-bot-grid";
|
6 |
+
import { SubjectFilter } from "@/app/(audiences)/components/subject-filter";
|
7 |
+
import SearchBar from "@/app/(audiences)/components/search-bar";
|
8 |
+
import ChatBotGridSkeleton from "@/app/(audiences)/components/chat-bot-grid-skeleton";
|
9 |
+
import { getPopularBots, getTrendingBots, getAllBots } from "./data/chatbots";
|
10 |
+
|
11 |
+
export default function ForTeachersPage() {
|
12 |
+
const [selectedCategories, setSelectedCategories] = React.useState<string[]>(
|
13 |
+
[],
|
14 |
+
);
|
15 |
+
const [selectedSubjects, setSelectedSubjects] = React.useState<string[]>([]);
|
16 |
+
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
17 |
+
|
18 |
+
const handleSearch = (query: string) => {
|
19 |
+
setSearchQuery(query);
|
20 |
+
};
|
21 |
+
|
22 |
+
return (
|
23 |
+
<main className="min-h-screen bg-background-primary">
|
24 |
+
{/* Hero Section */}
|
25 |
+
<section className="mb-12 pt-16 text-center">
|
26 |
+
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
27 |
+
PlayGO for Teachers
|
28 |
+
</h1>
|
29 |
+
<p className="mx-auto max-w-2xl text-lg text-text-secondary">
|
30 |
+
探索適合教學的 AI 聊天機器人,提升教學效率!
|
31 |
+
</p>
|
32 |
+
</section>
|
33 |
+
|
34 |
+
{/* Search and Filter Section */}
|
35 |
+
<div className="container mx-auto px-4">
|
36 |
+
<div className="mb-8">
|
37 |
+
<SearchBar onSearch={handleSearch} />
|
38 |
+
</div>
|
39 |
+
<div className="mb-12">
|
40 |
+
<SubjectFilter
|
41 |
+
selectedCategories={selectedCategories}
|
42 |
+
setSelectedCategories={setSelectedCategories}
|
43 |
+
selectedSubjects={selectedSubjects}
|
44 |
+
setSelectedSubjects={setSelectedSubjects}
|
45 |
+
/>
|
46 |
+
</div>
|
47 |
+
|
48 |
+
{/* Chatbots Grid */}
|
49 |
+
<Suspense fallback={<ChatBotGridSkeleton />}>
|
50 |
+
<ChatBotGrid
|
51 |
+
selectedCategories={selectedCategories}
|
52 |
+
selectedSubjects={selectedSubjects}
|
53 |
+
searchQuery={searchQuery}
|
54 |
+
getPopularBots={getPopularBots}
|
55 |
+
getTrendingBots={getTrendingBots}
|
56 |
+
getAllBots={getAllBots}
|
57 |
+
/>
|
58 |
+
</Suspense>
|
59 |
+
</div>
|
60 |
+
</main>
|
61 |
+
);
|
62 |
+
}
|
app/components/chatbot/chat-bot-card.tsx
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
// Move the existing ChatBotCard component here with no changes needed
|
2 |
+
// This is already well-structured as a reusable component
|
app/components/chatbot/chat-bot-grid.tsx
ADDED
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import { useState, useEffect } from "react";
|
4 |
+
import ChatBotCard from "@/app/(audiences)/components/chat-bot-card";
|
5 |
+
import { ChatBot } from "@/types/chatbot";
|
6 |
+
|
7 |
+
interface SectionProps {
|
8 |
+
title: string;
|
9 |
+
subtitle: string;
|
10 |
+
bots: ChatBot[];
|
11 |
+
columns?: number;
|
12 |
+
}
|
13 |
+
|
14 |
+
function Section({ title, subtitle, bots, columns = 4 }: SectionProps) {
|
15 |
+
if (bots.length === 0) return null;
|
16 |
+
|
17 |
+
return (
|
18 |
+
<div className="mb-16">
|
19 |
+
<div className="mb-8">
|
20 |
+
<h2 className="text-2xl font-bold text-text-primary mb-2">{title}</h2>
|
21 |
+
<p className="text-text-secondary">{subtitle}</p>
|
22 |
+
</div>
|
23 |
+
<div className={`grid gap-4 sm:grid-cols-2 lg:grid-cols-${columns}`}>
|
24 |
+
{bots.map((chatbot) => (
|
25 |
+
<ChatBotCard key={chatbot.id} chatbot={chatbot} />
|
26 |
+
))}
|
27 |
+
</div>
|
28 |
+
</div>
|
29 |
+
);
|
30 |
+
}
|
31 |
+
|
32 |
+
interface ChatBotGridProps {
|
33 |
+
selectedCategories: string[];
|
34 |
+
selectedSubjects: string[];
|
35 |
+
searchQuery: string;
|
36 |
+
getPopularBots: () => ChatBot[];
|
37 |
+
getTrendingBots: () => ChatBot[];
|
38 |
+
getAllBots: () => ChatBot[];
|
39 |
+
}
|
40 |
+
|
41 |
+
export default function ChatBotGrid({
|
42 |
+
selectedCategories,
|
43 |
+
selectedSubjects,
|
44 |
+
searchQuery,
|
45 |
+
getPopularBots,
|
46 |
+
getTrendingBots,
|
47 |
+
getAllBots,
|
48 |
+
}: ChatBotGridProps) {
|
49 |
+
const [filteredPopularBots, setFilteredPopularBots] =
|
50 |
+
useState<ChatBot[]>(getPopularBots());
|
51 |
+
const [filteredTrendingBots, setFilteredTrendingBots] =
|
52 |
+
useState<ChatBot[]>(getTrendingBots());
|
53 |
+
const [filteredAllBots, setFilteredAllBots] =
|
54 |
+
useState<ChatBot[]>(getAllBots());
|
55 |
+
|
56 |
+
useEffect(() => {
|
57 |
+
const filterBots = (bots: ChatBot[]) => {
|
58 |
+
let filtered = bots;
|
59 |
+
|
60 |
+
if (searchQuery) {
|
61 |
+
const query = searchQuery.toLowerCase();
|
62 |
+
filtered = filtered.filter(
|
63 |
+
(bot) =>
|
64 |
+
bot.title.toLowerCase().includes(query) ||
|
65 |
+
bot.description.toLowerCase().includes(query) ||
|
66 |
+
bot.subject.toLowerCase().includes(query) ||
|
67 |
+
bot.category.toLowerCase().includes(query),
|
68 |
+
);
|
69 |
+
}
|
70 |
+
|
71 |
+
if (selectedCategories.length > 0) {
|
72 |
+
filtered = filtered.filter(
|
73 |
+
(bot) => bot.category && selectedCategories.includes(bot.category),
|
74 |
+
);
|
75 |
+
}
|
76 |
+
|
77 |
+
if (selectedSubjects.length > 0) {
|
78 |
+
filtered = filtered.filter((bot) =>
|
79 |
+
selectedSubjects.includes(bot.subject),
|
80 |
+
);
|
81 |
+
}
|
82 |
+
|
83 |
+
return filtered;
|
84 |
+
};
|
85 |
+
|
86 |
+
setFilteredPopularBots(filterBots(getPopularBots()));
|
87 |
+
setFilteredTrendingBots(filterBots(getTrendingBots()));
|
88 |
+
setFilteredAllBots(filterBots(getAllBots()));
|
89 |
+
}, [
|
90 |
+
selectedCategories,
|
91 |
+
selectedSubjects,
|
92 |
+
searchQuery,
|
93 |
+
getPopularBots,
|
94 |
+
getTrendingBots,
|
95 |
+
getAllBots,
|
96 |
+
]);
|
97 |
+
|
98 |
+
const hasFiltersOrSearch =
|
99 |
+
selectedCategories.length > 0 || selectedSubjects.length > 0 || searchQuery;
|
100 |
+
|
101 |
+
if (hasFiltersOrSearch) {
|
102 |
+
return (
|
103 |
+
<Section
|
104 |
+
title="搜尋結果"
|
105 |
+
subtitle={`符合 ${searchQuery ? '"' + searchQuery + '" ' : ""}的工具`}
|
106 |
+
bots={filteredAllBots}
|
107 |
+
columns={4}
|
108 |
+
/>
|
109 |
+
);
|
110 |
+
}
|
111 |
+
|
112 |
+
const tutorBots = filteredAllBots.filter((bot) => bot.category === "教育");
|
113 |
+
const teachingBots = filteredAllBots.filter((bot) => bot.subject === "教學");
|
114 |
+
const assessmentBots = filteredAllBots.filter(
|
115 |
+
(bot) => bot.subject === "評量",
|
116 |
+
);
|
117 |
+
|
118 |
+
return (
|
119 |
+
<div className="space-y-8">
|
120 |
+
<Section
|
121 |
+
title="精選"
|
122 |
+
subtitle="歷來最多人使用的工具"
|
123 |
+
bots={filteredPopularBots}
|
124 |
+
columns={4}
|
125 |
+
/>
|
126 |
+
<Section
|
127 |
+
title="熱門"
|
128 |
+
subtitle="近期最多人使用的工具"
|
129 |
+
bots={filteredTrendingBots}
|
130 |
+
columns={4}
|
131 |
+
/>
|
132 |
+
<Section
|
133 |
+
title="教學輔助"
|
134 |
+
subtitle="教學規劃與課程設計工具"
|
135 |
+
bots={teachingBots}
|
136 |
+
columns={4}
|
137 |
+
/>
|
138 |
+
<Section
|
139 |
+
title="評量工具"
|
140 |
+
subtitle="作業與評量相關工具"
|
141 |
+
bots={assessmentBots}
|
142 |
+
columns={4}
|
143 |
+
/>
|
144 |
+
<Section
|
145 |
+
title="教育資源"
|
146 |
+
subtitle="其他教育相關工具"
|
147 |
+
bots={tutorBots}
|
148 |
+
columns={4}
|
149 |
+
/>
|
150 |
+
</div>
|
151 |
+
);
|
152 |
+
}
|
components/custom/LandingPageChatBot.tsx → app/components/landing-page-chat-bot.tsx
RENAMED
@@ -1,97 +1,111 @@
|
|
1 |
-
|
2 |
|
3 |
-
import { useState, useRef, useEffect } from
|
4 |
-
import { useChat } from
|
5 |
import {
|
6 |
AcademicCapIcon,
|
7 |
ClockIcon,
|
8 |
BookOpenIcon,
|
9 |
LightBulbIcon,
|
10 |
BriefcaseIcon,
|
11 |
-
} from
|
12 |
|
13 |
type MessageWithLoading = {
|
14 |
content: string;
|
15 |
role: string;
|
16 |
isStreaming?: boolean;
|
17 |
-
}
|
18 |
|
19 |
const EXAMPLE_PROMPTS = [
|
20 |
{
|
21 |
title: "教案規劃",
|
22 |
prompt: "我想規劃一堂關於數學的課程",
|
23 |
-
icon: AcademicCapIcon
|
24 |
},
|
25 |
{
|
26 |
title: "英語對話",
|
27 |
prompt: "我想練習英語對話",
|
28 |
-
icon: ClockIcon
|
29 |
},
|
30 |
{
|
31 |
title: "作業批改",
|
32 |
prompt: "我想批改學生的作文作業",
|
33 |
-
icon: BookOpenIcon
|
34 |
},
|
35 |
{
|
36 |
title: "數學解題",
|
37 |
prompt: "我想解數學題目",
|
38 |
-
icon: LightBulbIcon
|
39 |
},
|
40 |
{
|
41 |
title: "我想學程式",
|
42 |
prompt: "我想學習如何寫程式",
|
43 |
-
icon: BriefcaseIcon
|
44 |
-
}
|
45 |
-
]
|
46 |
|
47 |
export default function LandingPageChatBot() {
|
48 |
-
const {
|
49 |
-
|
50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
onError: (error) => {
|
52 |
-
console.error(
|
53 |
},
|
54 |
onFinish: (message) => {
|
55 |
-
console.log(
|
56 |
-
setMessages(prev =>
|
|
|
|
|
57 |
},
|
58 |
keepLastMessageOnError: true,
|
59 |
-
})
|
60 |
|
61 |
-
const chatContainerRef = useRef<HTMLDivElement>(null)
|
62 |
-
const [hasInteracted, setHasInteracted] = useState(false)
|
63 |
-
const [messages, setMessages] = useState<MessageWithLoading[]>([])
|
64 |
|
65 |
useEffect(() => {
|
66 |
-
setMessages(
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
|
|
|
|
|
|
|
|
|
|
71 |
|
72 |
const scrollToBottom = () => {
|
73 |
if (chatContainerRef.current) {
|
74 |
-
const { scrollHeight, clientHeight } = chatContainerRef.current
|
75 |
-
chatContainerRef.current.scrollTop = scrollHeight - clientHeight
|
76 |
}
|
77 |
-
}
|
78 |
|
79 |
useEffect(() => {
|
80 |
-
scrollToBottom()
|
81 |
-
}, [messages])
|
82 |
|
83 |
const sendMessage = async (text: string) => {
|
84 |
-
setHasInteracted(true)
|
85 |
await append({
|
86 |
content: text,
|
87 |
-
role:
|
88 |
-
})
|
89 |
-
}
|
90 |
|
91 |
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
92 |
-
setHasInteracted(true)
|
93 |
-
handleSubmit(e)
|
94 |
-
}
|
95 |
|
96 |
return (
|
97 |
<section className="w-full h-[1000px] bg-gradient-to-b from-background-secondary to-background-primary flex items-center justify-center px-4">
|
@@ -132,12 +146,12 @@ export default function LandingPageChatBot() {
|
|
132 |
viewBox="0 0 24 24"
|
133 |
strokeWidth="1.5"
|
134 |
stroke="currentColor"
|
135 |
-
className={`size-6 ${isLoading ?
|
136 |
>
|
137 |
<path
|
138 |
strokeLinecap="round"
|
139 |
strokeLinejoin="round"
|
140 |
-
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
|
141 |
/>
|
142 |
</svg>
|
143 |
</button>
|
@@ -168,7 +182,11 @@ export default function LandingPageChatBot() {
|
|
168 |
viewBox="0 0 24 24"
|
169 |
strokeWidth={2}
|
170 |
>
|
171 |
-
<path
|
|
|
|
|
|
|
|
|
172 |
</svg>
|
173 |
</div>
|
174 |
</button>
|
@@ -179,7 +197,9 @@ export default function LandingPageChatBot() {
|
|
179 |
) : (
|
180 |
<div className="bg-background-primary/50 backdrop-blur-sm rounded-2xl shadow-lg border border-border/50 overflow-hidden w-full h-[700px] flex flex-col">
|
181 |
<div className="p-6 border-b border-border/50 bg-background-secondary/30">
|
182 |
-
<h2 className="text-xl font-semibold text-text-primary">
|
|
|
|
|
183 |
</div>
|
184 |
|
185 |
<div
|
@@ -189,13 +209,13 @@ export default function LandingPageChatBot() {
|
|
189 |
{messages.map((message, index) => (
|
190 |
<div
|
191 |
key={index}
|
192 |
-
className={`flex ${message.role ===
|
193 |
>
|
194 |
<div
|
195 |
className={`max-w-[80%] p-4 rounded-2xl shadow-sm ${
|
196 |
-
message.role ===
|
197 |
-
?
|
198 |
-
:
|
199 |
}`}
|
200 |
>
|
201 |
{message.content}
|
@@ -214,7 +234,9 @@ export default function LandingPageChatBot() {
|
|
214 |
type="text"
|
215 |
value={input}
|
216 |
onChange={handleInputChange}
|
217 |
-
placeholder={
|
|
|
|
|
218 |
disabled={isLoading}
|
219 |
className="w-full p-4 pr-32 rounded-xl border border-border/50 bg-background-primary/50
|
220 |
backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary/50
|
@@ -228,18 +250,18 @@ export default function LandingPageChatBot() {
|
|
228 |
shadow-md hover:shadow-lg active:scale-95 disabled:opacity-50
|
229 |
disabled:hover:bg-primary disabled:cursor-not-allowed"
|
230 |
>
|
231 |
-
<svg
|
232 |
-
xmlns="http://www.w3.org/2000/svg"
|
233 |
-
fill="none"
|
234 |
-
viewBox="0 0 24 24"
|
235 |
-
strokeWidth="1.5"
|
236 |
-
stroke="currentColor"
|
237 |
-
className={`size-5 ${isLoading ?
|
238 |
>
|
239 |
-
<path
|
240 |
-
strokeLinecap="round"
|
241 |
-
strokeLinejoin="round"
|
242 |
-
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
|
243 |
/>
|
244 |
</svg>
|
245 |
</button>
|
@@ -250,5 +272,5 @@ export default function LandingPageChatBot() {
|
|
250 |
)}
|
251 |
</div>
|
252 |
</section>
|
253 |
-
)
|
254 |
-
}
|
|
|
1 |
+
"use client";
|
2 |
|
3 |
+
import { useState, useRef, useEffect } from "react";
|
4 |
+
import { useChat } from "ai/react";
|
5 |
import {
|
6 |
AcademicCapIcon,
|
7 |
ClockIcon,
|
8 |
BookOpenIcon,
|
9 |
LightBulbIcon,
|
10 |
BriefcaseIcon,
|
11 |
+
} from "@heroicons/react/24/outline";
|
12 |
|
13 |
type MessageWithLoading = {
|
14 |
content: string;
|
15 |
role: string;
|
16 |
isStreaming?: boolean;
|
17 |
+
};
|
18 |
|
19 |
const EXAMPLE_PROMPTS = [
|
20 |
{
|
21 |
title: "教案規劃",
|
22 |
prompt: "我想規劃一堂關於數學的課程",
|
23 |
+
icon: AcademicCapIcon,
|
24 |
},
|
25 |
{
|
26 |
title: "英語對話",
|
27 |
prompt: "我想練習英語對話",
|
28 |
+
icon: ClockIcon,
|
29 |
},
|
30 |
{
|
31 |
title: "作業批改",
|
32 |
prompt: "我想批改學生的作文作業",
|
33 |
+
icon: BookOpenIcon,
|
34 |
},
|
35 |
{
|
36 |
title: "數學解題",
|
37 |
prompt: "我想解數學題目",
|
38 |
+
icon: LightBulbIcon,
|
39 |
},
|
40 |
{
|
41 |
title: "我想學程式",
|
42 |
prompt: "我想學習如何寫程式",
|
43 |
+
icon: BriefcaseIcon,
|
44 |
+
},
|
45 |
+
];
|
46 |
|
47 |
export default function LandingPageChatBot() {
|
48 |
+
const {
|
49 |
+
messages: rawMessages,
|
50 |
+
input,
|
51 |
+
handleInputChange,
|
52 |
+
handleSubmit,
|
53 |
+
isLoading,
|
54 |
+
append,
|
55 |
+
} = useChat({
|
56 |
+
api: "/api/landing_page_chat",
|
57 |
+
streamProtocol: "data",
|
58 |
onError: (error) => {
|
59 |
+
console.error("Chat error:", error);
|
60 |
},
|
61 |
onFinish: (message) => {
|
62 |
+
console.log("Chat finished:", message);
|
63 |
+
setMessages((prev) =>
|
64 |
+
prev.map((msg) => ({ ...msg, isStreaming: false })),
|
65 |
+
);
|
66 |
},
|
67 |
keepLastMessageOnError: true,
|
68 |
+
});
|
69 |
|
70 |
+
const chatContainerRef = useRef<HTMLDivElement>(null);
|
71 |
+
const [hasInteracted, setHasInteracted] = useState(false);
|
72 |
+
const [messages, setMessages] = useState<MessageWithLoading[]>([]);
|
73 |
|
74 |
useEffect(() => {
|
75 |
+
setMessages(
|
76 |
+
rawMessages.map((msg, index) => ({
|
77 |
+
...msg,
|
78 |
+
isStreaming:
|
79 |
+
isLoading &&
|
80 |
+
index === rawMessages.length - 1 &&
|
81 |
+
msg.role === "assistant",
|
82 |
+
})),
|
83 |
+
);
|
84 |
+
}, [rawMessages, isLoading]);
|
85 |
|
86 |
const scrollToBottom = () => {
|
87 |
if (chatContainerRef.current) {
|
88 |
+
const { scrollHeight, clientHeight } = chatContainerRef.current;
|
89 |
+
chatContainerRef.current.scrollTop = scrollHeight - clientHeight;
|
90 |
}
|
91 |
+
};
|
92 |
|
93 |
useEffect(() => {
|
94 |
+
scrollToBottom();
|
95 |
+
}, [messages]);
|
96 |
|
97 |
const sendMessage = async (text: string) => {
|
98 |
+
setHasInteracted(true);
|
99 |
await append({
|
100 |
content: text,
|
101 |
+
role: "user",
|
102 |
+
});
|
103 |
+
};
|
104 |
|
105 |
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
106 |
+
setHasInteracted(true);
|
107 |
+
handleSubmit(e);
|
108 |
+
};
|
109 |
|
110 |
return (
|
111 |
<section className="w-full h-[1000px] bg-gradient-to-b from-background-secondary to-background-primary flex items-center justify-center px-4">
|
|
|
146 |
viewBox="0 0 24 24"
|
147 |
strokeWidth="1.5"
|
148 |
stroke="currentColor"
|
149 |
+
className={`size-6 ${isLoading ? "animate-pulse" : ""}`}
|
150 |
>
|
151 |
<path
|
152 |
strokeLinecap="round"
|
153 |
strokeLinejoin="round"
|
154 |
+
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
|
155 |
/>
|
156 |
</svg>
|
157 |
</button>
|
|
|
182 |
viewBox="0 0 24 24"
|
183 |
strokeWidth={2}
|
184 |
>
|
185 |
+
<path
|
186 |
+
strokeLinecap="round"
|
187 |
+
strokeLinejoin="round"
|
188 |
+
d="M9 5l7 7-7 7"
|
189 |
+
/>
|
190 |
</svg>
|
191 |
</div>
|
192 |
</button>
|
|
|
197 |
) : (
|
198 |
<div className="bg-background-primary/50 backdrop-blur-sm rounded-2xl shadow-lg border border-border/50 overflow-hidden w-full h-[700px] flex flex-col">
|
199 |
<div className="p-6 border-b border-border/50 bg-background-secondary/30">
|
200 |
+
<h2 className="text-xl font-semibold text-text-primary">
|
201 |
+
PlayGO 導覽員
|
202 |
+
</h2>
|
203 |
</div>
|
204 |
|
205 |
<div
|
|
|
209 |
{messages.map((message, index) => (
|
210 |
<div
|
211 |
key={index}
|
212 |
+
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
213 |
>
|
214 |
<div
|
215 |
className={`max-w-[80%] p-4 rounded-2xl shadow-sm ${
|
216 |
+
message.role === "user"
|
217 |
+
? "bg-primary text-white rounded-br-none"
|
218 |
+
: "bg-background-secondary/50 text-text-primary rounded-bl-none"
|
219 |
}`}
|
220 |
>
|
221 |
{message.content}
|
|
|
234 |
type="text"
|
235 |
value={input}
|
236 |
onChange={handleInputChange}
|
237 |
+
placeholder={
|
238 |
+
isLoading ? "AI is thinking..." : "Type your message..."
|
239 |
+
}
|
240 |
disabled={isLoading}
|
241 |
className="w-full p-4 pr-32 rounded-xl border border-border/50 bg-background-primary/50
|
242 |
backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary/50
|
|
|
250 |
shadow-md hover:shadow-lg active:scale-95 disabled:opacity-50
|
251 |
disabled:hover:bg-primary disabled:cursor-not-allowed"
|
252 |
>
|
253 |
+
<svg
|
254 |
+
xmlns="http://www.w3.org/2000/svg"
|
255 |
+
fill="none"
|
256 |
+
viewBox="0 0 24 24"
|
257 |
+
strokeWidth="1.5"
|
258 |
+
stroke="currentColor"
|
259 |
+
className={`size-5 ${isLoading ? "animate-pulse" : ""}`}
|
260 |
>
|
261 |
+
<path
|
262 |
+
strokeLinecap="round"
|
263 |
+
strokeLinejoin="round"
|
264 |
+
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
|
265 |
/>
|
266 |
</svg>
|
267 |
</button>
|
|
|
272 |
)}
|
273 |
</div>
|
274 |
</section>
|
275 |
+
);
|
276 |
+
}
|
app/components/theme-provider.tsx
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
5 |
+
|
6 |
+
/**
|
7 |
+
* Custom ThemeProvider component that wraps next-themes provider
|
8 |
+
* with hydration mismatch prevention.
|
9 |
+
*
|
10 |
+
* This component ensures theme switching functionality works correctly
|
11 |
+
* by only rendering after initial client-side mount to prevent
|
12 |
+
* hydration mismatches between server and client rendering.
|
13 |
+
*
|
14 |
+
* @param children - Child components to be wrapped by the theme provider
|
15 |
+
* @param props - Additional props passed to NextThemesProvider
|
16 |
+
*/
|
17 |
+
export function ThemeProvider({
|
18 |
+
children,
|
19 |
+
...props
|
20 |
+
}: React.ComponentProps<typeof NextThemesProvider>) {
|
21 |
+
// Track whether component has mounted on client-side
|
22 |
+
const [mounted, setMounted] = React.useState(false);
|
23 |
+
|
24 |
+
// Set mounted state to true after initial client-side render
|
25 |
+
// This ensures we only render content after hydration is complete
|
26 |
+
React.useEffect(() => {
|
27 |
+
setMounted(true);
|
28 |
+
}, []);
|
29 |
+
|
30 |
+
// Return null on server-side and initial client-side render
|
31 |
+
// to prevent hydration mismatch with theme preferences
|
32 |
+
if (!mounted) {
|
33 |
+
return null;
|
34 |
+
}
|
35 |
+
|
36 |
+
// Once mounted, render the theme provider with children
|
37 |
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
38 |
+
}
|
app/components/top-nav.tsx
ADDED
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import { Moon, Sun } from "lucide-react";
|
5 |
+
import { useTheme } from "next-themes";
|
6 |
+
import Link from "next/link";
|
7 |
+
import { Button } from "@/components/ui/button";
|
8 |
+
import {
|
9 |
+
DropdownMenu,
|
10 |
+
DropdownMenuContent,
|
11 |
+
DropdownMenuItem,
|
12 |
+
DropdownMenuTrigger,
|
13 |
+
} from "@/components/ui/dropdown-menu";
|
14 |
+
|
15 |
+
/**
|
16 |
+
* TopNav - A responsive navigation header component with theme switching functionality
|
17 |
+
* Features:
|
18 |
+
* - Colorful brand logo/text
|
19 |
+
* - Navigation links for teachers and students
|
20 |
+
* - Theme switcher (light/dark/system) with animated icons
|
21 |
+
* - Client-side hydration handling
|
22 |
+
*/
|
23 |
+
export function TopNav() {
|
24 |
+
const { setTheme } = useTheme();
|
25 |
+
// Track component mounting to prevent hydration mismatch
|
26 |
+
const [mounted, setMounted] = React.useState(false);
|
27 |
+
|
28 |
+
// Enable theme switching only after client-side hydration
|
29 |
+
React.useEffect(() => {
|
30 |
+
setMounted(true);
|
31 |
+
}, []);
|
32 |
+
|
33 |
+
// Pre-hydration render - shows a simplified version without theme toggle
|
34 |
+
if (!mounted) {
|
35 |
+
return (
|
36 |
+
<header className="fixed w-full bg-background-secondary/80 backdrop-blur-sm border-b border-border z-50">
|
37 |
+
<div className="container mx-auto px-4 py-4 flex items-center">
|
38 |
+
<Link href="/" className="text-3xl font-bold">
|
39 |
+
<span className="text-[#FF6B6B]">P</span>
|
40 |
+
<span className="text-[#4ECDC4]">l</span>
|
41 |
+
<span className="text-[#45B7D1]">a</span>
|
42 |
+
<span className="text-[#FDCB6E]">y</span>
|
43 |
+
<span className="text-[#FF6B6B]">G</span>
|
44 |
+
<span className="text-[#4ECDC4]">o</span>
|
45 |
+
<span className="ml-2 text-[#45B7D1]">A</span>
|
46 |
+
<span className="text-[#FDCB6E]">I</span>
|
47 |
+
</Link>
|
48 |
+
<nav className="flex items-center justify-center flex-1 space-x-8">
|
49 |
+
<Link
|
50 |
+
href="/for-teachers"
|
51 |
+
className="text-text-secondary hover:text-primary"
|
52 |
+
>
|
53 |
+
我是老師
|
54 |
+
</Link>
|
55 |
+
<Link
|
56 |
+
href="/for-students"
|
57 |
+
className="text-text-secondary hover:text-primary"
|
58 |
+
>
|
59 |
+
我是學生
|
60 |
+
</Link>
|
61 |
+
</nav>
|
62 |
+
<div className="w-9 h-9" />{" "}
|
63 |
+
{/* Placeholder maintaining layout consistency during hydration */}
|
64 |
+
</div>
|
65 |
+
</header>
|
66 |
+
);
|
67 |
+
}
|
68 |
+
|
69 |
+
// Post-hydration render - includes full functionality with theme toggle
|
70 |
+
return (
|
71 |
+
<header className="fixed w-full bg-background-secondary/80 backdrop-blur-sm border-b border-border z-50">
|
72 |
+
<div className="container mx-auto px-4 py-4 flex items-center">
|
73 |
+
{/* Brand logo with multi-colored letters */}
|
74 |
+
<Link href="/" className="text-3xl font-bold">
|
75 |
+
<span className="text-[#FF6B6B]">P</span>
|
76 |
+
<span className="text-[#4ECDC4]">l</span>
|
77 |
+
<span className="text-[#45B7D1]">a</span>
|
78 |
+
<span className="text-[#FDCB6E]">y</span>
|
79 |
+
<span className="text-[#FF6B6B]">G</span>
|
80 |
+
<span className="text-[#4ECDC4]">o</span>
|
81 |
+
<span className="ml-2 text-[#45B7D1]">A</span>
|
82 |
+
<span className="text-[#FDCB6E]">I</span>
|
83 |
+
</Link>
|
84 |
+
|
85 |
+
{/* Navigation links with language-specific text (Traditional Chinese) */}
|
86 |
+
<nav className="flex items-center justify-center flex-1 space-x-8">
|
87 |
+
<Link
|
88 |
+
href="/for-teachers"
|
89 |
+
className="text-text-secondary hover:text-primary"
|
90 |
+
>
|
91 |
+
我是老師
|
92 |
+
</Link>
|
93 |
+
<Link
|
94 |
+
href="/for-students"
|
95 |
+
className="text-text-secondary hover:text-primary"
|
96 |
+
>
|
97 |
+
我是學生
|
98 |
+
</Link>
|
99 |
+
</nav>
|
100 |
+
|
101 |
+
{/* Theme switcher dropdown with animated sun/moon icons */}
|
102 |
+
<DropdownMenu>
|
103 |
+
<DropdownMenuTrigger asChild>
|
104 |
+
<Button>
|
105 |
+
{/* Animated sun/moon icons that rotate based on theme */}
|
106 |
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
107 |
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
108 |
+
<span className="sr-only">Toggle theme</span>
|
109 |
+
</Button>
|
110 |
+
</DropdownMenuTrigger>
|
111 |
+
{/* Theme selection menu */}
|
112 |
+
<DropdownMenuContent align="end">
|
113 |
+
<DropdownMenuItem onClick={() => setTheme("light")}>
|
114 |
+
Light
|
115 |
+
</DropdownMenuItem>
|
116 |
+
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
117 |
+
Dark
|
118 |
+
</DropdownMenuItem>
|
119 |
+
<DropdownMenuItem onClick={() => setTheme("system")}>
|
120 |
+
System
|
121 |
+
</DropdownMenuItem>
|
122 |
+
</DropdownMenuContent>
|
123 |
+
</DropdownMenu>
|
124 |
+
</div>
|
125 |
+
</header>
|
126 |
+
);
|
127 |
+
}
|
app/globals.css
CHANGED
@@ -5,24 +5,24 @@
|
|
5 |
@layer base {
|
6 |
:root {
|
7 |
--background: 0 0% 100%;
|
8 |
-
--foreground:
|
9 |
--card: 0 0% 100%;
|
10 |
-
--card-foreground:
|
11 |
--popover: 0 0% 100%;
|
12 |
-
--popover-foreground:
|
13 |
-
--primary:
|
14 |
-
--primary-foreground:
|
15 |
-
--secondary:
|
16 |
-
--secondary-foreground:
|
17 |
-
--muted:
|
18 |
-
--muted-foreground:
|
19 |
-
--accent:
|
20 |
-
--accent-foreground:
|
21 |
--destructive: 0 84.2% 60.2%;
|
22 |
-
--destructive-foreground:
|
23 |
-
--border:
|
24 |
-
--input:
|
25 |
-
--ring:
|
26 |
--radius: 1rem;
|
27 |
--chart-1: 12 76% 61%;
|
28 |
--chart-2: 173 58% 39%;
|
@@ -33,24 +33,24 @@
|
|
33 |
|
34 |
.dark {
|
35 |
--background: 20 14.3% 4.1%;
|
36 |
-
--foreground:
|
37 |
-
--card:
|
38 |
-
--card-foreground:
|
39 |
-
--popover:
|
40 |
-
--popover-foreground:
|
41 |
-
--primary:
|
42 |
-
--primary-foreground:
|
43 |
-
--secondary:
|
44 |
-
--secondary-foreground:
|
45 |
-
--muted:
|
46 |
-
--muted-foreground:
|
47 |
--accent: 12 6.5% 15.1%;
|
48 |
-
--accent-foreground:
|
49 |
-
--destructive: 0
|
50 |
-
--destructive-foreground:
|
51 |
-
--border:
|
52 |
-
--input:
|
53 |
-
--ring:
|
54 |
--chart-1: 220 70% 50%;
|
55 |
--chart-2: 160 60% 45%;
|
56 |
--chart-3: 30 80% 55%;
|
|
|
5 |
@layer base {
|
6 |
:root {
|
7 |
--background: 0 0% 100%;
|
8 |
+
--foreground: 240 10% 3.9%;
|
9 |
--card: 0 0% 100%;
|
10 |
+
--card-foreground: 240 10% 3.9%;
|
11 |
--popover: 0 0% 100%;
|
12 |
+
--popover-foreground: 240 10% 3.9%;
|
13 |
+
--primary: 351.3 94.5% 71.4%;
|
14 |
+
--primary-foreground: 355.7 100% 97.3%;
|
15 |
+
--secondary: 240 4.8% 95.9%;
|
16 |
+
--secondary-foreground: 240 5.9% 10%;
|
17 |
+
--muted: 240 4.8% 95.9%;
|
18 |
+
--muted-foreground: 240 3.8% 46.1%;
|
19 |
+
--accent: 240 4.8% 95.9%;
|
20 |
+
--accent-foreground: 240 5.9% 10%;
|
21 |
--destructive: 0 84.2% 60.2%;
|
22 |
+
--destructive-foreground: 0 0% 98%;
|
23 |
+
--border: 240 5.9% 90%;
|
24 |
+
--input: 240 5.9% 90%;
|
25 |
+
--ring: 351.3 94.5% 71.4%;
|
26 |
--radius: 1rem;
|
27 |
--chart-1: 12 76% 61%;
|
28 |
--chart-2: 173 58% 39%;
|
|
|
33 |
|
34 |
.dark {
|
35 |
--background: 20 14.3% 4.1%;
|
36 |
+
--foreground: 0 0% 95%;
|
37 |
+
--card: 24 9.8% 10%;
|
38 |
+
--card-foreground: 0 0% 95%;
|
39 |
+
--popover: 0 0% 9%;
|
40 |
+
--popover-foreground: 0 0% 95%;
|
41 |
+
--primary: 351.3 94.5% 71.4%;
|
42 |
+
--primary-foreground: 355.7 100% 97.3%;
|
43 |
+
--secondary: 240 3.7% 15.9%;
|
44 |
+
--secondary-foreground: 0 0% 98%;
|
45 |
+
--muted: 0 0% 15%;
|
46 |
+
--muted-foreground: 240 5% 64.9%;
|
47 |
--accent: 12 6.5% 15.1%;
|
48 |
+
--accent-foreground: 0 0% 98%;
|
49 |
+
--destructive: 0 62.8% 30.6%;
|
50 |
+
--destructive-foreground: 0 85.7% 97.3%;
|
51 |
+
--border: 240 3.7% 15.9%;
|
52 |
+
--input: 240 3.7% 15.9%;
|
53 |
+
--ring: 351.3 94.5% 71.4%;
|
54 |
--chart-1: 220 70% 50%;
|
55 |
--chart-2: 160 60% 45%;
|
56 |
--chart-3: 30 80% 55%;
|
app/layout.tsx
CHANGED
@@ -1,8 +1,8 @@
|
|
1 |
import type { Metadata } from "next";
|
2 |
import localFont from "next/font/local";
|
3 |
import "./globals.css";
|
4 |
-
import { ThemeProvider } from
|
5 |
-
import { TopNav } from
|
6 |
|
7 |
const geistSans = localFont({
|
8 |
src: "./fonts/GeistVF.woff",
|
@@ -12,16 +12,21 @@ const geistSans = localFont({
|
|
12 |
|
13 |
export const metadata: Metadata = {
|
14 |
title: "PlayGo AI - AI-Powered Learning Platform",
|
15 |
-
description:
|
|
|
16 |
};
|
17 |
|
18 |
export default function RootLayout({
|
19 |
children,
|
20 |
}: {
|
21 |
-
children: React.ReactNode
|
22 |
}) {
|
23 |
return (
|
24 |
-
<html
|
|
|
|
|
|
|
|
|
25 |
<body className="antialiased">
|
26 |
<ThemeProvider
|
27 |
attribute="class"
|
@@ -30,11 +35,9 @@ export default function RootLayout({
|
|
30 |
disableTransitionOnChange
|
31 |
>
|
32 |
<TopNav />
|
33 |
-
<main className="pt-14">
|
34 |
-
{children}
|
35 |
-
</main>
|
36 |
</ThemeProvider>
|
37 |
</body>
|
38 |
</html>
|
39 |
-
)
|
40 |
}
|
|
|
1 |
import type { Metadata } from "next";
|
2 |
import localFont from "next/font/local";
|
3 |
import "./globals.css";
|
4 |
+
import { ThemeProvider } from "@/app/components/theme-provider";
|
5 |
+
import { TopNav } from "./components/top-nav";
|
6 |
|
7 |
const geistSans = localFont({
|
8 |
src: "./fonts/GeistVF.woff",
|
|
|
12 |
|
13 |
export const metadata: Metadata = {
|
14 |
title: "PlayGo AI - AI-Powered Learning Platform",
|
15 |
+
description:
|
16 |
+
"Transform education with AI-powered interactive learning experiences",
|
17 |
};
|
18 |
|
19 |
export default function RootLayout({
|
20 |
children,
|
21 |
}: {
|
22 |
+
children: React.ReactNode;
|
23 |
}) {
|
24 |
return (
|
25 |
+
<html
|
26 |
+
lang="en"
|
27 |
+
suppressHydrationWarning
|
28 |
+
className={`${geistSans.variable}`}
|
29 |
+
>
|
30 |
<body className="antialiased">
|
31 |
<ThemeProvider
|
32 |
attribute="class"
|
|
|
35 |
disableTransitionOnChange
|
36 |
>
|
37 |
<TopNav />
|
38 |
+
<main className="pt-14">{children}</main>
|
|
|
|
|
39 |
</ThemeProvider>
|
40 |
</body>
|
41 |
</html>
|
42 |
+
);
|
43 |
}
|
app/page.tsx
CHANGED
@@ -1,68 +1,131 @@
|
|
1 |
-
|
2 |
|
3 |
-
import Link from "next/link"
|
4 |
-
import
|
5 |
|
|
|
|
|
|
|
|
|
|
|
6 |
export default function LandingPage() {
|
7 |
return (
|
8 |
<div className="min-h-screen bg-background-primary">
|
9 |
<main className="pt-4">
|
10 |
-
{/*
|
11 |
<div className="">
|
12 |
-
<
|
13 |
</div>
|
14 |
|
15 |
-
{/* Features Grid */}
|
16 |
<section className="bg-background-secondary py-20">
|
17 |
<div className="container mx-auto px-4">
|
18 |
-
<h2 className="text-3xl font-bold text-center mb-16">
|
|
|
|
|
19 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
20 |
<div className="p-6 rounded-xl bg-background-primary">
|
21 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
22 |
-
<svg
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
</svg>
|
25 |
</div>
|
26 |
-
<h3 className="text-xl font-semibold mb-2">
|
27 |
-
|
|
|
|
|
|
|
|
|
28 |
</div>
|
29 |
|
30 |
<div className="p-6 rounded-xl bg-background-primary">
|
31 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
32 |
-
<svg
|
33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
</svg>
|
35 |
</div>
|
36 |
<h3 className="text-xl font-semibold mb-2">Diverse Subjects</h3>
|
37 |
-
<p className="text-text-secondary">
|
|
|
|
|
38 |
</div>
|
39 |
|
40 |
<div className="p-6 rounded-xl bg-background-primary">
|
41 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
42 |
-
<svg
|
43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
</svg>
|
45 |
</div>
|
46 |
-
<h3 className="text-xl font-semibold mb-2">
|
47 |
-
|
|
|
|
|
|
|
|
|
48 |
</div>
|
49 |
|
50 |
<div className="p-6 rounded-xl bg-background-primary">
|
51 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
52 |
-
<svg
|
53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
</svg>
|
55 |
</div>
|
56 |
-
<h3 className="text-xl font-semibold mb-2">
|
57 |
-
|
|
|
|
|
|
|
|
|
58 |
</div>
|
59 |
</div>
|
60 |
</div>
|
61 |
</section>
|
62 |
|
63 |
-
{/*
|
64 |
<section className="container mx-auto px-4 py-20">
|
65 |
-
<h2 className="text-3xl font-bold text-center mb-16">
|
|
|
|
|
66 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
67 |
<div>
|
68 |
<p className="text-4xl font-bold text-primary mb-2">500K+</p>
|
@@ -84,9 +147,16 @@ export default function LandingPage() {
|
|
84 |
</section>
|
85 |
</main>
|
86 |
|
87 |
-
{/*
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
<footer className="bg-background-secondary border-t border-border py-12">
|
89 |
<div className="container mx-auto px-4">
|
|
|
90 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
91 |
<div className="text-center md:text-left">
|
92 |
<h3 className="text-2xl font-bold mb-4">
|
@@ -99,48 +169,110 @@ export default function LandingPage() {
|
|
99 |
<span className="ml-2 text-[#45B7D1]">A</span>
|
100 |
<span className="text-[#FDCB6E]">I</span>
|
101 |
</h3>
|
102 |
-
<p className="text-text-secondary">
|
|
|
|
|
103 |
</div>
|
104 |
|
|
|
105 |
<div className="text-center">
|
106 |
<h3 className="font-semibold mb-4">Learning Resources</h3>
|
107 |
<ul className="space-y-2">
|
108 |
-
<li
|
109 |
-
|
110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
111 |
</ul>
|
112 |
</div>
|
113 |
|
|
|
114 |
<div className="text-center md:text-right">
|
115 |
<h3 className="font-semibold mb-4">Connect With Us</h3>
|
116 |
<div className="flex justify-center md:justify-end space-x-4">
|
117 |
-
<a
|
118 |
-
|
119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
120 |
</svg>
|
121 |
</a>
|
122 |
-
<a
|
123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
|
125 |
</svg>
|
126 |
</a>
|
127 |
-
<a
|
128 |
-
|
129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
130 |
</svg>
|
131 |
</a>
|
132 |
</div>
|
133 |
<p className="mt-4 text-text-secondary">
|
134 |
-
Contact us:
|
|
|
|
|
|
|
|
|
|
|
|
|
135 |
</p>
|
136 |
</div>
|
137 |
</div>
|
|
|
|
|
138 |
<div className="mt-12 pt-8 border-t border-border/50 text-center text-text-secondary">
|
139 |
-
<p>
|
140 |
-
|
|
|
|
|
|
|
|
|
|
|
141 |
</div>
|
142 |
</div>
|
143 |
</footer>
|
144 |
</div>
|
145 |
-
)
|
146 |
-
}
|
|
|
1 |
+
"use client";
|
2 |
|
3 |
+
import Link from "next/link";
|
4 |
+
import LandingPageChatBot from "@/app/components/landing-page-chat-bot";
|
5 |
|
6 |
+
/**
|
7 |
+
* LandingPage Component
|
8 |
+
* Main landing page for PlayGo AI educational platform
|
9 |
+
* Features a chatbot, feature highlights, statistics, and footer
|
10 |
+
*/
|
11 |
export default function LandingPage() {
|
12 |
return (
|
13 |
<div className="min-h-screen bg-background-primary">
|
14 |
<main className="pt-4">
|
15 |
+
{/* Interactive ChatBot Section */}
|
16 |
<div className="">
|
17 |
+
<LandingPageChatBot />
|
18 |
</div>
|
19 |
|
20 |
+
{/* Features Grid Section - Displays 4 key platform features with icons */}
|
21 |
<section className="bg-background-secondary py-20">
|
22 |
<div className="container mx-auto px-4">
|
23 |
+
<h2 className="text-3xl font-bold text-center mb-16">
|
24 |
+
Build with our powerful learning tools
|
25 |
+
</h2>
|
26 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
27 |
<div className="p-6 rounded-xl bg-background-primary">
|
28 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
29 |
+
<svg
|
30 |
+
className="w-6 h-6 text-primary"
|
31 |
+
fill="none"
|
32 |
+
stroke="currentColor"
|
33 |
+
viewBox="0 0 24 24"
|
34 |
+
>
|
35 |
+
<path
|
36 |
+
strokeLinecap="round"
|
37 |
+
strokeLinejoin="round"
|
38 |
+
strokeWidth={2}
|
39 |
+
d="M13 10V3L4 14h7v7l9-11h-7z"
|
40 |
+
/>
|
41 |
</svg>
|
42 |
</div>
|
43 |
+
<h3 className="text-xl font-semibold mb-2">
|
44 |
+
Interactive Learning
|
45 |
+
</h3>
|
46 |
+
<p className="text-text-secondary">
|
47 |
+
Engage with AI-powered quizzes, projects, and virtual labs
|
48 |
+
</p>
|
49 |
</div>
|
50 |
|
51 |
<div className="p-6 rounded-xl bg-background-primary">
|
52 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
53 |
+
<svg
|
54 |
+
className="w-6 h-6 text-primary"
|
55 |
+
fill="none"
|
56 |
+
stroke="currentColor"
|
57 |
+
viewBox="0 0 24 24"
|
58 |
+
>
|
59 |
+
<path
|
60 |
+
strokeLinecap="round"
|
61 |
+
strokeLinejoin="round"
|
62 |
+
strokeWidth={2}
|
63 |
+
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
64 |
+
/>
|
65 |
</svg>
|
66 |
</div>
|
67 |
<h3 className="text-xl font-semibold mb-2">Diverse Subjects</h3>
|
68 |
+
<p className="text-text-secondary">
|
69 |
+
Explore topics from STEM to humanities with expert AI guidance
|
70 |
+
</p>
|
71 |
</div>
|
72 |
|
73 |
<div className="p-6 rounded-xl bg-background-primary">
|
74 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
75 |
+
<svg
|
76 |
+
className="w-6 h-6 text-primary"
|
77 |
+
fill="none"
|
78 |
+
stroke="currentColor"
|
79 |
+
viewBox="0 0 24 24"
|
80 |
+
>
|
81 |
+
<path
|
82 |
+
strokeLinecap="round"
|
83 |
+
strokeLinejoin="round"
|
84 |
+
strokeWidth={2}
|
85 |
+
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
86 |
+
/>
|
87 |
</svg>
|
88 |
</div>
|
89 |
+
<h3 className="text-xl font-semibold mb-2">
|
90 |
+
Personalized Path
|
91 |
+
</h3>
|
92 |
+
<p className="text-text-secondary">
|
93 |
+
Adaptive learning tailored to your unique pace and style
|
94 |
+
</p>
|
95 |
</div>
|
96 |
|
97 |
<div className="p-6 rounded-xl bg-background-primary">
|
98 |
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
99 |
+
<svg
|
100 |
+
className="w-6 h-6 text-primary"
|
101 |
+
fill="none"
|
102 |
+
stroke="currentColor"
|
103 |
+
viewBox="0 0 24 24"
|
104 |
+
>
|
105 |
+
<path
|
106 |
+
strokeLinecap="round"
|
107 |
+
strokeLinejoin="round"
|
108 |
+
strokeWidth={2}
|
109 |
+
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
110 |
+
/>
|
111 |
</svg>
|
112 |
</div>
|
113 |
+
<h3 className="text-xl font-semibold mb-2">
|
114 |
+
Progress Tracking
|
115 |
+
</h3>
|
116 |
+
<p className="text-text-secondary">
|
117 |
+
Monitor learning outcomes with detailed analytics
|
118 |
+
</p>
|
119 |
</div>
|
120 |
</div>
|
121 |
</div>
|
122 |
</section>
|
123 |
|
124 |
+
{/* Statistics Section - Displays key metrics about platform usage */}
|
125 |
<section className="container mx-auto px-4 py-20">
|
126 |
+
<h2 className="text-3xl font-bold text-center mb-16">
|
127 |
+
Trusted by educators worldwide
|
128 |
+
</h2>
|
129 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
130 |
<div>
|
131 |
<p className="text-4xl font-bold text-primary mb-2">500K+</p>
|
|
|
147 |
</section>
|
148 |
</main>
|
149 |
|
150 |
+
{/* Footer Section
|
151 |
+
* Contains:
|
152 |
+
* - Branded logo with playful multi-colored letters
|
153 |
+
* - Learning resource links
|
154 |
+
* - Social media links and contact information
|
155 |
+
* - Non-profit mission statement and copyright
|
156 |
+
*/}
|
157 |
<footer className="bg-background-secondary border-t border-border py-12">
|
158 |
<div className="container mx-auto px-4">
|
159 |
+
{/* Logo and Tagline */}
|
160 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
161 |
<div className="text-center md:text-left">
|
162 |
<h3 className="text-2xl font-bold mb-4">
|
|
|
169 |
<span className="ml-2 text-[#45B7D1]">A</span>
|
170 |
<span className="text-[#FDCB6E]">I</span>
|
171 |
</h3>
|
172 |
+
<p className="text-text-secondary">
|
173 |
+
Making learning fun and accessible for everyone
|
174 |
+
</p>
|
175 |
</div>
|
176 |
|
177 |
+
{/* Learning Resources Navigation */}
|
178 |
<div className="text-center">
|
179 |
<h3 className="font-semibold mb-4">Learning Resources</h3>
|
180 |
<ul className="space-y-2">
|
181 |
+
<li>
|
182 |
+
<Link
|
183 |
+
href="/library"
|
184 |
+
className="text-text-secondary hover:text-primary"
|
185 |
+
>
|
186 |
+
Learning Library
|
187 |
+
</Link>
|
188 |
+
</li>
|
189 |
+
<li>
|
190 |
+
<Link
|
191 |
+
href="/tutorials"
|
192 |
+
className="text-text-secondary hover:text-primary"
|
193 |
+
>
|
194 |
+
Tutorials
|
195 |
+
</Link>
|
196 |
+
</li>
|
197 |
+
<li>
|
198 |
+
<Link
|
199 |
+
href="/blog"
|
200 |
+
className="text-text-secondary hover:text-primary"
|
201 |
+
>
|
202 |
+
Educational Blog
|
203 |
+
</Link>
|
204 |
+
</li>
|
205 |
</ul>
|
206 |
</div>
|
207 |
|
208 |
+
{/* Social Media and Contact Information */}
|
209 |
<div className="text-center md:text-right">
|
210 |
<h3 className="font-semibold mb-4">Connect With Us</h3>
|
211 |
<div className="flex justify-center md:justify-end space-x-4">
|
212 |
+
<a
|
213 |
+
href="#"
|
214 |
+
className="text-text-secondary hover:text-primary"
|
215 |
+
aria-label="Discord"
|
216 |
+
>
|
217 |
+
<svg
|
218 |
+
className="w-6 h-6"
|
219 |
+
fill="currentColor"
|
220 |
+
viewBox="0 0 24 24"
|
221 |
+
>
|
222 |
+
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
|
223 |
</svg>
|
224 |
</a>
|
225 |
+
<a
|
226 |
+
href="#"
|
227 |
+
className="text-text-secondary hover:text-primary"
|
228 |
+
aria-label="Twitter"
|
229 |
+
>
|
230 |
+
<svg
|
231 |
+
className="w-6 h-6"
|
232 |
+
fill="currentColor"
|
233 |
+
viewBox="0 0 24 24"
|
234 |
+
>
|
235 |
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
|
236 |
</svg>
|
237 |
</a>
|
238 |
+
<a
|
239 |
+
href="#"
|
240 |
+
className="text-text-secondary hover:text-primary"
|
241 |
+
aria-label="YouTube"
|
242 |
+
>
|
243 |
+
<svg
|
244 |
+
className="w-6 h-6"
|
245 |
+
fill="currentColor"
|
246 |
+
viewBox="0 0 24 24"
|
247 |
+
>
|
248 |
+
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
249 |
</svg>
|
250 |
</a>
|
251 |
</div>
|
252 |
<p className="mt-4 text-text-secondary">
|
253 |
+
Contact us:{" "}
|
254 |
+
<a
|
255 |
+
href="mailto:hello@playgoai.org"
|
256 |
+
className="hover:text-primary"
|
257 |
+
>
|
258 |
+
hello@playgoai.org
|
259 |
+
</a>
|
260 |
</p>
|
261 |
</div>
|
262 |
</div>
|
263 |
+
|
264 |
+
{/* Non-profit Mission and Copyright */}
|
265 |
<div className="mt-12 pt-8 border-t border-border/50 text-center text-text-secondary">
|
266 |
+
<p>
|
267 |
+
PlayGo AI - A non-profit organization dedicated to making
|
268 |
+
education accessible through AI
|
269 |
+
</p>
|
270 |
+
<p className="mt-2">
|
271 |
+
© {new Date().getFullYear()} PlayGo AI. All rights reserved.
|
272 |
+
</p>
|
273 |
</div>
|
274 |
</div>
|
275 |
</footer>
|
276 |
</div>
|
277 |
+
);
|
278 |
+
}
|
app/types/chatbot.ts
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface ChatBot {
|
2 |
+
id: string;
|
3 |
+
title: string;
|
4 |
+
description: string;
|
5 |
+
subject: string;
|
6 |
+
icon: string;
|
7 |
+
category: string;
|
8 |
+
popular?: boolean;
|
9 |
+
trending?: boolean;
|
10 |
+
}
|
components.json
CHANGED
@@ -18,4 +18,4 @@
|
|
18 |
"hooks": "@/hooks"
|
19 |
},
|
20 |
"iconLibrary": "lucide"
|
21 |
-
}
|
|
|
18 |
"hooks": "@/hooks"
|
19 |
},
|
20 |
"iconLibrary": "lucide"
|
21 |
+
}
|
components/custom/ChatBotCard.tsx
DELETED
@@ -1,43 +0,0 @@
|
|
1 |
-
import { Card, CardHeader, CardContent, CardFooter } from "@/components/ui/card"
|
2 |
-
|
3 |
-
interface ChatBotCardProps {
|
4 |
-
chatbot: {
|
5 |
-
id: string
|
6 |
-
title: string
|
7 |
-
description: string
|
8 |
-
subject: string
|
9 |
-
icon: string
|
10 |
-
author?: string
|
11 |
-
}
|
12 |
-
}
|
13 |
-
|
14 |
-
export default function ChatBotCard({ chatbot }: ChatBotCardProps) {
|
15 |
-
return (
|
16 |
-
<Card className="group relative overflow-hidden bg-background-primary/50 shadow-sm transition-all hover:shadow-md backdrop-blur-sm">
|
17 |
-
<CardHeader className="space-y-0">
|
18 |
-
<div className="flex items-center justify-between">
|
19 |
-
<div className="flex items-center gap-3">
|
20 |
-
<span className="text-3xl">{chatbot.icon}</span>
|
21 |
-
<h3 className="text-xl font-semibold text-text-primary">{chatbot.title}</h3>
|
22 |
-
</div>
|
23 |
-
<span className="rounded-full bg-background-secondary/50 px-3 py-1 text-sm text-text-secondary">
|
24 |
-
{chatbot.subject}
|
25 |
-
</span>
|
26 |
-
</div>
|
27 |
-
</CardHeader>
|
28 |
-
|
29 |
-
<CardContent>
|
30 |
-
<p className="text-text-secondary">{chatbot.description}</p>
|
31 |
-
{chatbot.author && (
|
32 |
-
<p className="mt-2 text-sm text-text-secondary">作者:{chatbot.author}</p>
|
33 |
-
)}
|
34 |
-
</CardContent>
|
35 |
-
|
36 |
-
<CardFooter>
|
37 |
-
<button className="w-full rounded-xl bg-primary px-4 py-2 text-white transition-colors hover:bg-primary/90">
|
38 |
-
開始對話
|
39 |
-
</button>
|
40 |
-
</CardFooter>
|
41 |
-
</Card>
|
42 |
-
)
|
43 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/custom/ChatBotGrid.tsx
DELETED
@@ -1,60 +0,0 @@
|
|
1 |
-
'use client'
|
2 |
-
|
3 |
-
import { useState } from 'react'
|
4 |
-
import ChatBotCard from './ChatBotCard'
|
5 |
-
|
6 |
-
interface ChatBot {
|
7 |
-
id: string
|
8 |
-
title: string
|
9 |
-
description: string
|
10 |
-
subject: string
|
11 |
-
icon: string
|
12 |
-
author?: string
|
13 |
-
}
|
14 |
-
|
15 |
-
const SAMPLE_CHATBOTS: ChatBot[] = [
|
16 |
-
{
|
17 |
-
id: '1',
|
18 |
-
title: 'Code Tutor',
|
19 |
-
description: "Let's code together! I'm Khanmigo Lite, by Khan Academy.",
|
20 |
-
subject: '程式設計',
|
21 |
-
icon: '👨💻',
|
22 |
-
author: 'khanacademy.org'
|
23 |
-
},
|
24 |
-
{
|
25 |
-
id: '2',
|
26 |
-
title: 'Whimsical Diagrams',
|
27 |
-
description: 'Explains and visualizes concepts with flowcharts, mindmaps and sequence diagrams.',
|
28 |
-
subject: '視覺化',
|
29 |
-
icon: '📊',
|
30 |
-
author: 'whimsical.com'
|
31 |
-
},
|
32 |
-
{
|
33 |
-
id: '3',
|
34 |
-
title: 'Resume',
|
35 |
-
description: 'By combining the expertise of top resume writers with advanced AI, we assist in diagnosing and improving resumes.',
|
36 |
-
subject: '求職',
|
37 |
-
icon: '📝',
|
38 |
-
author: 'jobright.ai'
|
39 |
-
},
|
40 |
-
{
|
41 |
-
id: '4',
|
42 |
-
title: 'Universal Primer',
|
43 |
-
description: 'The fastest way to learn anything hard.',
|
44 |
-
subject: '學習',
|
45 |
-
icon: '📚',
|
46 |
-
author: 'Siqi Chen'
|
47 |
-
}
|
48 |
-
]
|
49 |
-
|
50 |
-
export default function ChatBotGrid() {
|
51 |
-
const [chatbots] = useState<ChatBot[]>(SAMPLE_CHATBOTS)
|
52 |
-
|
53 |
-
return (
|
54 |
-
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-2">
|
55 |
-
{chatbots.map((chatbot) => (
|
56 |
-
<ChatBotCard key={chatbot.id} chatbot={chatbot} />
|
57 |
-
))}
|
58 |
-
</div>
|
59 |
-
)
|
60 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/custom/SubjectFilter.tsx
DELETED
@@ -1,19 +0,0 @@
|
|
1 |
-
'use client'
|
2 |
-
|
3 |
-
export default function SubjectFilter() {
|
4 |
-
const subjects = ['熱門精選', '寫作', '生產力', '研究與分析', '教育', '日常生活', '程式設計']
|
5 |
-
|
6 |
-
return (
|
7 |
-
<div className="flex flex-wrap gap-4">
|
8 |
-
{subjects.map((subject) => (
|
9 |
-
<button
|
10 |
-
key={subject}
|
11 |
-
className="rounded-full border border-border/50 px-6 py-2 text-base transition-colors
|
12 |
-
hover:bg-background-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/50"
|
13 |
-
>
|
14 |
-
{subject}
|
15 |
-
</button>
|
16 |
-
))}
|
17 |
-
</div>
|
18 |
-
)
|
19 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/custom/theme-provider.tsx
DELETED
@@ -1,22 +0,0 @@
|
|
1 |
-
"use client"
|
2 |
-
|
3 |
-
import * as React from "react"
|
4 |
-
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
5 |
-
|
6 |
-
export function ThemeProvider({
|
7 |
-
children,
|
8 |
-
...props
|
9 |
-
}: React.ComponentProps<typeof NextThemesProvider>) {
|
10 |
-
const [mounted, setMounted] = React.useState(false)
|
11 |
-
|
12 |
-
// Prevent hydration mismatch by only rendering after mount
|
13 |
-
React.useEffect(() => {
|
14 |
-
setMounted(true)
|
15 |
-
}, [])
|
16 |
-
|
17 |
-
if (!mounted) {
|
18 |
-
return null
|
19 |
-
}
|
20 |
-
|
21 |
-
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
22 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/custom/top-nav.tsx
DELETED
@@ -1,96 +0,0 @@
|
|
1 |
-
'use client'
|
2 |
-
|
3 |
-
import * as React from 'react'
|
4 |
-
import { Moon, Sun } from "lucide-react"
|
5 |
-
import { useTheme } from "next-themes"
|
6 |
-
import Link from 'next/link'
|
7 |
-
import { Button } from "@/components/ui/button"
|
8 |
-
import {
|
9 |
-
DropdownMenu,
|
10 |
-
DropdownMenuContent,
|
11 |
-
DropdownMenuItem,
|
12 |
-
DropdownMenuTrigger,
|
13 |
-
} from "@/components/ui/dropdown-menu"
|
14 |
-
|
15 |
-
export function TopNav() {
|
16 |
-
const { setTheme } = useTheme()
|
17 |
-
const [mounted, setMounted] = React.useState(false)
|
18 |
-
|
19 |
-
React.useEffect(() => {
|
20 |
-
setMounted(true)
|
21 |
-
}, [])
|
22 |
-
|
23 |
-
// Prevent theme toggle from showing before client-side hydration
|
24 |
-
if (!mounted) {
|
25 |
-
return (
|
26 |
-
<header className="fixed w-full bg-background-secondary/80 backdrop-blur-sm border-b border-border z-50">
|
27 |
-
<div className="container mx-auto px-4 py-4 flex items-center">
|
28 |
-
<Link href="/" className="text-3xl font-bold">
|
29 |
-
<span className="text-[#FF6B6B]">P</span>
|
30 |
-
<span className="text-[#4ECDC4]">l</span>
|
31 |
-
<span className="text-[#45B7D1]">a</span>
|
32 |
-
<span className="text-[#FDCB6E]">y</span>
|
33 |
-
<span className="text-[#FF6B6B]">G</span>
|
34 |
-
<span className="text-[#4ECDC4]">o</span>
|
35 |
-
<span className="ml-2 text-[#45B7D1]">A</span>
|
36 |
-
<span className="text-[#FDCB6E]">I</span>
|
37 |
-
</Link>
|
38 |
-
<nav className="flex items-center justify-center flex-1 space-x-8">
|
39 |
-
<Link href="/for-teachers" className="text-text-secondary hover:text-primary">
|
40 |
-
我是老師
|
41 |
-
</Link>
|
42 |
-
<Link href="/for-students" className="text-text-secondary hover:text-primary">
|
43 |
-
我是學生
|
44 |
-
</Link>
|
45 |
-
</nav>
|
46 |
-
<div className="w-9 h-9" /> {/* Placeholder for theme toggle */}
|
47 |
-
</div>
|
48 |
-
</header>
|
49 |
-
)
|
50 |
-
}
|
51 |
-
|
52 |
-
return (
|
53 |
-
<header className="fixed w-full bg-background-secondary/80 backdrop-blur-sm border-b border-border z-50">
|
54 |
-
<div className="container mx-auto px-4 py-4 flex items-center">
|
55 |
-
<Link href="/" className="text-3xl font-bold">
|
56 |
-
<span className="text-[#FF6B6B]">P</span>
|
57 |
-
<span className="text-[#4ECDC4]">l</span>
|
58 |
-
<span className="text-[#45B7D1]">a</span>
|
59 |
-
<span className="text-[#FDCB6E]">y</span>
|
60 |
-
<span className="text-[#FF6B6B]">G</span>
|
61 |
-
<span className="text-[#4ECDC4]">o</span>
|
62 |
-
<span className="ml-2 text-[#45B7D1]">A</span>
|
63 |
-
<span className="text-[#FDCB6E]">I</span>
|
64 |
-
</Link>
|
65 |
-
<nav className="flex items-center justify-center flex-1 space-x-8">
|
66 |
-
<Link href="/for-teachers" className="text-text-secondary hover:text-primary">
|
67 |
-
我是老師
|
68 |
-
</Link>
|
69 |
-
<Link href="/for-students" className="text-text-secondary hover:text-primary">
|
70 |
-
我是學生
|
71 |
-
</Link>
|
72 |
-
</nav>
|
73 |
-
<DropdownMenu>
|
74 |
-
<DropdownMenuTrigger asChild>
|
75 |
-
<Button>
|
76 |
-
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
77 |
-
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
78 |
-
<span className="sr-only">Toggle theme</span>
|
79 |
-
</Button>
|
80 |
-
</DropdownMenuTrigger>
|
81 |
-
<DropdownMenuContent align="end">
|
82 |
-
<DropdownMenuItem onClick={() => setTheme("light")}>
|
83 |
-
Light
|
84 |
-
</DropdownMenuItem>
|
85 |
-
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
86 |
-
Dark
|
87 |
-
</DropdownMenuItem>
|
88 |
-
<DropdownMenuItem onClick={() => setTheme("system")}>
|
89 |
-
System
|
90 |
-
</DropdownMenuItem>
|
91 |
-
</DropdownMenuContent>
|
92 |
-
</DropdownMenu>
|
93 |
-
</div>
|
94 |
-
</header>
|
95 |
-
)
|
96 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
components/ui/badge.tsx
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react";
|
2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils";
|
5 |
+
|
6 |
+
const badgeVariants = cva(
|
7 |
+
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
8 |
+
{
|
9 |
+
variants: {
|
10 |
+
variant: {
|
11 |
+
default:
|
12 |
+
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
13 |
+
secondary:
|
14 |
+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
15 |
+
destructive:
|
16 |
+
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
17 |
+
outline: "text-foreground",
|
18 |
+
},
|
19 |
+
},
|
20 |
+
defaultVariants: {
|
21 |
+
variant: "default",
|
22 |
+
},
|
23 |
+
},
|
24 |
+
);
|
25 |
+
|
26 |
+
export interface BadgeProps
|
27 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
28 |
+
VariantProps<typeof badgeVariants> {}
|
29 |
+
|
30 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
31 |
+
return (
|
32 |
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
33 |
+
);
|
34 |
+
}
|
35 |
+
|
36 |
+
export { Badge, badgeVariants };
|
components/ui/button.tsx
CHANGED
@@ -1,11 +1,11 @@
|
|
1 |
-
import * as React from "react"
|
2 |
-
import { Slot } from "@radix-ui/react-slot"
|
3 |
-
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
|
5 |
-
import { cn } from "@/lib/utils"
|
6 |
|
7 |
const buttonVariants = cva(
|
8 |
-
"inline-flex items-center justify-center
|
9 |
{
|
10 |
variants: {
|
11 |
variant: {
|
@@ -31,27 +31,27 @@ const buttonVariants = cva(
|
|
31 |
variant: "default",
|
32 |
size: "default",
|
33 |
},
|
34 |
-
}
|
35 |
-
)
|
36 |
|
37 |
export interface ButtonProps
|
38 |
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
39 |
VariantProps<typeof buttonVariants> {
|
40 |
-
asChild?: boolean
|
41 |
}
|
42 |
|
43 |
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
44 |
({ className, variant, size, asChild = false, ...props }, ref) => {
|
45 |
-
const Comp = asChild ? Slot : "button"
|
46 |
return (
|
47 |
<Comp
|
48 |
className={cn(buttonVariants({ variant, size, className }))}
|
49 |
ref={ref}
|
50 |
{...props}
|
51 |
/>
|
52 |
-
)
|
53 |
-
}
|
54 |
-
)
|
55 |
-
Button.displayName = "Button"
|
56 |
|
57 |
-
export { Button, buttonVariants }
|
|
|
1 |
+
import * as React from "react";
|
2 |
+
import { Slot } from "@radix-ui/react-slot";
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
4 |
|
5 |
+
import { cn } from "@/lib/utils";
|
6 |
|
7 |
const buttonVariants = cva(
|
8 |
+
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
9 |
{
|
10 |
variants: {
|
11 |
variant: {
|
|
|
31 |
variant: "default",
|
32 |
size: "default",
|
33 |
},
|
34 |
+
},
|
35 |
+
);
|
36 |
|
37 |
export interface ButtonProps
|
38 |
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
39 |
VariantProps<typeof buttonVariants> {
|
40 |
+
asChild?: boolean;
|
41 |
}
|
42 |
|
43 |
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
44 |
({ className, variant, size, asChild = false, ...props }, ref) => {
|
45 |
+
const Comp = asChild ? Slot : "button";
|
46 |
return (
|
47 |
<Comp
|
48 |
className={cn(buttonVariants({ variant, size, className }))}
|
49 |
ref={ref}
|
50 |
{...props}
|
51 |
/>
|
52 |
+
);
|
53 |
+
},
|
54 |
+
);
|
55 |
+
Button.displayName = "Button";
|
56 |
|
57 |
+
export { Button, buttonVariants };
|
components/ui/card.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import * as React from "react"
|
2 |
|
3 |
-
import { cn } from "@/lib/utils"
|
4 |
|
5 |
const Card = React.forwardRef<
|
6 |
HTMLDivElement,
|
@@ -10,12 +10,12 @@ const Card = React.forwardRef<
|
|
10 |
ref={ref}
|
11 |
className={cn(
|
12 |
"rounded-xl border bg-card text-card-foreground shadow",
|
13 |
-
className
|
14 |
)}
|
15 |
{...props}
|
16 |
/>
|
17 |
-
))
|
18 |
-
Card.displayName = "Card"
|
19 |
|
20 |
const CardHeader = React.forwardRef<
|
21 |
HTMLDivElement,
|
@@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
|
|
26 |
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
27 |
{...props}
|
28 |
/>
|
29 |
-
))
|
30 |
-
CardHeader.displayName = "CardHeader"
|
31 |
|
32 |
const CardTitle = React.forwardRef<
|
33 |
HTMLDivElement,
|
@@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
|
|
38 |
className={cn("font-semibold leading-none tracking-tight", className)}
|
39 |
{...props}
|
40 |
/>
|
41 |
-
))
|
42 |
-
CardTitle.displayName = "CardTitle"
|
43 |
|
44 |
const CardDescription = React.forwardRef<
|
45 |
HTMLDivElement,
|
@@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
|
|
50 |
className={cn("text-sm text-muted-foreground", className)}
|
51 |
{...props}
|
52 |
/>
|
53 |
-
))
|
54 |
-
CardDescription.displayName = "CardDescription"
|
55 |
|
56 |
const CardContent = React.forwardRef<
|
57 |
HTMLDivElement,
|
58 |
React.HTMLAttributes<HTMLDivElement>
|
59 |
>(({ className, ...props }, ref) => (
|
60 |
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
61 |
-
))
|
62 |
-
CardContent.displayName = "CardContent"
|
63 |
|
64 |
const CardFooter = React.forwardRef<
|
65 |
HTMLDivElement,
|
@@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
|
|
70 |
className={cn("flex items-center p-6 pt-0", className)}
|
71 |
{...props}
|
72 |
/>
|
73 |
-
))
|
74 |
-
CardFooter.displayName = "CardFooter"
|
75 |
|
76 |
-
export {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react";
|
2 |
|
3 |
+
import { cn } from "@/lib/utils";
|
4 |
|
5 |
const Card = React.forwardRef<
|
6 |
HTMLDivElement,
|
|
|
10 |
ref={ref}
|
11 |
className={cn(
|
12 |
"rounded-xl border bg-card text-card-foreground shadow",
|
13 |
+
className,
|
14 |
)}
|
15 |
{...props}
|
16 |
/>
|
17 |
+
));
|
18 |
+
Card.displayName = "Card";
|
19 |
|
20 |
const CardHeader = React.forwardRef<
|
21 |
HTMLDivElement,
|
|
|
26 |
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
27 |
{...props}
|
28 |
/>
|
29 |
+
));
|
30 |
+
CardHeader.displayName = "CardHeader";
|
31 |
|
32 |
const CardTitle = React.forwardRef<
|
33 |
HTMLDivElement,
|
|
|
38 |
className={cn("font-semibold leading-none tracking-tight", className)}
|
39 |
{...props}
|
40 |
/>
|
41 |
+
));
|
42 |
+
CardTitle.displayName = "CardTitle";
|
43 |
|
44 |
const CardDescription = React.forwardRef<
|
45 |
HTMLDivElement,
|
|
|
50 |
className={cn("text-sm text-muted-foreground", className)}
|
51 |
{...props}
|
52 |
/>
|
53 |
+
));
|
54 |
+
CardDescription.displayName = "CardDescription";
|
55 |
|
56 |
const CardContent = React.forwardRef<
|
57 |
HTMLDivElement,
|
58 |
React.HTMLAttributes<HTMLDivElement>
|
59 |
>(({ className, ...props }, ref) => (
|
60 |
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
61 |
+
));
|
62 |
+
CardContent.displayName = "CardContent";
|
63 |
|
64 |
const CardFooter = React.forwardRef<
|
65 |
HTMLDivElement,
|
|
|
70 |
className={cn("flex items-center p-6 pt-0", className)}
|
71 |
{...props}
|
72 |
/>
|
73 |
+
));
|
74 |
+
CardFooter.displayName = "CardFooter";
|
75 |
|
76 |
+
export {
|
77 |
+
Card,
|
78 |
+
CardHeader,
|
79 |
+
CardFooter,
|
80 |
+
CardTitle,
|
81 |
+
CardDescription,
|
82 |
+
CardContent,
|
83 |
+
};
|
components/ui/command.tsx
ADDED
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import { type DialogProps } from "@radix-ui/react-dialog";
|
5 |
+
import { Command as CommandPrimitive } from "cmdk";
|
6 |
+
import { Search } from "lucide-react";
|
7 |
+
|
8 |
+
import { cn } from "@/lib/utils";
|
9 |
+
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
10 |
+
|
11 |
+
const Command = React.forwardRef<
|
12 |
+
React.ElementRef<typeof CommandPrimitive>,
|
13 |
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
14 |
+
>(({ className, ...props }, ref) => (
|
15 |
+
<CommandPrimitive
|
16 |
+
ref={ref}
|
17 |
+
className={cn(
|
18 |
+
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
19 |
+
className,
|
20 |
+
)}
|
21 |
+
{...props}
|
22 |
+
/>
|
23 |
+
));
|
24 |
+
Command.displayName = CommandPrimitive.displayName;
|
25 |
+
|
26 |
+
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
27 |
+
return (
|
28 |
+
<Dialog {...props}>
|
29 |
+
<DialogContent className="overflow-hidden p-0">
|
30 |
+
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
31 |
+
{children}
|
32 |
+
</Command>
|
33 |
+
</DialogContent>
|
34 |
+
</Dialog>
|
35 |
+
);
|
36 |
+
};
|
37 |
+
|
38 |
+
const CommandInput = React.forwardRef<
|
39 |
+
React.ElementRef<typeof CommandPrimitive.Input>,
|
40 |
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
41 |
+
>(({ className, ...props }, ref) => (
|
42 |
+
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
43 |
+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
44 |
+
<CommandPrimitive.Input
|
45 |
+
ref={ref}
|
46 |
+
className={cn(
|
47 |
+
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
48 |
+
className,
|
49 |
+
)}
|
50 |
+
{...props}
|
51 |
+
/>
|
52 |
+
</div>
|
53 |
+
));
|
54 |
+
|
55 |
+
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
56 |
+
|
57 |
+
const CommandList = React.forwardRef<
|
58 |
+
React.ElementRef<typeof CommandPrimitive.List>,
|
59 |
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
60 |
+
>(({ className, ...props }, ref) => (
|
61 |
+
<CommandPrimitive.List
|
62 |
+
ref={ref}
|
63 |
+
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
64 |
+
{...props}
|
65 |
+
/>
|
66 |
+
));
|
67 |
+
|
68 |
+
CommandList.displayName = CommandPrimitive.List.displayName;
|
69 |
+
|
70 |
+
const CommandEmpty = React.forwardRef<
|
71 |
+
React.ElementRef<typeof CommandPrimitive.Empty>,
|
72 |
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
73 |
+
>((props, ref) => (
|
74 |
+
<CommandPrimitive.Empty
|
75 |
+
ref={ref}
|
76 |
+
className="py-6 text-center text-sm"
|
77 |
+
{...props}
|
78 |
+
/>
|
79 |
+
));
|
80 |
+
|
81 |
+
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
82 |
+
|
83 |
+
const CommandGroup = React.forwardRef<
|
84 |
+
React.ElementRef<typeof CommandPrimitive.Group>,
|
85 |
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
86 |
+
>(({ className, ...props }, ref) => (
|
87 |
+
<CommandPrimitive.Group
|
88 |
+
ref={ref}
|
89 |
+
className={cn(
|
90 |
+
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
91 |
+
className,
|
92 |
+
)}
|
93 |
+
{...props}
|
94 |
+
/>
|
95 |
+
));
|
96 |
+
|
97 |
+
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
98 |
+
|
99 |
+
const CommandSeparator = React.forwardRef<
|
100 |
+
React.ElementRef<typeof CommandPrimitive.Separator>,
|
101 |
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
102 |
+
>(({ className, ...props }, ref) => (
|
103 |
+
<CommandPrimitive.Separator
|
104 |
+
ref={ref}
|
105 |
+
className={cn("-mx-1 h-px bg-border", className)}
|
106 |
+
{...props}
|
107 |
+
/>
|
108 |
+
));
|
109 |
+
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
110 |
+
|
111 |
+
const CommandItem = React.forwardRef<
|
112 |
+
React.ElementRef<typeof CommandPrimitive.Item>,
|
113 |
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
114 |
+
>(({ className, ...props }, ref) => (
|
115 |
+
<CommandPrimitive.Item
|
116 |
+
ref={ref}
|
117 |
+
className={cn(
|
118 |
+
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
119 |
+
className,
|
120 |
+
)}
|
121 |
+
{...props}
|
122 |
+
/>
|
123 |
+
));
|
124 |
+
|
125 |
+
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
126 |
+
|
127 |
+
const CommandShortcut = ({
|
128 |
+
className,
|
129 |
+
...props
|
130 |
+
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
131 |
+
return (
|
132 |
+
<span
|
133 |
+
className={cn(
|
134 |
+
"ml-auto text-xs tracking-widest text-muted-foreground",
|
135 |
+
className,
|
136 |
+
)}
|
137 |
+
{...props}
|
138 |
+
/>
|
139 |
+
);
|
140 |
+
};
|
141 |
+
CommandShortcut.displayName = "CommandShortcut";
|
142 |
+
|
143 |
+
export {
|
144 |
+
Command,
|
145 |
+
CommandDialog,
|
146 |
+
CommandInput,
|
147 |
+
CommandList,
|
148 |
+
CommandEmpty,
|
149 |
+
CommandGroup,
|
150 |
+
CommandItem,
|
151 |
+
CommandShortcut,
|
152 |
+
CommandSeparator,
|
153 |
+
};
|
components/ui/dialog.tsx
ADDED
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
5 |
+
import { X } from "lucide-react";
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils";
|
8 |
+
|
9 |
+
const Dialog = DialogPrimitive.Root;
|
10 |
+
|
11 |
+
const DialogTrigger = DialogPrimitive.Trigger;
|
12 |
+
|
13 |
+
const DialogPortal = DialogPrimitive.Portal;
|
14 |
+
|
15 |
+
const DialogClose = DialogPrimitive.Close;
|
16 |
+
|
17 |
+
const DialogOverlay = React.forwardRef<
|
18 |
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
19 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
20 |
+
>(({ className, ...props }, ref) => (
|
21 |
+
<DialogPrimitive.Overlay
|
22 |
+
ref={ref}
|
23 |
+
className={cn(
|
24 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
25 |
+
className,
|
26 |
+
)}
|
27 |
+
{...props}
|
28 |
+
/>
|
29 |
+
));
|
30 |
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
31 |
+
|
32 |
+
const DialogContent = React.forwardRef<
|
33 |
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
34 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
35 |
+
>(({ className, children, ...props }, ref) => (
|
36 |
+
<DialogPortal>
|
37 |
+
<DialogOverlay />
|
38 |
+
<DialogPrimitive.Content
|
39 |
+
ref={ref}
|
40 |
+
className={cn(
|
41 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
42 |
+
className,
|
43 |
+
)}
|
44 |
+
{...props}
|
45 |
+
>
|
46 |
+
{children}
|
47 |
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
48 |
+
<X className="h-4 w-4" />
|
49 |
+
<span className="sr-only">Close</span>
|
50 |
+
</DialogPrimitive.Close>
|
51 |
+
</DialogPrimitive.Content>
|
52 |
+
</DialogPortal>
|
53 |
+
));
|
54 |
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
55 |
+
|
56 |
+
const DialogHeader = ({
|
57 |
+
className,
|
58 |
+
...props
|
59 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
60 |
+
<div
|
61 |
+
className={cn(
|
62 |
+
"flex flex-col space-y-1.5 text-center sm:text-left",
|
63 |
+
className,
|
64 |
+
)}
|
65 |
+
{...props}
|
66 |
+
/>
|
67 |
+
);
|
68 |
+
DialogHeader.displayName = "DialogHeader";
|
69 |
+
|
70 |
+
const DialogFooter = ({
|
71 |
+
className,
|
72 |
+
...props
|
73 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
74 |
+
<div
|
75 |
+
className={cn(
|
76 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
77 |
+
className,
|
78 |
+
)}
|
79 |
+
{...props}
|
80 |
+
/>
|
81 |
+
);
|
82 |
+
DialogFooter.displayName = "DialogFooter";
|
83 |
+
|
84 |
+
const DialogTitle = React.forwardRef<
|
85 |
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
86 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
87 |
+
>(({ className, ...props }, ref) => (
|
88 |
+
<DialogPrimitive.Title
|
89 |
+
ref={ref}
|
90 |
+
className={cn(
|
91 |
+
"text-lg font-semibold leading-none tracking-tight",
|
92 |
+
className,
|
93 |
+
)}
|
94 |
+
{...props}
|
95 |
+
/>
|
96 |
+
));
|
97 |
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
98 |
+
|
99 |
+
const DialogDescription = React.forwardRef<
|
100 |
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
101 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
102 |
+
>(({ className, ...props }, ref) => (
|
103 |
+
<DialogPrimitive.Description
|
104 |
+
ref={ref}
|
105 |
+
className={cn("text-sm text-muted-foreground", className)}
|
106 |
+
{...props}
|
107 |
+
/>
|
108 |
+
));
|
109 |
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
110 |
+
|
111 |
+
export {
|
112 |
+
Dialog,
|
113 |
+
DialogPortal,
|
114 |
+
DialogOverlay,
|
115 |
+
DialogTrigger,
|
116 |
+
DialogClose,
|
117 |
+
DialogContent,
|
118 |
+
DialogHeader,
|
119 |
+
DialogFooter,
|
120 |
+
DialogTitle,
|
121 |
+
DialogDescription,
|
122 |
+
};
|
components/ui/dropdown-menu.tsx
CHANGED
@@ -1,27 +1,27 @@
|
|
1 |
-
"use client"
|
2 |
|
3 |
-
import * as React from "react"
|
4 |
-
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
5 |
-
import { Check, ChevronRight, Circle } from "lucide-react"
|
6 |
|
7 |
-
import { cn } from "@/lib/utils"
|
8 |
|
9 |
-
const DropdownMenu = DropdownMenuPrimitive.Root
|
10 |
|
11 |
-
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
12 |
|
13 |
-
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
14 |
|
15 |
-
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
16 |
|
17 |
-
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
18 |
|
19 |
-
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
20 |
|
21 |
const DropdownMenuSubTrigger = React.forwardRef<
|
22 |
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
23 |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
24 |
-
inset?: boolean
|
25 |
}
|
26 |
>(({ className, inset, children, ...props }, ref) => (
|
27 |
<DropdownMenuPrimitive.SubTrigger
|
@@ -29,16 +29,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|
29 |
className={cn(
|
30 |
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
31 |
inset && "pl-8",
|
32 |
-
className
|
33 |
)}
|
34 |
{...props}
|
35 |
>
|
36 |
{children}
|
37 |
<ChevronRight className="ml-auto" />
|
38 |
</DropdownMenuPrimitive.SubTrigger>
|
39 |
-
))
|
40 |
DropdownMenuSubTrigger.displayName =
|
41 |
-
DropdownMenuPrimitive.SubTrigger.displayName
|
42 |
|
43 |
const DropdownMenuSubContent = React.forwardRef<
|
44 |
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
@@ -48,13 +48,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
|
48 |
ref={ref}
|
49 |
className={cn(
|
50 |
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
51 |
-
className
|
52 |
)}
|
53 |
{...props}
|
54 |
/>
|
55 |
-
))
|
56 |
DropdownMenuSubContent.displayName =
|
57 |
-
DropdownMenuPrimitive.SubContent.displayName
|
58 |
|
59 |
const DropdownMenuContent = React.forwardRef<
|
60 |
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
@@ -67,18 +67,18 @@ const DropdownMenuContent = React.forwardRef<
|
|
67 |
className={cn(
|
68 |
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
69 |
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
70 |
-
className
|
71 |
)}
|
72 |
{...props}
|
73 |
/>
|
74 |
</DropdownMenuPrimitive.Portal>
|
75 |
-
))
|
76 |
-
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
77 |
|
78 |
const DropdownMenuItem = React.forwardRef<
|
79 |
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
80 |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
81 |
-
inset?: boolean
|
82 |
}
|
83 |
>(({ className, inset, ...props }, ref) => (
|
84 |
<DropdownMenuPrimitive.Item
|
@@ -86,12 +86,12 @@ const DropdownMenuItem = React.forwardRef<
|
|
86 |
className={cn(
|
87 |
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
88 |
inset && "pl-8",
|
89 |
-
className
|
90 |
)}
|
91 |
{...props}
|
92 |
/>
|
93 |
-
))
|
94 |
-
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
95 |
|
96 |
const DropdownMenuCheckboxItem = React.forwardRef<
|
97 |
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
@@ -101,7 +101,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|
101 |
ref={ref}
|
102 |
className={cn(
|
103 |
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
104 |
-
className
|
105 |
)}
|
106 |
checked={checked}
|
107 |
{...props}
|
@@ -113,9 +113,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|
113 |
</span>
|
114 |
{children}
|
115 |
</DropdownMenuPrimitive.CheckboxItem>
|
116 |
-
))
|
117 |
DropdownMenuCheckboxItem.displayName =
|
118 |
-
DropdownMenuPrimitive.CheckboxItem.displayName
|
119 |
|
120 |
const DropdownMenuRadioItem = React.forwardRef<
|
121 |
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
@@ -125,7 +125,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
125 |
ref={ref}
|
126 |
className={cn(
|
127 |
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
128 |
-
className
|
129 |
)}
|
130 |
{...props}
|
131 |
>
|
@@ -136,13 +136,13 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
136 |
</span>
|
137 |
{children}
|
138 |
</DropdownMenuPrimitive.RadioItem>
|
139 |
-
))
|
140 |
-
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
141 |
|
142 |
const DropdownMenuLabel = React.forwardRef<
|
143 |
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
144 |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
145 |
-
inset?: boolean
|
146 |
}
|
147 |
>(({ className, inset, ...props }, ref) => (
|
148 |
<DropdownMenuPrimitive.Label
|
@@ -150,12 +150,12 @@ const DropdownMenuLabel = React.forwardRef<
|
|
150 |
className={cn(
|
151 |
"px-2 py-1.5 text-sm font-semibold",
|
152 |
inset && "pl-8",
|
153 |
-
className
|
154 |
)}
|
155 |
{...props}
|
156 |
/>
|
157 |
-
))
|
158 |
-
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
159 |
|
160 |
const DropdownMenuSeparator = React.forwardRef<
|
161 |
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
@@ -166,8 +166,8 @@ const DropdownMenuSeparator = React.forwardRef<
|
|
166 |
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
167 |
{...props}
|
168 |
/>
|
169 |
-
))
|
170 |
-
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
171 |
|
172 |
const DropdownMenuShortcut = ({
|
173 |
className,
|
@@ -178,9 +178,9 @@ const DropdownMenuShortcut = ({
|
|
178 |
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
179 |
{...props}
|
180 |
/>
|
181 |
-
)
|
182 |
-
}
|
183 |
-
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
184 |
|
185 |
export {
|
186 |
DropdownMenu,
|
@@ -198,4 +198,4 @@ export {
|
|
198 |
DropdownMenuSubContent,
|
199 |
DropdownMenuSubTrigger,
|
200 |
DropdownMenuRadioGroup,
|
201 |
-
}
|
|
|
1 |
+
"use client";
|
2 |
|
3 |
+
import * as React from "react";
|
4 |
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
5 |
+
import { Check, ChevronRight, Circle } from "lucide-react";
|
6 |
|
7 |
+
import { cn } from "@/lib/utils";
|
8 |
|
9 |
+
const DropdownMenu = DropdownMenuPrimitive.Root;
|
10 |
|
11 |
+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
12 |
|
13 |
+
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
14 |
|
15 |
+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
16 |
|
17 |
+
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
18 |
|
19 |
+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
20 |
|
21 |
const DropdownMenuSubTrigger = React.forwardRef<
|
22 |
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
23 |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
24 |
+
inset?: boolean;
|
25 |
}
|
26 |
>(({ className, inset, children, ...props }, ref) => (
|
27 |
<DropdownMenuPrimitive.SubTrigger
|
|
|
29 |
className={cn(
|
30 |
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
31 |
inset && "pl-8",
|
32 |
+
className,
|
33 |
)}
|
34 |
{...props}
|
35 |
>
|
36 |
{children}
|
37 |
<ChevronRight className="ml-auto" />
|
38 |
</DropdownMenuPrimitive.SubTrigger>
|
39 |
+
));
|
40 |
DropdownMenuSubTrigger.displayName =
|
41 |
+
DropdownMenuPrimitive.SubTrigger.displayName;
|
42 |
|
43 |
const DropdownMenuSubContent = React.forwardRef<
|
44 |
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
|
48 |
ref={ref}
|
49 |
className={cn(
|
50 |
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
51 |
+
className,
|
52 |
)}
|
53 |
{...props}
|
54 |
/>
|
55 |
+
));
|
56 |
DropdownMenuSubContent.displayName =
|
57 |
+
DropdownMenuPrimitive.SubContent.displayName;
|
58 |
|
59 |
const DropdownMenuContent = React.forwardRef<
|
60 |
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
|
67 |
className={cn(
|
68 |
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
69 |
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
70 |
+
className,
|
71 |
)}
|
72 |
{...props}
|
73 |
/>
|
74 |
</DropdownMenuPrimitive.Portal>
|
75 |
+
));
|
76 |
+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
77 |
|
78 |
const DropdownMenuItem = React.forwardRef<
|
79 |
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
80 |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
81 |
+
inset?: boolean;
|
82 |
}
|
83 |
>(({ className, inset, ...props }, ref) => (
|
84 |
<DropdownMenuPrimitive.Item
|
|
|
86 |
className={cn(
|
87 |
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
88 |
inset && "pl-8",
|
89 |
+
className,
|
90 |
)}
|
91 |
{...props}
|
92 |
/>
|
93 |
+
));
|
94 |
+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
95 |
|
96 |
const DropdownMenuCheckboxItem = React.forwardRef<
|
97 |
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
|
101 |
ref={ref}
|
102 |
className={cn(
|
103 |
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
104 |
+
className,
|
105 |
)}
|
106 |
checked={checked}
|
107 |
{...props}
|
|
|
113 |
</span>
|
114 |
{children}
|
115 |
</DropdownMenuPrimitive.CheckboxItem>
|
116 |
+
));
|
117 |
DropdownMenuCheckboxItem.displayName =
|
118 |
+
DropdownMenuPrimitive.CheckboxItem.displayName;
|
119 |
|
120 |
const DropdownMenuRadioItem = React.forwardRef<
|
121 |
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
|
125 |
ref={ref}
|
126 |
className={cn(
|
127 |
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
128 |
+
className,
|
129 |
)}
|
130 |
{...props}
|
131 |
>
|
|
|
136 |
</span>
|
137 |
{children}
|
138 |
</DropdownMenuPrimitive.RadioItem>
|
139 |
+
));
|
140 |
+
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
141 |
|
142 |
const DropdownMenuLabel = React.forwardRef<
|
143 |
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
144 |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
145 |
+
inset?: boolean;
|
146 |
}
|
147 |
>(({ className, inset, ...props }, ref) => (
|
148 |
<DropdownMenuPrimitive.Label
|
|
|
150 |
className={cn(
|
151 |
"px-2 py-1.5 text-sm font-semibold",
|
152 |
inset && "pl-8",
|
153 |
+
className,
|
154 |
)}
|
155 |
{...props}
|
156 |
/>
|
157 |
+
));
|
158 |
+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
159 |
|
160 |
const DropdownMenuSeparator = React.forwardRef<
|
161 |
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
|
|
166 |
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
167 |
{...props}
|
168 |
/>
|
169 |
+
));
|
170 |
+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
171 |
|
172 |
const DropdownMenuShortcut = ({
|
173 |
className,
|
|
|
178 |
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
179 |
{...props}
|
180 |
/>
|
181 |
+
);
|
182 |
+
};
|
183 |
+
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
184 |
|
185 |
export {
|
186 |
DropdownMenu,
|
|
|
198 |
DropdownMenuSubContent,
|
199 |
DropdownMenuSubTrigger,
|
200 |
DropdownMenuRadioGroup,
|
201 |
+
};
|
components/ui/input.tsx
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react";
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils";
|
4 |
+
|
5 |
+
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
6 |
+
({ className, type, ...props }, ref) => {
|
7 |
+
return (
|
8 |
+
<input
|
9 |
+
type={type}
|
10 |
+
className={cn(
|
11 |
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
12 |
+
className,
|
13 |
+
)}
|
14 |
+
ref={ref}
|
15 |
+
{...props}
|
16 |
+
/>
|
17 |
+
);
|
18 |
+
},
|
19 |
+
);
|
20 |
+
Input.displayName = "Input";
|
21 |
+
|
22 |
+
export { Input };
|
components/ui/popover.tsx
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils";
|
7 |
+
|
8 |
+
const Popover = PopoverPrimitive.Root;
|
9 |
+
|
10 |
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
11 |
+
|
12 |
+
const PopoverAnchor = PopoverPrimitive.Anchor;
|
13 |
+
|
14 |
+
const PopoverContent = React.forwardRef<
|
15 |
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
16 |
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
17 |
+
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
18 |
+
<PopoverPrimitive.Portal>
|
19 |
+
<PopoverPrimitive.Content
|
20 |
+
ref={ref}
|
21 |
+
align={align}
|
22 |
+
sideOffset={sideOffset}
|
23 |
+
className={cn(
|
24 |
+
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
25 |
+
className,
|
26 |
+
)}
|
27 |
+
{...props}
|
28 |
+
/>
|
29 |
+
</PopoverPrimitive.Portal>
|
30 |
+
));
|
31 |
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
32 |
+
|
33 |
+
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
components/ui/separator.tsx
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils";
|
7 |
+
|
8 |
+
const Separator = React.forwardRef<
|
9 |
+
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
10 |
+
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
11 |
+
>(
|
12 |
+
(
|
13 |
+
{ className, orientation = "horizontal", decorative = true, ...props },
|
14 |
+
ref,
|
15 |
+
) => (
|
16 |
+
<SeparatorPrimitive.Root
|
17 |
+
ref={ref}
|
18 |
+
decorative={decorative}
|
19 |
+
orientation={orientation}
|
20 |
+
className={cn(
|
21 |
+
"shrink-0 bg-border",
|
22 |
+
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
23 |
+
className,
|
24 |
+
)}
|
25 |
+
{...props}
|
26 |
+
/>
|
27 |
+
),
|
28 |
+
);
|
29 |
+
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
30 |
+
|
31 |
+
export { Separator };
|
lib/utils.ts
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import { clsx, type ClassValue } from "clsx"
|
2 |
-
import { twMerge } from "tailwind-merge"
|
3 |
|
4 |
export function cn(...inputs: ClassValue[]) {
|
5 |
-
return twMerge(clsx(inputs))
|
6 |
}
|
|
|
1 |
+
import { clsx, type ClassValue } from "clsx";
|
2 |
+
import { twMerge } from "tailwind-merge";
|
3 |
|
4 |
export function cn(...inputs: ClassValue[]) {
|
5 |
+
return twMerge(clsx(inputs));
|
6 |
}
|
next.config.ts
CHANGED
@@ -1,14 +1,14 @@
|
|
1 |
import type { NextConfig } from "next";
|
2 |
|
3 |
const nextConfig: NextConfig = {
|
4 |
-
output:
|
5 |
async rewrites() {
|
6 |
return [
|
7 |
{
|
8 |
-
source:
|
9 |
-
destination:
|
10 |
-
}
|
11 |
-
]
|
12 |
},
|
13 |
};
|
14 |
|
|
|
1 |
import type { NextConfig } from "next";
|
2 |
|
3 |
const nextConfig: NextConfig = {
|
4 |
+
output: "standalone",
|
5 |
async rewrites() {
|
6 |
return [
|
7 |
{
|
8 |
+
source: "/api/:path*",
|
9 |
+
destination: "http://localhost:8000/api/:path*", // FastAPI backend
|
10 |
+
},
|
11 |
+
];
|
12 |
},
|
13 |
};
|
14 |
|
package.json
CHANGED
@@ -10,11 +10,17 @@
|
|
10 |
},
|
11 |
"dependencies": {
|
12 |
"@heroicons/react": "^2.1.5",
|
|
|
13 |
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
|
|
|
|
14 |
"@radix-ui/react-slot": "^1.1.0",
|
|
|
15 |
"ai": "^3.4.31",
|
16 |
"class-variance-authority": "^0.7.0",
|
17 |
"clsx": "^2.1.1",
|
|
|
|
|
18 |
"lucide-react": "^0.456.0",
|
19 |
"next": "15.0.2",
|
20 |
"next-themes": "^0.4.3",
|
@@ -32,6 +38,7 @@
|
|
32 |
"eslint": "^8",
|
33 |
"eslint-config-next": "15.0.2",
|
34 |
"postcss": "^8",
|
|
|
35 |
"tailwindcss": "^3.4.14",
|
36 |
"typescript": "^5"
|
37 |
},
|
|
|
10 |
},
|
11 |
"dependencies": {
|
12 |
"@heroicons/react": "^2.1.5",
|
13 |
+
"@radix-ui/react-dialog": "^1.1.2",
|
14 |
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
15 |
+
"@radix-ui/react-popover": "^1.1.2",
|
16 |
+
"@radix-ui/react-separator": "^1.1.0",
|
17 |
"@radix-ui/react-slot": "^1.1.0",
|
18 |
+
"@tanstack/react-table": "^8.20.5",
|
19 |
"ai": "^3.4.31",
|
20 |
"class-variance-authority": "^0.7.0",
|
21 |
"clsx": "^2.1.1",
|
22 |
+
"cmdk": "1.0.0",
|
23 |
+
"eslint-config-prettier": "^9.1.0",
|
24 |
"lucide-react": "^0.456.0",
|
25 |
"next": "15.0.2",
|
26 |
"next-themes": "^0.4.3",
|
|
|
38 |
"eslint": "^8",
|
39 |
"eslint-config-next": "15.0.2",
|
40 |
"postcss": "^8",
|
41 |
+
"prettier": "3.3.3",
|
42 |
"tailwindcss": "^3.4.14",
|
43 |
"typescript": "^5"
|
44 |
},
|
pnpm-lock.yaml
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
public/chatbots/code-tutor.webp
ADDED
![]() |
public/chatbots/whimsical.svg
ADDED
|
tailwind.config.ts
CHANGED
@@ -1,83 +1,83 @@
|
|
1 |
import type { Config } from "tailwindcss";
|
2 |
|
3 |
const config: Config = {
|
4 |
-
darkMode: [
|
5 |
content: [
|
6 |
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
7 |
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
8 |
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
9 |
],
|
10 |
theme: {
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
},
|
82 |
plugins: [require("tailwindcss-animate")],
|
83 |
};
|
|
|
1 |
import type { Config } from "tailwindcss";
|
2 |
|
3 |
const config: Config = {
|
4 |
+
darkMode: ["class", "class"],
|
5 |
content: [
|
6 |
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
7 |
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
8 |
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
9 |
],
|
10 |
theme: {
|
11 |
+
extend: {
|
12 |
+
colors: {
|
13 |
+
primary: {
|
14 |
+
DEFAULT: "hsl(var(--primary))",
|
15 |
+
dark: "#3DAA9D",
|
16 |
+
light: "#7EDCD6",
|
17 |
+
foreground: "hsl(var(--primary-foreground))",
|
18 |
+
},
|
19 |
+
secondary: {
|
20 |
+
DEFAULT: "hsl(var(--secondary))",
|
21 |
+
dark: "#E65555",
|
22 |
+
light: "#FF8F8F",
|
23 |
+
foreground: "hsl(var(--secondary-foreground))",
|
24 |
+
},
|
25 |
+
accent: {
|
26 |
+
blue: "#45B7D1",
|
27 |
+
yellow: "#FDCB6E",
|
28 |
+
dark: {
|
29 |
+
blue: "#3A9BB2",
|
30 |
+
yellow: "#D4A85D",
|
31 |
+
},
|
32 |
+
DEFAULT: "hsl(var(--accent))",
|
33 |
+
foreground: "hsl(var(--accent-foreground))",
|
34 |
+
},
|
35 |
+
background: "hsl(var(--background))",
|
36 |
+
text: {
|
37 |
+
primary: "var(--text-primary)",
|
38 |
+
secondary: "var(--text-secondary)",
|
39 |
+
tertiary: "var(--text-tertiary)",
|
40 |
+
},
|
41 |
+
border: "hsl(var(--border))",
|
42 |
+
foreground: "hsl(var(--foreground))",
|
43 |
+
card: {
|
44 |
+
DEFAULT: "hsl(var(--card))",
|
45 |
+
foreground: "hsl(var(--card-foreground))",
|
46 |
+
},
|
47 |
+
popover: {
|
48 |
+
DEFAULT: "hsl(var(--popover))",
|
49 |
+
foreground: "hsl(var(--popover-foreground))",
|
50 |
+
},
|
51 |
+
muted: {
|
52 |
+
DEFAULT: "hsl(var(--muted))",
|
53 |
+
foreground: "hsl(var(--muted-foreground))",
|
54 |
+
},
|
55 |
+
destructive: {
|
56 |
+
DEFAULT: "hsl(var(--destructive))",
|
57 |
+
foreground: "hsl(var(--destructive-foreground))",
|
58 |
+
},
|
59 |
+
input: "hsl(var(--input))",
|
60 |
+
ring: "hsl(var(--ring))",
|
61 |
+
chart: {
|
62 |
+
"1": "hsl(var(--chart-1))",
|
63 |
+
"2": "hsl(var(--chart-2))",
|
64 |
+
"3": "hsl(var(--chart-3))",
|
65 |
+
"4": "hsl(var(--chart-4))",
|
66 |
+
"5": "hsl(var(--chart-5))",
|
67 |
+
},
|
68 |
+
},
|
69 |
+
backdropBlur: {
|
70 |
+
xs: "2px",
|
71 |
+
},
|
72 |
+
boxShadow: {
|
73 |
+
dark: "0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.24)",
|
74 |
+
},
|
75 |
+
borderRadius: {
|
76 |
+
lg: "var(--radius)",
|
77 |
+
md: "calc(var(--radius) - 2px)",
|
78 |
+
sm: "calc(var(--radius) - 4px)",
|
79 |
+
},
|
80 |
+
},
|
81 |
},
|
82 |
plugins: [require("tailwindcss-animate")],
|
83 |
};
|