feat: implement message CRUD and dark mode

- Add Message, Recipient models and migration
- Create message API routes (GET, POST, PATCH, DELETE)
- Fix IDOR vulnerability in PATCH (verify ownership before delete)
- Add dashboard and message pages (list, new, detail)
- Add UI components (card, input, label, textarea)
- Enable dark mode in layout
- Add session user id typing to NextAuth
- Add AGENTS.md memory bank documentation
This commit is contained in:
Yusuf İpek
2025-12-24 15:07:20 +03:00
parent c2da0437d0
commit 1626106a8b
17 changed files with 851 additions and 3 deletions
+115
View File
@@ -0,0 +1,115 @@
# Cline's Memory Bank
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
## Memory Bank Structure
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
flowchart TD
PB[projectbrief.md] --> PC[productContext.md]
PB --> SP[systemPatterns.md]
PB --> TC[techContext.md]
PC --> AC[activeContext.md]
SP --> AC
TC --> AC
AC --> P[progress.md]
### Core Files (Required)
1. `projectbrief.md`
- Foundation document that shapes all other files
- Created at project start if it doesn't exist
- Defines core requirements and goals
- Source of truth for project scope
2. `productContext.md`
- Why this project exists
- Problems it solves
- How it should work
- User experience goals
3. `activeContext.md`
- Current work focus
- Recent changes
- Next steps
- Active decisions and considerations
- Important patterns and preferences
- Learnings and project insights
4. `systemPatterns.md`
- System architecture
- Key technical decisions
- Design patterns in use
- Component relationships
- Critical implementation paths
5. `techContext.md`
- Technologies used
- Development setup
- Technical constraints
- Dependencies
- Tool usage patterns
6. `progress.md`
- What works
- What's left to build
- Current status
- Known issues
- Evolution of project decisions
### Additional Context
Create additional files/folders within memory-bank/ when they help organize:
- Complex feature documentation
- Integration specifications
- API documentation
- Testing strategies
- Deployment procedures
## Core Workflows
### Plan Mode
flowchart TD
Start[Start] --> ReadFiles[Read Memory Bank]
ReadFiles --> CheckFiles{Files Complete?}
CheckFiles -->|No| Plan[Create Plan]
Plan --> Document[Document in Chat]
CheckFiles -->|Yes| Verify[Verify Context]
Verify --> Strategy[Develop Strategy]
Strategy --> Present[Present Approach]
### Act Mode
flowchart TD
Start[Start] --> Context[Check Memory Bank]
Context --> Update[Update Documentation]
Update --> Execute[Execute Task]
Execute --> Document[Document Changes]
## Documentation Updates
Memory Bank updates occur when:
1. Discovering new project patterns
2. After implementing significant changes
3. When user requests with **update memory bank** (MUST review ALL files)
4. When context needs clarification
flowchart TD
Start[Update Process]
subgraph Process
P1[Review ALL Files]
P2[Document Current State]
P3[Clarify Next Steps]
P4[Document Insights & Patterns]
P1 --> P2 --> P3 --> P4
end
Start --> Process
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state.
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
+48 -1
View File
@@ -11,6 +11,7 @@
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -1645,6 +1646,52 @@
} }
} }
}, },
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -2053,7 +2100,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
+1
View File
@@ -12,6 +12,7 @@
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"lastPing" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"checkInterval" INTEGER NOT NULL DEFAULT 24,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Recipient" (
"id" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"email" TEXT NOT NULL,
CONSTRAINT "Recipient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Attachment" (
"id" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"fileUrl" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
CONSTRAINT "Attachment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+31 -1
View File
@@ -1,4 +1,3 @@
// ... existing code ...
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@@ -42,6 +41,37 @@ model User {
image String? image String?
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
messages Message[]
}
model Message {
id String @id @default(cuid())
userId String
title String
content String? @db.Text
status String @default("DRAFT")
lastPing DateTime @default(now())
checkInterval Int @default(24)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
recipients Recipient[]
attachments Attachment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Recipient {
id String @id @default(cuid())
messageId String
email String
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
}
model Attachment {
id String @id @default(cuid())
messageId String
fileUrl String
fileName String
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
} }
model VerificationToken { model VerificationToken {
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/options";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ messageId: string }> }
) {
const { messageId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 });
}
try {
const body = await req.json();
const { title, content, recipients, checkInterval } = body;
const existingMessage = await prisma.message.findUnique({
where: { id: messageId, userId: session.user.id },
});
if (!existingMessage) {
return new NextResponse("Unauthorized", { status: 401 });
}
// Delete existing recipients and recreate them for simplicity in update
await prisma.recipient.deleteMany({
where: { messageId: messageId },
});
const message = await prisma.message.update({
where: {
id: messageId,
userId: session.user.id,
},
data: {
title,
content,
checkInterval,
recipients: {
create: recipients.map((email: string) => ({ email })),
},
},
});
return NextResponse.json(message);
} catch (error) {
console.error("MESSAGE_PATCH_ERROR", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ messageId: string }> }
) {
const { messageId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 });
}
try {
const message = await prisma.message.delete({
where: {
id: messageId,
userId: session.user.id,
},
});
return NextResponse.json(message);
} catch (error) {
console.error("MESSAGE_DELETE_ERROR", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/options";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 });
}
try {
const body = await req.json();
const { title, content, recipients, checkInterval } = body;
const message = await prisma.message.create({
data: {
userId: session.user.id,
title,
content,
checkInterval,
recipients: {
create: recipients.map((email: string) => ({ email })),
},
},
});
return NextResponse.json(message);
} catch (error) {
console.error("MESSAGE_POST_ERROR", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 });
}
try {
const messages = await prisma.message.findMany({
where: {
userId: session.user.id,
},
include: {
recipients: true,
},
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(messages);
} catch (error) {
console.error("MESSAGES_GET_ERROR", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -0,0 +1,47 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/options";
import { redirect } from "next/navigation";
import MessageForm from "@/components/messages/message-form";
export default async function EditMessagePage({
params,
}: {
params: Promise<{ messageId: string }>;
}) {
const { messageId } = await params;
const session = await getServerSession(authOptions);
if (!session) {
redirect("/auth/signin");
}
const message = await prisma.message.findUnique({
where: {
id: messageId,
userId: session.user.id,
},
include: {
recipients: true,
},
});
if (!message) {
redirect("/dashboard");
}
const initialData = {
id: message.id,
title: message.title,
content: message.content || "",
checkInterval: message.checkInterval,
recipients: message.recipients.map((r) => r.email),
};
return (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-bold mb-6 text-center">Mesajı Düzenle</h1>
<MessageForm initialData={initialData} />
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import MessageForm from "@/components/messages/message-form";
export default function NewMessagePage() {
return (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-bold mb-6 text-center">Yeni Ö Adam Anahtarı Oluştur</h1>
<MessageForm />
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Plus, MessageSquare, Edit2 } from "lucide-react";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/options";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/auth/signin");
}
const messages = await prisma.message.findMany({
where: { userId: session.user.id },
include: { recipients: true },
orderBy: { createdAt: "desc" },
});
return (
<div className="container mx-auto py-10 space-y-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Panelim</h1>
<Link href="/dashboard/messages/new">
<Button>
<Plus className="mr-2 h-4 w-4" /> Yeni Mesaj
</Button>
</Link>
</div>
{messages.length === 0 ? (
<Card className="flex flex-col items-center justify-center p-12 text-center">
<MessageSquare className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-xl font-semibold">Henüz mesajınız yok</h3>
<p className="text-muted-foreground mb-6">
İlk "Ölü Adam Anahtarı" mesajınızı oluşturarak başlayın.
</p>
<Link href="/dashboard/messages/new">
<Button variant="outline">Oluştur</Button>
</Link>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{messages.map((msg) => (
<Card key={msg.id} className="relative group">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="truncate pr-8">{msg.title}</CardTitle>
<Link href={`/dashboard/messages/${msg.id}`}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit2 className="h-4 w-4" />
</Button>
</Link>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
Durum: <span className="font-medium text-foreground">{msg.status}</span>
</p>
<p className="text-sm text-muted-foreground">
Alıcılar: {msg.recipients.length}
</p>
<p className="text-sm text-muted-foreground">
Kontrol: {msg.checkInterval} saat başı
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className="dark">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
import { Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
interface MessageFormProps {
initialData?: {
id: string;
title: string;
content: string;
recipients: string[];
checkInterval: number;
};
}
export default function MessageForm({ initialData }: MessageFormProps) {
const [title, setTitle] = useState(initialData?.title || "");
const [content, setContent] = useState(initialData?.content || "");
const [recipients, setRecipients] = useState(initialData?.recipients || [""]);
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24);
const [loading, setLoading] = useState(false);
const router = useRouter();
const addRecipient = () => setRecipients([...recipients, ""]);
const removeRecipient = (index: number) => setRecipients(recipients.filter((_, i) => i !== index));
const updateRecipient = (index: number, value: string) => {
const newRecipients = [...recipients];
newRecipients[index] = value;
setRecipients(newRecipients);
};
const deleteMessage = async () => {
if (!initialData?.id || !confirm("Bu mesajı silmek istediğinize emin misiniz?")) return;
setLoading(true);
try {
const response = await fetch(`/api/messages/${initialData.id}`, {
method: "DELETE",
});
if (response.ok) {
router.push("/dashboard");
router.refresh();
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const url = initialData?.id
? `/api/messages/${initialData.id}`
: "/api/messages";
const method = initialData?.id ? "PATCH" : "POST";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content, recipients, checkInterval }),
});
if (response.ok) {
router.push("/dashboard");
router.refresh();
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<Card className="w-full max-w-2xl mx-auto">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{initialData ? "Mesajı Düzenle" : "Yeni Mesaj Oluştur"}</CardTitle>
{initialData && (
<Button
type="button"
variant="destructive"
size="sm"
onClick={deleteMessage}
disabled={loading}
>
<Trash2 className="h-4 w-4 mr-2" /> Sil
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Başlık</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Örn: Vasiyetim"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">Mesaj İçeriği</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Mesajınızı buraya yazın..."
rows={5}
/>
</div>
<div className="space-y-2">
<Label>Alıcılar</Label>
{recipients.map((recipient, index) => (
<div key={index} className="flex gap-2">
<Input
value={recipient}
onChange={(e) => updateRecipient(index, e.target.value)}
placeholder="[email protected]"
type="email"
required
/>
{recipients.length > 1 && (
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => removeRecipient(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addRecipient}
className="mt-2"
>
<Plus className="h-4 w-4 mr-2" /> Alıcı Ekle
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="interval">Kontrol Aralığı (Saat)</Label>
<Input
id="interval"
type="number"
value={checkInterval}
onChange={(e) => setCheckInterval(parseInt(e.target.value))}
min={1}
required
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Kaydediliyor..." : "Mesajı Kaydet"}
</Button>
</CardFooter>
</Card>
</form>
);
}
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+9
View File
@@ -1,8 +1,17 @@
import { NextAuthOptions } from "next-auth"; import { NextAuthOptions } from "next-auth";
import { DefaultSession } from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import GoogleProvider from "next-auth/providers/google"; import GoogleProvider from "next-auth/providers/google";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
} & DefaultSession["user"];
}
}
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
providers: [ providers: [