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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/index.js +152 -0
  4. package/package.json +54 -0
  5. package/template/.cursor/agents/business-intelligence.md +38 -0
  6. package/template/.cursor/agents/code-reviewer.md +74 -0
  7. package/template/.cursor/agents/frontend.md +45 -0
  8. package/template/.cursor/agents/security-researcher.md +49 -0
  9. package/template/.cursor/agents/technical-lead.md +36 -0
  10. package/template/.cursor/agents/test-qa.md +85 -0
  11. package/template/.cursor/rules/ai.mdc +53 -0
  12. package/template/.cursor/rules/architecture.mdc +42 -0
  13. package/template/.cursor/rules/coding-standards.mdc +53 -0
  14. package/template/.cursor/rules/components.mdc +33 -0
  15. package/template/.cursor/rules/data-fetching.mdc +63 -0
  16. package/template/.cursor/rules/forms.mdc +59 -0
  17. package/template/.cursor/rules/general.mdc +52 -0
  18. package/template/.cursor/rules/nextjs.mdc +46 -0
  19. package/template/.cursor/rules/security.mdc +54 -0
  20. package/template/.cursor/rules/supabase.mdc +36 -0
  21. package/template/.cursor/rules/testing.mdc +116 -0
  22. package/template/.cursor/skills/create-api-route/SKILL.md +62 -0
  23. package/template/.cursor/skills/create-feature/SKILL.md +52 -0
  24. package/template/.cursor/skills/review-branch/SKILL.md +61 -0
  25. package/template/.cursor/skills/security-audit/SKILL.md +69 -0
  26. package/template/.env.example +24 -0
  27. package/template/.requirements/README.md +15 -0
  28. package/template/.requirements/auth.md +50 -0
  29. package/template/.requirements/template.md +25 -0
  30. package/template/README.md +98 -0
  31. package/template/components.json +19 -0
  32. package/template/drizzle.config.ts +10 -0
  33. package/template/eslint.config.ts +66 -0
  34. package/template/middleware.ts +10 -0
  35. package/template/next.config.ts +31 -0
  36. package/template/package.json.tmpl +62 -0
  37. package/template/playwright.config.ts +22 -0
  38. package/template/src/app/(auth)/login/page.tsx +12 -0
  39. package/template/src/app/(protected)/layout.tsx +19 -0
  40. package/template/src/app/(protected)/page.tsx +16 -0
  41. package/template/src/app/api/auth/callback/route.ts +21 -0
  42. package/template/src/app/globals.css +1 -0
  43. package/template/src/app/layout.tsx +21 -0
  44. package/template/src/e2e/chat.spec.ts +8 -0
  45. package/template/src/e2e/home.spec.ts +8 -0
  46. package/template/src/e2e/login.spec.ts +21 -0
  47. package/template/src/features/auth/__tests__/login-form.test.tsx +43 -0
  48. package/template/src/features/auth/__tests__/use-auth.test.ts +46 -0
  49. package/template/src/features/auth/actions/login.action.ts +38 -0
  50. package/template/src/features/auth/actions/logout.action.ts +10 -0
  51. package/template/src/features/auth/components/login-form.tsx +80 -0
  52. package/template/src/features/auth/hooks/use-auth.ts +17 -0
  53. package/template/src/features/chat/__tests__/chat-ui.test.tsx +30 -0
  54. package/template/src/features/chat/__tests__/route.test.ts +19 -0
  55. package/template/src/features/chat/api/route.ts +16 -0
  56. package/template/src/features/chat/components/chat-ui.tsx +47 -0
  57. package/template/src/features/chat/hooks/use-chat.ts +1 -0
  58. package/template/src/features/tts/__tests__/route.test.ts +13 -0
  59. package/template/src/features/tts/__tests__/tts-player.test.tsx +20 -0
  60. package/template/src/features/tts/api/route.ts +14 -0
  61. package/template/src/features/tts/components/tts-player.tsx +59 -0
  62. package/template/src/features/video/__tests__/route.test.ts +13 -0
  63. package/template/src/features/video/__tests__/video-generator.test.tsx +20 -0
  64. package/template/src/features/video/api/route.ts +14 -0
  65. package/template/src/features/video/components/video-generator.tsx +56 -0
  66. package/template/src/shared/__tests__/ai.test.ts +8 -0
  67. package/template/src/shared/__tests__/minimax-media.test.ts +57 -0
  68. package/template/src/shared/__tests__/providers.test.tsx +14 -0
  69. package/template/src/shared/__tests__/schema.test.ts +18 -0
  70. package/template/src/shared/__tests__/supabase-client.test.ts +13 -0
  71. package/template/src/shared/__tests__/supabase-server.test.ts +22 -0
  72. package/template/src/shared/components/providers.tsx +19 -0
  73. package/template/src/shared/db/index.ts +7 -0
  74. package/template/src/shared/db/schema.ts +15 -0
  75. package/template/src/shared/lib/ai.ts +8 -0
  76. package/template/src/shared/lib/minimax-media.ts +63 -0
  77. package/template/src/shared/lib/supabase/client.ts +8 -0
  78. package/template/src/shared/lib/supabase/middleware.ts +40 -0
  79. package/template/src/shared/lib/supabase/server.ts +26 -0
  80. package/template/src/test-setup.ts +1 -0
  81. package/template/tailwind.css +20 -0
  82. package/template/tsconfig.json +31 -0
  83. 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,8 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test.describe("Chat feature", () => {
4
+ test("login page is accessible", async ({ page }) => {
5
+ await page.goto("/login");
6
+ await expect(page.getByTestId("login-form")).toBeVisible();
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test.describe("Home page", () => {
4
+ test("shows login page when not authenticated", async ({ page }) => {
5
+ await page.goto("/");
6
+ await expect(page).toHaveURL(/login/);
7
+ });
8
+ });
@@ -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
+ }