nextjs-hackathon-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/index.js +152 -0
- package/package.json +54 -0
- package/template/.cursor/agents/business-intelligence.md +38 -0
- package/template/.cursor/agents/code-reviewer.md +74 -0
- package/template/.cursor/agents/frontend.md +45 -0
- package/template/.cursor/agents/security-researcher.md +49 -0
- package/template/.cursor/agents/technical-lead.md +36 -0
- package/template/.cursor/agents/test-qa.md +85 -0
- package/template/.cursor/rules/ai.mdc +53 -0
- package/template/.cursor/rules/architecture.mdc +42 -0
- package/template/.cursor/rules/coding-standards.mdc +53 -0
- package/template/.cursor/rules/components.mdc +33 -0
- package/template/.cursor/rules/data-fetching.mdc +63 -0
- package/template/.cursor/rules/forms.mdc +59 -0
- package/template/.cursor/rules/general.mdc +52 -0
- package/template/.cursor/rules/nextjs.mdc +46 -0
- package/template/.cursor/rules/security.mdc +54 -0
- package/template/.cursor/rules/supabase.mdc +36 -0
- package/template/.cursor/rules/testing.mdc +116 -0
- package/template/.cursor/skills/create-api-route/SKILL.md +62 -0
- package/template/.cursor/skills/create-feature/SKILL.md +52 -0
- package/template/.cursor/skills/review-branch/SKILL.md +61 -0
- package/template/.cursor/skills/security-audit/SKILL.md +69 -0
- package/template/.env.example +24 -0
- package/template/.requirements/README.md +15 -0
- package/template/.requirements/auth.md +50 -0
- package/template/.requirements/template.md +25 -0
- package/template/README.md +98 -0
- package/template/components.json +19 -0
- package/template/drizzle.config.ts +10 -0
- package/template/eslint.config.ts +66 -0
- package/template/middleware.ts +10 -0
- package/template/next.config.ts +31 -0
- package/template/package.json.tmpl +62 -0
- package/template/playwright.config.ts +22 -0
- package/template/src/app/(auth)/login/page.tsx +12 -0
- package/template/src/app/(protected)/layout.tsx +19 -0
- package/template/src/app/(protected)/page.tsx +16 -0
- package/template/src/app/api/auth/callback/route.ts +21 -0
- package/template/src/app/globals.css +1 -0
- package/template/src/app/layout.tsx +21 -0
- package/template/src/e2e/chat.spec.ts +8 -0
- package/template/src/e2e/home.spec.ts +8 -0
- package/template/src/e2e/login.spec.ts +21 -0
- package/template/src/features/auth/__tests__/login-form.test.tsx +43 -0
- package/template/src/features/auth/__tests__/use-auth.test.ts +46 -0
- package/template/src/features/auth/actions/login.action.ts +38 -0
- package/template/src/features/auth/actions/logout.action.ts +10 -0
- package/template/src/features/auth/components/login-form.tsx +80 -0
- package/template/src/features/auth/hooks/use-auth.ts +17 -0
- package/template/src/features/chat/__tests__/chat-ui.test.tsx +30 -0
- package/template/src/features/chat/__tests__/route.test.ts +19 -0
- package/template/src/features/chat/api/route.ts +16 -0
- package/template/src/features/chat/components/chat-ui.tsx +47 -0
- package/template/src/features/chat/hooks/use-chat.ts +1 -0
- package/template/src/features/tts/__tests__/route.test.ts +13 -0
- package/template/src/features/tts/__tests__/tts-player.test.tsx +20 -0
- package/template/src/features/tts/api/route.ts +14 -0
- package/template/src/features/tts/components/tts-player.tsx +59 -0
- package/template/src/features/video/__tests__/route.test.ts +13 -0
- package/template/src/features/video/__tests__/video-generator.test.tsx +20 -0
- package/template/src/features/video/api/route.ts +14 -0
- package/template/src/features/video/components/video-generator.tsx +56 -0
- package/template/src/shared/__tests__/ai.test.ts +8 -0
- package/template/src/shared/__tests__/minimax-media.test.ts +57 -0
- package/template/src/shared/__tests__/providers.test.tsx +14 -0
- package/template/src/shared/__tests__/schema.test.ts +18 -0
- package/template/src/shared/__tests__/supabase-client.test.ts +13 -0
- package/template/src/shared/__tests__/supabase-server.test.ts +22 -0
- package/template/src/shared/components/providers.tsx +19 -0
- package/template/src/shared/db/index.ts +7 -0
- package/template/src/shared/db/schema.ts +15 -0
- package/template/src/shared/lib/ai.ts +8 -0
- package/template/src/shared/lib/minimax-media.ts +63 -0
- package/template/src/shared/lib/supabase/client.ts +8 -0
- package/template/src/shared/lib/supabase/middleware.ts +40 -0
- package/template/src/shared/lib/supabase/server.ts +26 -0
- package/template/src/test-setup.ts +1 -0
- package/template/tailwind.css +20 -0
- package/template/tsconfig.json +31 -0
- package/template/vitest.config.ts +33 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { createClient } from "@/shared/lib/supabase/server";
|
|
3
|
+
|
|
4
|
+
export async function GET(request: Request) {
|
|
5
|
+
const { searchParams, origin } = new URL(request.url);
|
|
6
|
+
const code = searchParams.get("code");
|
|
7
|
+
const next = searchParams.get("next") ?? "/";
|
|
8
|
+
|
|
9
|
+
if (!code) {
|
|
10
|
+
return NextResponse.redirect(`${origin}/login?error=no_code`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const supabase = await createClient();
|
|
14
|
+
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
|
15
|
+
|
|
16
|
+
if (error) {
|
|
17
|
+
return NextResponse.redirect(`${origin}/login?error=auth_error`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return NextResponse.redirect(`${origin}${next}`);
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "../../tailwind.css";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Providers } from "@/shared/components/providers";
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: "Hackathon App",
|
|
6
|
+
description: "Built with nextjs-hackathon-stack",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<html lang="en" suppressHydrationWarning>
|
|
16
|
+
<body>
|
|
17
|
+
<Providers>{children}</Providers>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { test, expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test.describe("Login flow", () => {
|
|
4
|
+
test("redirects unauthenticated users to login", async ({ page }) => {
|
|
5
|
+
await page.goto("/");
|
|
6
|
+
await expect(page).toHaveURL(/login/);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("shows login form", async ({ page }) => {
|
|
10
|
+
await page.goto("/login");
|
|
11
|
+
await expect(page.getByTestId("login-form")).toBeVisible();
|
|
12
|
+
await expect(page.getByLabel(/email/i)).toBeVisible();
|
|
13
|
+
await expect(page.getByLabel(/password/i)).toBeVisible();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("shows validation errors on empty submit", async ({ page }) => {
|
|
17
|
+
await page.goto("/login");
|
|
18
|
+
await page.getByRole("button", { name: /sign in/i }).click();
|
|
19
|
+
await expect(page.getByText(/invalid email/i)).toBeVisible();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
|
+
import { LoginForm } from "../components/login-form";
|
|
5
|
+
|
|
6
|
+
vi.mock("../actions/login.action", () => ({
|
|
7
|
+
loginAction: vi.fn().mockResolvedValue({ status: "idle" }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("LoginForm", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("renders email and password fields", () => {
|
|
16
|
+
// Arrange + Act
|
|
17
|
+
render(<LoginForm />);
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders submit button", () => {
|
|
25
|
+
// Arrange + Act
|
|
26
|
+
render(<LoginForm />);
|
|
27
|
+
|
|
28
|
+
// Assert
|
|
29
|
+
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("shows validation error when submitting with empty email", async () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
render(<LoginForm />);
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { createElement } from "react";
|
|
5
|
+
import { useAuth } from "../hooks/use-auth";
|
|
6
|
+
|
|
7
|
+
const mockGetSession = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("@/shared/lib/supabase/client", () => ({
|
|
10
|
+
createClient: () => ({
|
|
11
|
+
auth: { getSession: mockGetSession },
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
function wrapper({ children }: { children: React.ReactNode }) {
|
|
16
|
+
const queryClient = new QueryClient({
|
|
17
|
+
defaultOptions: { queries: { retry: false } },
|
|
18
|
+
});
|
|
19
|
+
return createElement(QueryClientProvider, { client: queryClient }, children);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("useAuth", () => {
|
|
23
|
+
it("returns session data when auth succeeds", async () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const mockSession = { user: { id: "123", email: "test@example.com" } };
|
|
26
|
+
mockGetSession.mockResolvedValue({ data: { session: mockSession }, error: null });
|
|
27
|
+
|
|
28
|
+
// Act
|
|
29
|
+
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
33
|
+
expect(result.current.data).toEqual(mockSession);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("transitions to error state when auth fails", async () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
mockGetSession.mockResolvedValue({ data: { session: null }, error: new Error("Auth error") });
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { redirect } from "next/navigation";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createClient } from "@/shared/lib/supabase/server";
|
|
6
|
+
|
|
7
|
+
const loginSchema = z.object({
|
|
8
|
+
email: z.string().email(),
|
|
9
|
+
password: z.string().min(8),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type LoginActionState =
|
|
13
|
+
| { status: "idle" }
|
|
14
|
+
| { status: "error"; message: string }
|
|
15
|
+
| { status: "success" };
|
|
16
|
+
|
|
17
|
+
export async function loginAction(
|
|
18
|
+
_prev: LoginActionState,
|
|
19
|
+
formData: FormData
|
|
20
|
+
): Promise<LoginActionState> {
|
|
21
|
+
const parsed = loginSchema.safeParse({
|
|
22
|
+
email: formData.get("email"),
|
|
23
|
+
password: formData.get("password"),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!parsed.success) {
|
|
27
|
+
return { status: "error", message: parsed.error.issues[0]?.message ?? "Invalid input" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const supabase = await createClient();
|
|
31
|
+
const { error } = await supabase.auth.signInWithPassword(parsed.data);
|
|
32
|
+
|
|
33
|
+
if (error) {
|
|
34
|
+
return { status: "error", message: error.message };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
redirect("/");
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { redirect } from "next/navigation";
|
|
4
|
+
import { createClient } from "@/shared/lib/supabase/server";
|
|
5
|
+
|
|
6
|
+
export async function logoutAction(): Promise<void> {
|
|
7
|
+
const supabase = await createClient();
|
|
8
|
+
await supabase.auth.signOut();
|
|
9
|
+
redirect("/login");
|
|
10
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState } from "react";
|
|
4
|
+
import { useForm } from "react-hook-form";
|
|
5
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { loginAction, type LoginActionState } from "../actions/login.action";
|
|
8
|
+
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
email: z.string().email("Invalid email"),
|
|
11
|
+
password: z.string().min(8, "Password must be at least 8 characters"),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
type FormValues = z.infer<typeof schema>;
|
|
15
|
+
|
|
16
|
+
const initialState: LoginActionState = { status: "idle" };
|
|
17
|
+
|
|
18
|
+
export function LoginForm() {
|
|
19
|
+
const [state, formAction, isPending] = useActionState(loginAction, initialState);
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
register,
|
|
23
|
+
handleSubmit,
|
|
24
|
+
formState: { errors },
|
|
25
|
+
} = useForm<FormValues>({ resolver: zodResolver(schema) });
|
|
26
|
+
|
|
27
|
+
const onSubmit = handleSubmit((data) => {
|
|
28
|
+
const fd = new FormData();
|
|
29
|
+
fd.set("email", data.email);
|
|
30
|
+
fd.set("password", data.password);
|
|
31
|
+
formAction(fd);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<form onSubmit={onSubmit} data-testid="login-form" className="space-y-4">
|
|
36
|
+
{state.status === "error" && (
|
|
37
|
+
<p role="alert" className="text-sm text-red-600">
|
|
38
|
+
{state.message}
|
|
39
|
+
</p>
|
|
40
|
+
)}
|
|
41
|
+
<div>
|
|
42
|
+
<label htmlFor="email" className="block text-sm font-medium">
|
|
43
|
+
Email
|
|
44
|
+
</label>
|
|
45
|
+
<input
|
|
46
|
+
id="email"
|
|
47
|
+
type="email"
|
|
48
|
+
autoComplete="email"
|
|
49
|
+
{...register("email")}
|
|
50
|
+
className="mt-1 block w-full rounded border px-3 py-2"
|
|
51
|
+
/>
|
|
52
|
+
{errors.email && (
|
|
53
|
+
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
<div>
|
|
57
|
+
<label htmlFor="password" className="block text-sm font-medium">
|
|
58
|
+
Password
|
|
59
|
+
</label>
|
|
60
|
+
<input
|
|
61
|
+
id="password"
|
|
62
|
+
type="password"
|
|
63
|
+
autoComplete="current-password"
|
|
64
|
+
{...register("password")}
|
|
65
|
+
className="mt-1 block w-full rounded border px-3 py-2"
|
|
66
|
+
/>
|
|
67
|
+
{errors.password && (
|
|
68
|
+
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
<button
|
|
72
|
+
type="submit"
|
|
73
|
+
disabled={isPending}
|
|
74
|
+
className="w-full rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
|
|
75
|
+
>
|
|
76
|
+
{isPending ? "Signing in..." : "Sign in"}
|
|
77
|
+
</button>
|
|
78
|
+
</form>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { createClient } from "@/shared/lib/supabase/client";
|
|
5
|
+
|
|
6
|
+
export function useAuth() {
|
|
7
|
+
return useQuery({
|
|
8
|
+
queryKey: ["auth", "session"],
|
|
9
|
+
queryFn: async () => {
|
|
10
|
+
const supabase = createClient();
|
|
11
|
+
const { data, error } = await supabase.auth.getSession();
|
|
12
|
+
if (error) throw error;
|
|
13
|
+
return data.session;
|
|
14
|
+
},
|
|
15
|
+
staleTime: 5 * 60 * 1000,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { ChatUi } from "../components/chat-ui";
|
|
4
|
+
|
|
5
|
+
vi.mock("@ai-sdk/react", () => ({
|
|
6
|
+
useChat: vi.fn(() => ({
|
|
7
|
+
messages: [],
|
|
8
|
+
input: "",
|
|
9
|
+
handleInputChange: vi.fn(),
|
|
10
|
+
handleSubmit: vi.fn(),
|
|
11
|
+
isLoading: false,
|
|
12
|
+
})),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("ChatUi", () => {
|
|
16
|
+
it("renders chat input", () => {
|
|
17
|
+
render(<ChatUi />);
|
|
18
|
+
expect(screen.getByTestId("chat-input")).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders send button", () => {
|
|
22
|
+
render(<ChatUi />);
|
|
23
|
+
expect(screen.getByRole("button", { name: /send/i })).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders messages list area", () => {
|
|
27
|
+
render(<ChatUi />);
|
|
28
|
+
expect(screen.getByTestId("chat-ui")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("ai", () => ({
|
|
4
|
+
streamText: vi.fn(() => ({
|
|
5
|
+
toDataStreamResponse: vi.fn(() => new Response("data: done\n\n")),
|
|
6
|
+
})),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("@/shared/lib/ai", () => ({
|
|
10
|
+
aiModel: {},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("chat route", () => {
|
|
14
|
+
it("module is importable", async () => {
|
|
15
|
+
const mod = await import("../api/route");
|
|
16
|
+
expect(mod.runtime).toBe("edge");
|
|
17
|
+
expect(typeof mod.POST).toBe("function");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { streamText } from "ai";
|
|
2
|
+
import { aiModel } from "@/shared/lib/ai";
|
|
3
|
+
|
|
4
|
+
export const runtime = "edge";
|
|
5
|
+
|
|
6
|
+
export async function POST(req: Request) {
|
|
7
|
+
const { messages } = (await req.json()) as { messages: Array<{ role: string; content: string }> };
|
|
8
|
+
|
|
9
|
+
const result = streamText({
|
|
10
|
+
model: aiModel,
|
|
11
|
+
messages,
|
|
12
|
+
maxTokens: 2048,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return result.toDataStreamResponse();
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useChat } from "@ai-sdk/react";
|
|
4
|
+
|
|
5
|
+
export function ChatUi() {
|
|
6
|
+
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
|
|
7
|
+
api: "/features/chat/api",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex flex-col gap-4" data-testid="chat-ui">
|
|
12
|
+
<div className="h-96 overflow-y-auto rounded border p-4 space-y-2">
|
|
13
|
+
{messages.map((message) => (
|
|
14
|
+
<div
|
|
15
|
+
key={message.id}
|
|
16
|
+
className={`rounded p-2 ${
|
|
17
|
+
message.role === "user" ? "bg-primary/10 ml-8" : "bg-muted mr-8"
|
|
18
|
+
}`}
|
|
19
|
+
>
|
|
20
|
+
<p className="text-sm">{message.content}</p>
|
|
21
|
+
</div>
|
|
22
|
+
))}
|
|
23
|
+
{isLoading && (
|
|
24
|
+
<div className="bg-muted mr-8 rounded p-2">
|
|
25
|
+
<p className="text-sm text-muted-foreground">Thinking...</p>
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
</div>
|
|
29
|
+
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
30
|
+
<input
|
|
31
|
+
value={input}
|
|
32
|
+
onChange={handleInputChange}
|
|
33
|
+
placeholder="Ask anything..."
|
|
34
|
+
className="flex-1 rounded border px-3 py-2"
|
|
35
|
+
data-testid="chat-input"
|
|
36
|
+
/>
|
|
37
|
+
<button
|
|
38
|
+
type="submit"
|
|
39
|
+
disabled={isLoading || !input.trim()}
|
|
40
|
+
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
|
|
41
|
+
>
|
|
42
|
+
Send
|
|
43
|
+
</button>
|
|
44
|
+
</form>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useChat } from "@ai-sdk/react";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@/shared/lib/minimax-media", () => ({
|
|
4
|
+
synthesizeSpeech: vi.fn().mockResolvedValue("https://example.com/audio.mp3"),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
describe("tts route", () => {
|
|
8
|
+
it("module is importable", async () => {
|
|
9
|
+
const mod = await import("../api/route");
|
|
10
|
+
expect(mod.runtime).toBe("edge");
|
|
11
|
+
expect(typeof mod.POST).toBe("function");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { TtsPlayer } from "../components/tts-player";
|
|
4
|
+
|
|
5
|
+
describe("TtsPlayer", () => {
|
|
6
|
+
it("renders text input", () => {
|
|
7
|
+
render(<TtsPlayer />);
|
|
8
|
+
expect(screen.getByPlaceholderText(/enter text to speak/i)).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("renders speak button", () => {
|
|
12
|
+
render(<TtsPlayer />);
|
|
13
|
+
expect(screen.getByRole("button", { name: /speak/i })).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("button disabled when input empty", () => {
|
|
17
|
+
render(<TtsPlayer />);
|
|
18
|
+
expect(screen.getByRole("button", { name: /speak/i })).toBeDisabled();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { synthesizeSpeech } from "@/shared/lib/minimax-media";
|
|
2
|
+
|
|
3
|
+
export const runtime = "edge";
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
const { text, voiceId } = (await req.json()) as { text: string; voiceId?: string };
|
|
7
|
+
|
|
8
|
+
if (!text || typeof text !== "string") {
|
|
9
|
+
return Response.json({ error: "text is required" }, { status: 400 });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const audioFile = await synthesizeSpeech(text, voiceId);
|
|
13
|
+
return Response.json({ audioFile });
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
export function TtsPlayer() {
|
|
6
|
+
const [text, setText] = useState("");
|
|
7
|
+
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
8
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
setIsLoading(true);
|
|
14
|
+
setError(null);
|
|
15
|
+
|
|
16
|
+
const response = await fetch("/features/tts/api", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({ text }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
setError("Failed to synthesize speech");
|
|
24
|
+
setIsLoading(false);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const data = (await response.json()) as { audioFile: string };
|
|
29
|
+
setAudioUrl(data.audioFile);
|
|
30
|
+
setIsLoading(false);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="space-y-4" data-testid="tts-player">
|
|
35
|
+
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
36
|
+
<textarea
|
|
37
|
+
value={text}
|
|
38
|
+
onChange={(e) => setText(e.target.value)}
|
|
39
|
+
placeholder="Enter text to speak..."
|
|
40
|
+
className="flex-1 rounded border px-3 py-2"
|
|
41
|
+
rows={3}
|
|
42
|
+
/>
|
|
43
|
+
<button
|
|
44
|
+
type="submit"
|
|
45
|
+
disabled={isLoading || !text.trim()}
|
|
46
|
+
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
|
|
47
|
+
>
|
|
48
|
+
{isLoading ? "Synthesizing..." : "Speak"}
|
|
49
|
+
</button>
|
|
50
|
+
</form>
|
|
51
|
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
52
|
+
{audioUrl && (
|
|
53
|
+
<audio controls src={audioUrl} className="w-full">
|
|
54
|
+
<track kind="captions" />
|
|
55
|
+
</audio>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@/shared/lib/minimax-media", () => ({
|
|
4
|
+
generateVideo: vi.fn().mockResolvedValue("task-abc"),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
describe("video route", () => {
|
|
8
|
+
it("module is importable", async () => {
|
|
9
|
+
const mod = await import("../api/route");
|
|
10
|
+
expect(mod.runtime).toBe("edge");
|
|
11
|
+
expect(typeof mod.POST).toBe("function");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { VideoGenerator } from "../components/video-generator";
|
|
4
|
+
|
|
5
|
+
describe("VideoGenerator", () => {
|
|
6
|
+
it("renders prompt input", () => {
|
|
7
|
+
render(<VideoGenerator />);
|
|
8
|
+
expect(screen.getByPlaceholderText(/describe a video/i)).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("renders generate button", () => {
|
|
12
|
+
render(<VideoGenerator />);
|
|
13
|
+
expect(screen.getByRole("button", { name: /generate/i })).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("button disabled when input empty", () => {
|
|
17
|
+
render(<VideoGenerator />);
|
|
18
|
+
expect(screen.getByRole("button", { name: /generate/i })).toBeDisabled();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { generateVideo } from "@/shared/lib/minimax-media";
|
|
2
|
+
|
|
3
|
+
export const runtime = "edge";
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
const { prompt } = (await req.json()) as { prompt: string };
|
|
7
|
+
|
|
8
|
+
if (!prompt || typeof prompt !== "string") {
|
|
9
|
+
return Response.json({ error: "prompt is required" }, { status: 400 });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const taskId = await generateVideo(prompt);
|
|
13
|
+
return Response.json({ taskId });
|
|
14
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
export function VideoGenerator() {
|
|
6
|
+
const [prompt, setPrompt] = useState("");
|
|
7
|
+
const [taskId, setTaskId] = useState<string | null>(null);
|
|
8
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
setIsLoading(true);
|
|
14
|
+
setError(null);
|
|
15
|
+
|
|
16
|
+
const response = await fetch("/features/video/api", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({ prompt }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
setError("Failed to generate video");
|
|
24
|
+
setIsLoading(false);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const data = (await response.json()) as { taskId: string };
|
|
29
|
+
setTaskId(data.taskId);
|
|
30
|
+
setIsLoading(false);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="space-y-4" data-testid="video-generator">
|
|
35
|
+
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
36
|
+
<input
|
|
37
|
+
value={prompt}
|
|
38
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
39
|
+
placeholder="Describe a video..."
|
|
40
|
+
className="flex-1 rounded border px-3 py-2"
|
|
41
|
+
/>
|
|
42
|
+
<button
|
|
43
|
+
type="submit"
|
|
44
|
+
disabled={isLoading || !prompt.trim()}
|
|
45
|
+
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
|
|
46
|
+
>
|
|
47
|
+
{isLoading ? "Generating..." : "Generate"}
|
|
48
|
+
</button>
|
|
49
|
+
</form>
|
|
50
|
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
51
|
+
{taskId && (
|
|
52
|
+
<p className="text-sm text-muted-foreground">Task ID: {taskId}</p>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|