Refactor code structure for improved readability and maintainability

This commit is contained in:
Poyraz Avsever
2026-05-08 11:18:55 +03:00
parent e98972597d
commit 9adc34418e
21 changed files with 1194 additions and 393 deletions
View File
+19
View File
@@ -0,0 +1,19 @@
> mood-tracker-mvp@0.1.0 dev D:\Poyraz\kodlama\Introduction-to-Data-Visualization-Project-Assignment
> next dev "--port" "3010"
▲ Next.js 16.2.6 (Turbopack)
- Local: http://localhost:3010
- Network: http://192.168.0.114:3010
- Environments: .env.local
✓ Ready in 1070ms
Creating turbopack project {
dir: 'D:\\Poyraz\\kodlama\\Introduction-to-Data-Visualization-Project-Assignment',
testMode: true
}
○ Compiling /login ...
GET /login 200 in 8.7s (next.js: 7.6s, proxy.ts: 335ms, application-code: 738ms)
GET /register 200 in 8.4s (next.js: 8.0s, proxy.ts: 10ms, application-code: 395ms)
[?25h
ELIFECYCLE Command failed with exit code 1.
+38 -38
View File
@@ -1,19 +1,19 @@
"use client";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { analyzeJournalWithLocalAI } from "@/lib/ai";
import { db } from "@/lib/db";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -31,22 +31,23 @@ export default function JournalPage() {
const [energy, setEnergy] = useState("3");
const [isSubmitting, setIsSubmitting] = useState(false);
// Dexie.js üzerinden günlükleri tarih sırasına göre çekiyoruz (en yeni en üstte)
// Dexie.js üzerinden günlükleri tarih sırasına göre çekiyoruz (en yeni en üstte).
const journals = useLiveQuery(() =>
db.journals.orderBy("date").reverse().toArray(),
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!content.trim()) return;
setIsSubmitting(true);
const now = new Date().toISOString();
const entryId = uuidv4();
const currentContent = content; // Analiz için metni kopyala
const currentContent = content;
try {
// 1. Veriyi veritabanına hemen ekle (Kullanıcı beklemesin)
// Veriyi önce kaydet, AI analizini daha sonra arka planda tamamla.
await db.journals.add({
id: entryId,
date: now.split("T")[0],
@@ -57,12 +58,10 @@ export default function JournalPage() {
updated_at: now,
});
// UI'ı Sıfırla
setContent("");
setEnergy("3");
setMood("happy");
// 2. Arka planda AI Analizi başlat
try {
const aiResult = await analyzeJournalWithLocalAI(currentContent);
if (aiResult) {
@@ -72,8 +71,7 @@ export default function JournalPage() {
ai_summary: aiResult.ai_summary,
});
// Eğer AI görev önerdiyse Görevler tablosuna at (pending / onay bekliyor yapısı eklenebilir ama direkt atalım)
if (aiResult.suggested_tasks && aiResult.suggested_tasks.length > 0) {
if (aiResult.suggested_tasks?.length) {
for (const taskTitle of aiResult.suggested_tasks) {
await db.tasks.add({
id: uuidv4(),
@@ -87,8 +85,8 @@ export default function JournalPage() {
}
}
}
} catch (aiErr) {
console.error("Arka plan AI analizi hatası:", aiErr);
} catch (aiError) {
console.error("Arka plan AI analizi hatası:", aiError);
}
} catch (error) {
console.error("Günlük kaydedilemedi:", error);
@@ -104,7 +102,7 @@ export default function JournalPage() {
};
return (
<div className="space-y-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="mx-auto max-w-4xl animate-in space-y-6 fade-in slide-in-from-bottom-4 duration-500">
<div>
<h1 className="text-3xl font-bold tracking-tight">Günlük</h1>
<p className="text-muted-foreground">
@@ -119,27 +117,28 @@ export default function JournalPage() {
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Ruh Hali</Label>
<div className="flex gap-2">
{moods.map((m) => (
{moods.map((currentMood) => (
<button
key={m.id}
key={currentMood.id}
type="button"
onClick={() => setMood(m.id)}
className={`flex-1 py-2 px-1 rounded-md border text-xl flex items-center justify-center transition-colors ${
mood === m.id
? "bg-primary/20 border-primary"
: "bg-card border-border hover:bg-muted"
onClick={() => setMood(currentMood.id)}
className={`flex flex-1 items-center justify-center rounded-md border px-1 py-2 text-xl transition-colors ${
mood === currentMood.id
? "border-primary bg-primary/20"
: "border-border bg-card hover:bg-muted"
}`}
title={m.label}
title={currentMood.label}
>
{m.emoji}
{currentMood.emoji}
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Enerji Seviyesi (1-5): {energy}</Label>
<Input
@@ -148,7 +147,7 @@ export default function JournalPage() {
max="5"
step="1"
value={energy}
onChange={(e) => setEnergy(e.target.value)}
onChange={(event) => setEnergy(event.target.value)}
className="w-full"
/>
</div>
@@ -159,7 +158,7 @@ export default function JournalPage() {
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={(event) => setContent(event.target.value)}
className="min-h-[150px] resize-y"
placeholder="Örneğin: Bugün toplantıda işler ters gitti..."
/>
@@ -190,13 +189,14 @@ export default function JournalPage() {
<Card key={journal.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<span>
{moods.find((m) => m.id === journal.mood)?.emoji}
{moods.find((currentMood) => currentMood.id === journal.mood)
?.emoji ?? "📝"}
</span>
<span>{journal.date}</span>
</CardTitle>
<span className="text-xs px-2 py-1 bg-muted rounded-md font-medium text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
Enerji: {journal.energy}/5
</span>
</div>
@@ -206,30 +206,30 @@ export default function JournalPage() {
{journal.content}
</p>
{/* AI Sonuçlarını Göster */}
{journal.ai_tags && (
<div className="mt-4 flex flex-wrap gap-2">
{journal.ai_tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-md"
className="rounded-md bg-primary/10 px-2 py-1 text-xs text-primary"
>
{tag}
</span>
))}
</div>
)}
{journal.ai_summary && (
<div className="mt-3 p-3 bg-muted/40 rounded-lg border text-sm text-muted-foreground italic border-l-2 border-l-primary">
" {journal.ai_summary} "
<div className="mt-3 rounded-lg border border-l-2 border-l-primary bg-muted/40 p-3 text-sm italic text-muted-foreground">
&ldquo;{journal.ai_summary}&rdquo;
</div>
)}
<div className="flex justify-end mt-4">
<div className="mt-4 flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-500/10"
className="text-red-500 hover:bg-red-500/10 hover:text-red-600"
onClick={() => handleDelete(journal.id)}
>
Sil
+43 -12
View File
@@ -1,20 +1,51 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { DashboardShell } from "@/components/layout/dashboard-shell";
import { createClient } from "@/lib/supabase/server";
export default function DashboardLayout({
export default async function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: profile } = user
? await supabase
.from("profiles")
.select("first_name, last_name, avatar_url")
.eq("id", user.id)
.maybeSingle()
: { data: null };
const fallbackName = user?.email?.split("@")[0] ?? "MindSpace Kullanıcısı";
const displayName =
[profile?.first_name, profile?.last_name].filter(Boolean).join(" ") ||
fallbackName;
const shortName = displayName
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
.slice(0, 2) || "MS";
return (
<div className="flex min-h-screen bg-background text-foreground">
<Sidebar />
<div className="flex flex-col flex-1 h-screen overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6 md:p-8">
{children}
</main>
</div>
</div>
<DashboardShell
user={{
email: user?.email ?? "bilinmiyor@mindspace.local",
displayName,
shortName,
avatarUrl:
profile?.avatar_url ||
user?.user_metadata?.avatar_url ||
user?.user_metadata?.picture ||
null,
}}
>
{children}
</DashboardShell>
);
}
+81 -92
View File
@@ -1,23 +1,27 @@
"use client";
import { useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Activity, Brain, PenTool, CheckCircle } from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "@/lib/db";
import { Activity, Brain, CheckCircle, PenTool } from "lucide-react";
import {
LineChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from "recharts";
import { db, type Journal, type Task } from "@/lib/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const EMPTY_JOURNALS: Journal[] = [];
const EMPTY_TASKS: Task[] = [];
const moodScores: Record<string, number> = {
happy: 4,
neutral: 3,
@@ -26,65 +30,71 @@ const moodScores: Record<string, number> = {
};
export default function DashboardPage() {
const journals =
useLiveQuery(() => db.journals.orderBy("date").toArray()) || [];
const tasks = useLiveQuery(() => db.tasks.toArray()) || [];
const liveJournals = useLiveQuery<Journal[]>(() =>
db.journals.orderBy("date").toArray(),
);
const liveTasks = useLiveQuery<Task[]>(() => db.tasks.toArray());
const pendingTasksCount = tasks.filter((t) => t.status === "todo").length;
const journals = liveJournals ?? EMPTY_JOURNALS;
const tasks = liveTasks ?? EMPTY_TASKS;
const pendingTasksCount = tasks.filter((task) => task.status === "todo").length;
// Bugüne ait enerji seviyesi
const today = new Date().toISOString().split("T")[0];
const todaysJournal = journals.find((j) => j.date === today);
const todaysJournal = journals.find((journal) => journal.date === today);
const todaysEnergy = todaysJournal ? todaysJournal.energy : "--";
// 1. Grafik: Son 7 günün Ruh Hali ve Enerji trendi
const trendData = useMemo(() => {
const dataMap: Record<
string,
{ date: string; moodSum: number; energySum: number; count: number }
> = {};
journals.forEach((j) => {
if (!dataMap[j.date]) {
dataMap[j.date] = { date: j.date, moodSum: 0, energySum: 0, count: 0 };
journals.forEach((journal) => {
if (!dataMap[journal.date]) {
dataMap[journal.date] = {
date: journal.date,
moodSum: 0,
energySum: 0,
count: 0,
};
}
dataMap[j.date].moodSum += moodScores[j.mood] || 3;
dataMap[j.date].energySum += j.energy;
dataMap[j.date].count += 1;
dataMap[journal.date].moodSum += moodScores[journal.mood] || 3;
dataMap[journal.date].energySum += journal.energy;
dataMap[journal.date].count += 1;
});
return Object.values(dataMap)
.map((d) => ({
date: d.date.slice(5), // Sadece MM-DD formatı alalım
Mood: Number((d.moodSum / d.count).toFixed(1)),
Enerji: Number((d.energySum / d.count).toFixed(1)),
.map((entry) => ({
date: entry.date.slice(5),
Mood: Number((entry.moodSum / entry.count).toFixed(1)),
Enerji: Number((entry.energySum / entry.count).toFixed(1)),
}))
.slice(-7); // Sadece son 7 günü göster
.slice(-7);
}, [journals]);
// 2. Grafik: En çok kullanılan AI etiketleri
const tagData = useMemo(() => {
const counts: Record<string, number> = {};
journals.forEach((j) => {
if (j.ai_tags) {
j.ai_tags.forEach((tag) => {
counts[tag] = (counts[tag] || 0) + 1;
});
}
journals.forEach((journal) => {
journal.ai_tags?.forEach((tag) => {
counts[tag] = (counts[tag] || 0) + 1;
});
});
return Object.entries(counts)
.map(([name, value]) => ({ name, Değer: value }))
.sort((a, b) => b.Değer - a.Değer)
.slice(0, 5); // En çok geçen 5 etiket
.slice(0, 5);
}, [journals]);
// Son günlüğe ait AI Summary
const lastInsight = [...journals]
.reverse()
.find((j) => j.ai_summary)?.ai_summary;
.find((journal) => journal.ai_summary)?.ai_summary;
return (
<div className="space-y-6 max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="mx-auto max-w-6xl animate-in space-y-6 fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Bugün Nasılsın?</h1>
<p className="text-muted-foreground">
@@ -94,15 +104,13 @@ export default function DashboardPage() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">
Günlük Kayıtları
</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Günlük Kayıtları</CardTitle>
<PenTool className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{journals.length}</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="mt-1 text-xs text-muted-foreground">
{journals.length === 0
? "Henüz kayıt girilmedi"
: "Toplam kayıt eklendi"}
@@ -111,58 +119,49 @@ export default function DashboardPage() {
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">
Aktif Görevler
</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Aktif Görevler</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{pendingTasksCount}</div>
<p className="text-xs text-muted-foreground mt-1">Bekleyen görev</p>
<p className="mt-1 text-xs text-muted-foreground">Bekleyen görev</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">
Bugünkü Enerji
</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Bugünkü Enerji</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{todaysEnergy}</div>
<p className="text-xs text-muted-foreground mt-1">
{todaysJournal ? "/ 5 Seviyesinde" : "Kayıt bekleniyor"}
<p className="mt-1 text-xs text-muted-foreground">
{todaysJournal ? "/ 5 seviyesinde" : "Kayıt bekleniyor"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">
Local AI Durumu
</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Local AI Durumu</CardTitle>
<Brain className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-500">Hazır</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="mt-1 text-xs text-muted-foreground">
Ollama / Veri bekliyor
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7 ">
{/* Line Chart */}
<Card className="lg:col-span-4 p-6 flex flex-col justify-between">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="flex flex-col justify-between p-6 lg:col-span-4">
<div className="mb-4">
<h3 className="text-lg font-medium mb-1">
Ruh Hali & Enerji Trendi
</h3>
<h3 className="mb-1 text-lg font-medium">Ruh Hali ve Enerji Trendi</h3>
<p className="text-sm text-muted-foreground">
Son 7 günlük ortalamalar (1-5 Arası Puanlama)
Son 7 günlük ortalamalar (1-5 arası puanlama)
</p>
</div>
<div className="h-[250px] w-full">
@@ -196,9 +195,7 @@ export default function DashboardPage() {
}}
itemStyle={{ fontWeight: "500" }}
/>
<Legend
wrapperStyle={{ paddingTop: "10px", fontSize: "14px" }}
/>
<Legend wrapperStyle={{ paddingTop: "10px", fontSize: "14px" }} />
<Line
type="monotone"
dataKey="Mood"
@@ -218,32 +215,25 @@ export default function DashboardPage() {
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center border-2 border-dashed rounded-md bg-muted/20">
<p className="text-sm text-muted-foreground">
Veri bekleniyor...
</p>
<div className="flex h-full items-center justify-center rounded-md border-2 border-dashed bg-muted/20">
<p className="text-sm text-muted-foreground">Veri bekleniyor...</p>
</div>
)}
</div>
</Card>
{/* Bar Chart & Insight */}
<div className="lg:col-span-3 space-y-4 flex flex-col">
<Card className="p-6 flex-1 flex flex-col">
<div className="flex flex-col space-y-4 lg:col-span-3">
<Card className="flex flex-1 flex-col p-6">
<div className="mb-4">
<h3 className="text-lg font-medium mb-1">AI Konu Dağılımı</h3>
<h3 className="mb-1 text-lg font-medium">AI Konu Dağılımı</h3>
<p className="text-sm text-muted-foreground">
Günlüklerinden çıkarılan en sık 5 etiket
</p>
</div>
<div className="h-[150px] w-full mt-auto">
<div className="mt-auto h-[150px] w-full">
{tagData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={tagData}
layout="vertical"
margin={{ left: -20 }}
>
<BarChart data={tagData} layout="vertical" margin={{ left: -20 }}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={false}
@@ -276,20 +266,19 @@ export default function DashboardPage() {
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center border-2 border-dashed rounded-md bg-muted/20">
<p className="text-sm text-muted-foreground">
Yeterli etiket yok.
</p>
<div className="flex h-full items-center justify-center rounded-md border-2 border-dashed bg-muted/20">
<p className="text-sm text-muted-foreground">Yeterli etiket yok.</p>
</div>
)}
</div>
</Card>
<Card className="p-6 bg-primary/5 border-primary/20 flex-1">
<h3 className="text-lg font-medium mb-3 flex items-center gap-2">
<Brain className="w-5 h-5 text-primary" /> Son AI İçgörüsü
<Card className="flex-1 border-primary/20 bg-primary/5 p-6">
<h3 className="mb-3 flex items-center gap-2 text-lg font-medium">
<Brain className="h-5 w-5 text-primary" />
Son AI İçgörüsü
</h3>
<p className="text-sm text-foreground/80 leading-relaxed italic">
<p className="text-sm italic leading-relaxed text-foreground/80">
{lastInsight
? `"${lastInsight}"`
: "Henüz bir içgörü oluşmadı. Biraz günlük yaz, AI analiz yapsın."}
+32 -25
View File
@@ -1,12 +1,22 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
type ProfileUpdateData = {
first_name: string
last_name: string
avatar_url?: string
}
export async function updateProfile(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { error: 'Kullanıcı bulunamadı.' }
}
@@ -15,46 +25,45 @@ export async function updateProfile(formData: FormData) {
const lastName = formData.get('lastName') as string
const avatarFile = formData.get('avatar') as File | null
let avatarUrl = undefined
let avatarUrl: string | undefined
// Upload avatar if a new file is provided
if (avatarFile && avatarFile.size > 0) {
const fileExt = avatarFile.name.split('.').pop()
const fileName = `${user.id}/${Math.random()}.${fileExt}`
const { error: uploadError, data: uploadData } = await supabase.storage
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, avatarFile, { upsert: true })
if (uploadError) {
return { error: 'Profil fotoğrafı yüklenirken hata oluştu: ' + uploadError.message }
return {
error: `Profil fotoğrafı yüklenirken hata oluştu: ${uploadError.message}`,
}
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName)
const {
data: { publicUrl },
} = supabase.storage.from('avatars').getPublicUrl(fileName)
avatarUrl = publicUrl
}
// Update profile
const updateData: any = {
const updateData: ProfileUpdateData = {
first_name: firstName,
last_name: lastName,
}
if (avatarUrl) {
updateData.avatar_url = avatarUrl
}
// Upsert profile in case it doesn't exist yet
const { error } = await supabase
.from('profiles')
.upsert({ id: user.id, ...updateData })
const { error } = await supabase.from('profiles').upsert({
id: user.id,
...updateData,
})
if (error) {
return { error: 'Profil güncellenirken hata oluştu: ' + error.message }
return { error: `Profil güncellenirken hata oluştu: ${error.message}` }
}
revalidatePath('/settings')
@@ -64,17 +73,15 @@ export async function updateProfile(formData: FormData) {
export async function updatePassword(formData: FormData) {
const supabase = await createClient()
const password = formData.get('password') as string
if (!password || password.length < 6) {
return { error: 'Şifre en az 6 karakter olmalıdır.' }
}
const { error } = await supabase.auth.updateUser({
password: password
})
const { error } = await supabase.auth.updateUser({ password })
if (error) {
return { error: 'Şifre güncellenirken hata oluştu: ' + error.message }
return { error: `Şifre güncellenirken hata oluştu: ${error.message}` }
}
return { success: true }
+160 -81
View File
@@ -1,137 +1,201 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Bot, KeyRound, Save, User } from "lucide-react";
import { updatePassword, updateProfile } from "./actions";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Bot, Save, User, KeyRound } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { updateProfile, updatePassword } from "./actions";
type AiProvider = "groq" | "ollama" | "openai";
function getInitialAiProvider(): AiProvider {
if (typeof window === "undefined") {
return "groq";
}
const savedProvider = localStorage.getItem("mindspace_ai_provider");
return savedProvider === "ollama" || savedProvider === "openai"
? savedProvider
: "groq";
}
function getInitialApiKey() {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem("mindspace_api_key") ?? "";
}
export default function SettingsPage() {
const [aiProvider, setAiProvider] = useState("groq");
const [apiKey, setApiKey] = useState("");
const [aiProvider, setAiProvider] = useState<AiProvider>(getInitialAiProvider);
const [apiKey, setApiKey] = useState(getInitialApiKey);
const [saveStatus, setSaveStatus] = useState("");
// Profile state
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [profileSaveStatus, setProfileSaveStatus] = useState("");
const [passwordSaveStatus, setPasswordSaveStatus] = useState("");
const [supabase] = useState(() => createClient());
const formRef = useRef<HTMLFormElement>(null);
const supabase = createClient();
useEffect(() => {
const savedProvider = localStorage.getItem("mindspace_ai_provider");
const savedApiKey = localStorage.getItem("mindspace_api_key");
if (savedProvider) setAiProvider(savedProvider);
if (savedApiKey) setApiKey(savedApiKey);
// Fetch user profile
async function fetchProfile() {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data } = await supabase.from("profiles").select("*").eq("id", user.id).single();
if (data) {
setFirstName(data.first_name || "");
setLastName(data.last_name || "");
setAvatarUrl(data.avatar_url || "");
}
let isActive = true;
const fetchProfile = async () => {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isActive) {
return;
}
}
fetchProfile();
}, []);
const { data } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
if (!data || !isActive) {
return;
}
setFirstName(data.first_name || "");
setLastName(data.last_name || "");
setAvatarUrl(data.avatar_url || "");
};
void fetchProfile();
return () => {
isActive = false;
};
}, [supabase]);
const handleSaveAI = () => {
localStorage.setItem("mindspace_ai_provider", aiProvider);
localStorage.setItem("mindspace_api_key", apiKey);
setSaveStatus("Ayarlar başarıyla kaydedildi!");
setTimeout(() => setSaveStatus(""), 3000);
window.setTimeout(() => setSaveStatus(""), 3000);
};
const handleProfileAction = async (formData: FormData) => {
const res = await updateProfile(formData);
if (res?.error) {
setProfileSaveStatus("Hata: " + res.error);
const response = await updateProfile(formData);
if (response?.error) {
setProfileSaveStatus(`Hata: ${response.error}`);
} else {
setProfileSaveStatus("Profil başarıyla güncellendi!");
if (formData.get("avatar") && (formData.get("avatar") as File).size > 0) {
const avatar = formData.get("avatar");
if (avatar instanceof File && avatar.size > 0) {
window.location.reload();
}
}
setTimeout(() => setProfileSaveStatus(""), 3000);
window.setTimeout(() => setProfileSaveStatus(""), 3000);
};
const handlePasswordAction = async (formData: FormData) => {
const res = await updatePassword(formData);
if (res?.error) {
setPasswordSaveStatus("Hata: " + res.error);
const response = await updatePassword(formData);
if (response?.error) {
setPasswordSaveStatus(`Hata: ${response.error}`);
} else {
setPasswordSaveStatus("Şifre başarıyla güncellendi!");
formRef.current?.reset();
}
setTimeout(() => setPasswordSaveStatus(""), 3000);
window.setTimeout(() => setPasswordSaveStatus(""), 3000);
};
return (
<div className="flex flex-col gap-6 p-4 max-w-2xl mx-auto w-full">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Ayarlar</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
Kullanıcı profili ve yapay zeka asistanı yapılandırmanızı yönetin.
</p>
</div>
{/* Profile Settings */}
<div className="bg-card border border-border rounded-xl p-6 shadow-sm space-y-6 flex flex-col">
<div className="flex flex-col space-y-6 rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3 border-b border-border/50 pb-4">
<User className="w-6 h-6 text-primary" />
<User className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold">Kullanıcı Profili</h2>
</div>
<form action={handleProfileAction} className="space-y-4">
<div className="flex items-center gap-4 mb-6">
<div className="mb-6 flex items-center gap-4">
{avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="w-16 h-16 rounded-full object-cover border border-border" />
<>
{/* Avatar URL dış kaynaklı olduğu için bu önizlemede native img kullanıyoruz. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={avatarUrl}
alt="Avatar"
className="h-16 w-16 rounded-full border border-border object-cover"
/>
</>
) : (
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center border border-border">
<User className="w-8 h-8 text-muted-foreground" />
</div>
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-border bg-muted">
<User className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div className="space-y-1 flex-1">
<div className="flex-1 space-y-1">
<Label htmlFor="avatar">Profil Fotoğrafı Yükle</Label>
<Input id="avatar" name="avatar" type="file" accept="image/*" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Ad</Label>
<Input id="firstName" name="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<Input
id="firstName"
name="firstName"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Soyad</Label>
<Input id="lastName" name="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} />
<Input
id="lastName"
name="lastName"
value={lastName}
onChange={(event) => setLastName(event.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-4 mt-4">
<div className="mt-4 flex items-center gap-4">
<Button type="submit" className="w-max">
<Save className="w-4 h-4 mr-2" />
<Save className="mr-2 h-4 w-4" />
Profili Kaydet
</Button>
{profileSaveStatus && (
<span className={`text-sm ${profileSaveStatus.startsWith("Hata") ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
<span
className={`text-sm ${
profileSaveStatus.startsWith("Hata")
? "text-red-500"
: "text-green-600 dark:text-green-400"
}`}
>
{profileSaveStatus}
</span>
)}
@@ -139,26 +203,38 @@ export default function SettingsPage() {
</form>
</div>
{/* Password Settings */}
<div className="bg-card border border-border rounded-xl p-6 shadow-sm space-y-6 flex flex-col">
<div className="flex flex-col space-y-6 rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3 border-b border-border/50 pb-4">
<KeyRound className="w-6 h-6 text-primary" />
<KeyRound className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold">Şifre Değiştir</h2>
</div>
<form ref={formRef} action={handlePasswordAction} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Yeni Şifre</Label>
<Input id="password" name="password" type="password" minLength={6} placeholder="En az 6 karakter" required />
<Input
id="password"
name="password"
type="password"
minLength={6}
placeholder="En az 6 karakter"
required
/>
</div>
<div className="flex items-center gap-4 mt-4">
<div className="mt-4 flex items-center gap-4">
<Button type="submit" className="w-max">
<Save className="w-4 h-4 mr-2" />
<Save className="mr-2 h-4 w-4" />
Şifreyi Güncelle
</Button>
{passwordSaveStatus && (
<span className={`text-sm ${passwordSaveStatus.startsWith("Hata") ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
<span
className={`text-sm ${
passwordSaveStatus.startsWith("Hata")
? "text-red-500"
: "text-green-600 dark:text-green-400"
}`}
>
{passwordSaveStatus}
</span>
)}
@@ -166,31 +242,34 @@ export default function SettingsPage() {
</form>
</div>
{/* AI Settings */}
<div className="bg-card border border-border rounded-xl p-6 shadow-sm space-y-6 flex flex-col">
<div className="flex flex-col space-y-6 rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3 border-b border-border/50 pb-4">
<Bot className="w-6 h-6 text-primary" />
<Bot className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold">Terapist (AI) Ayarları</h2>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label>AI Sağlayıcısı</Label>
<Select value={aiProvider} onValueChange={setAiProvider}>
<Select
value={aiProvider}
onValueChange={(value) => setAiProvider(value as AiProvider)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sağlayıcı seçin" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ollama">
Ollama (Yerel & Gizlilik Odaklı)
Ollama (Yerel ve Gizlilik Odaklı)
</SelectItem>
<SelectItem value="openai">OpenAI (GPT-4o vb.)</SelectItem>
<SelectItem value="groq">Groq (Llama-3 Bulut)</SelectItem>
<SelectItem value="groq">Groq (Llama 3 Bulut)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Gizliliğiniz için yerel Ollama önerilir. Sunucunuzda çalışmayan
durumlarda OpenAI veya Groq gibi bulut çözümlerine geçebilirsiniz.
durumlarda OpenAI veya Groq gibi bulut çözümlerine
geçebilirsiniz.
</p>
</div>
@@ -200,19 +279,19 @@ export default function SettingsPage() {
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onChange={(event) => setApiKey(event.target.value)}
placeholder="sk-..."
/>
<p className="text-xs text-muted-foreground">
Anahtarınız sadece tarayıcınızın kendi local hafızasında güvenle
saklanır, hiçbir sunucuya kaydedilmez.
Anahtarınız sadece tarayıcınızın kendi local hafızasında saklanır;
herhangi bir sunucuya kaydedilmez.
</p>
</div>
)}
<div className="flex items-center gap-4 mt-4">
<Button onClick={handleSaveAI} className="w-max">
<Save className="w-4 h-4 mr-2" />
<div className="mt-4 flex items-center gap-4">
<Button type="button" onClick={handleSaveAI} className="w-max">
<Save className="mr-2 h-4 w-4" />
Kaydet
</Button>
{saveStatus && (
+73 -24
View File
@@ -1,14 +1,53 @@
import { NextResponse } from "next/server";
type ChatProvider = "groq" | "openai" | "ollama";
type ChatRequestBody = {
provider?: ChatProvider;
apiKey?: string;
userMessageContent?: string;
};
type ProviderErrorResponse = {
error?: {
message?: string;
};
};
type ChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
};
type OllamaResponse = {
response?: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "Bilinmeyen sunucu hatası";
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { provider, apiKey, userMessageContent } = body;
const body = (await request.json()) as ChatRequestBody;
const provider = body.provider ?? "ollama";
const apiKey = body.apiKey ?? "";
const userMessageContent = body.userMessageContent?.trim();
if (!userMessageContent) {
return NextResponse.json(
{ error: "Mesaj içeriği boş olamaz." },
{ status: 400 },
);
}
let assistantReply = "";
if (provider === "groq") {
const res = await fetch(
const response = await fetch(
"https://api.groq.com/openai/v1/chat/completions",
{
method: "POST",
@@ -30,14 +69,15 @@ export async function POST(request: Request) {
},
);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error?.message || "Groq API Hatası");
if (!response.ok) {
const errorData = (await response.json()) as ProviderErrorResponse;
throw new Error(errorData.error?.message || "Groq API hatası");
}
const data = await res.json();
assistantReply = data.choices[0].message.content;
const data = (await response.json()) as ChatCompletionResponse;
assistantReply = data.choices?.[0]?.message?.content ?? "";
} else if (provider === "openai") {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -55,32 +95,41 @@ export async function POST(request: Request) {
],
}),
});
if (!res.ok) throw new Error("OpenAI API Hatası");
const data = await res.json();
assistantReply = data.choices[0].message.content;
if (!response.ok) {
throw new Error("OpenAI API hatası");
}
const data = (await response.json()) as ChatCompletionResponse;
assistantReply = data.choices?.[0]?.message?.content ?? "";
} else {
// Varsayılan: Yerel Ollama
const res = await fetch("http://127.0.0.1:11434/api/generate", {
const response = await fetch("http://127.0.0.1:11434/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "llama3", // veya mistral
model: "llama3",
prompt: `Sen MindSpace adlı kullanıcının kişisel yapay zeka terapistisin ve sırdaşısın. Şefkatli ve destekleyici cevap ver.\n\nKullanıcı: ${userMessageContent}\nTerapist:`,
stream: false,
}),
});
if (!res.ok) throw new Error("Ollama API yanıt vermedi.");
const data = await res.json();
assistantReply = data.response;
if (!response.ok) {
throw new Error("Ollama API yanıt vermedi.");
}
const data = (await response.json()) as OllamaResponse;
assistantReply = data.response ?? "";
}
if (!assistantReply) {
throw new Error("Model geçerli bir yanıt üretmedi.");
}
return NextResponse.json({ reply: assistantReply });
} catch (error: any) {
console.error("API Route Hatası:", error);
return NextResponse.json(
{ error: error.message || "Bilinmeyen Sunucu Hatası" },
{ status: 500 },
);
} catch (error: unknown) {
const message = getErrorMessage(error);
console.error("API route hatası:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+9
View File
@@ -39,3 +39,12 @@ export async function signup(formData: FormData) {
revalidatePath('/', 'layout')
redirect('/')
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
revalidatePath('/', 'layout')
redirect('/login')
}
+545
View File
@@ -0,0 +1,545 @@
"use client";
import { signOut } from "@/app/login/actions";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import {
BookOpenText,
CheckSquare2,
ChevronRight,
LogOut,
Menu,
MessageCircleHeart,
PanelLeftClose,
Settings2,
Sparkles,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
type DashboardShellProps = {
children: React.ReactNode;
user: {
email: string;
displayName: string;
shortName: string;
avatarUrl: string | null;
};
};
type RouteMeta = {
eyebrow: string;
title: string;
description: string;
};
const routes = [
{
label: "Dashboard",
href: "/",
icon: Sparkles,
description: "Günün ritmi ve genel görünüm",
},
{
label: "Günlük",
href: "/journal",
icon: BookOpenText,
description: "Düşünceler, duygu ve enerji kayıtları",
},
{
label: "Görevler",
href: "/tasks",
icon: CheckSquare2,
description: "Planlar, öncelikler ve aksiyonlar",
},
{
label: "Sohbet",
href: "/chat",
icon: MessageCircleHeart,
description: "AI ile bağlamsal yansıma alanı",
},
{
label: "Ayarlar",
href: "/settings",
icon: Settings2,
description: "Profil, güvenlik ve AI tercihleri",
},
] as const;
const routeMeta: Record<string, RouteMeta> = {
"/": {
eyebrow: "MindSpace Workspace",
title: "Günün Nabzı",
description: "Verilerini, odağını ve kişisel akışını tek panelden yönet.",
},
"/journal": {
eyebrow: "Journal",
title: "Düşünce Akışı",
description: "Bugünün zihinsel yükünü yaz, duygu ve enerji izlerini kaydet.",
},
"/tasks": {
eyebrow: "Tasks",
title: "Aksiyon Katmanı",
description: "Önceliklerini sadeleştir, küçük ama net adımlar belirle.",
},
"/chat": {
eyebrow: "Reflective AI",
title: "Yansıtıcı Sohbet",
description: "Geçmiş kayıtlarınla bağ kuran özel AI eşlikçin burada.",
},
"/settings": {
eyebrow: "Preferences",
title: "Hesap ve Kontroller",
description: "Profilini, güvenliği ve AI ayarlarını tek yerden yönet.",
},
};
export function DashboardShell({ children, user }: DashboardShellProps) {
const pathname = usePathname();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const closeMobileSidebar = () => setIsMobileSidebarOpen(false);
const currentMeta = useMemo(() => {
if (pathname === "/") return routeMeta["/"];
if (pathname?.startsWith("/journal")) return routeMeta["/journal"];
if (pathname?.startsWith("/tasks")) return routeMeta["/tasks"];
if (pathname?.startsWith("/chat")) return routeMeta["/chat"];
if (pathname?.startsWith("/settings")) return routeMeta["/settings"];
return routeMeta["/"];
}, [pathname]);
const todayLabel = useMemo(
() =>
new Intl.DateTimeFormat("tr-TR", {
weekday: "long",
day: "numeric",
month: "long",
}).format(new Date()),
[],
);
return (
<div className="relative min-h-screen overflow-hidden bg-background text-foreground">
<AmbientBackdrop />
<div className="relative z-10 flex min-h-screen">
<div className="hidden shrink-0 lg:block lg:w-[320px]">
<div className="sticky top-0 h-screen p-4 pr-0">
<SidebarPanel pathname={pathname} user={user} desktop />
</div>
</div>
<AnimatePresence>
{isMobileSidebarOpen ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex lg:hidden"
>
<button
type="button"
aria-label="Menüyü kapat"
className="absolute inset-0 bg-background/75 backdrop-blur-sm"
onClick={closeMobileSidebar}
/>
<motion.div
initial={{ x: -48, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -48, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="relative z-10 h-full w-[min(88vw,340px)] p-4 pr-2"
>
<SidebarPanel
pathname={pathname}
user={user}
onNavigate={closeMobileSidebar}
/>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
<div className="flex min-h-screen min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 px-4 pb-3 pt-4 md:px-6 lg:px-8">
<TopBar
user={user}
currentMeta={currentMeta}
todayLabel={todayLabel}
onOpenSidebar={() => setIsMobileSidebarOpen(true)}
/>
</header>
<main className="flex-1 px-4 pb-6 md:px-6 md:pb-8 lg:px-8">
<div className="min-h-[calc(100vh-7rem)] rounded-[28px] border border-white/8 bg-[linear-gradient(180deg,rgba(31,23,43,0.84),rgba(18,13,28,0.72))] shadow-[0_30px_120px_rgba(7,5,14,0.45)] ring-1 ring-white/6 backdrop-blur-xl">
<div className="h-full p-4 md:p-6 lg:p-8">{children}</div>
</div>
</main>
</div>
</div>
</div>
);
}
function SidebarPanel({
pathname,
user,
desktop = false,
onNavigate,
}: {
pathname: string;
user: DashboardShellProps["user"];
desktop?: boolean;
onNavigate?: () => void;
}) {
return (
<aside className="flex h-full flex-col overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(31,23,43,0.96),rgba(18,13,28,0.88))] shadow-[0_24px_100px_rgba(7,5,14,0.52)] ring-1 ring-white/6 backdrop-blur-xl">
<div className="relative overflow-hidden border-b border-white/8 px-5 pb-5 pt-6">
<div className="absolute -left-8 top-0 h-24 w-24 rounded-full bg-primary/20 blur-3xl" />
<div className="absolute right-0 top-8 h-16 w-16 rounded-full bg-white/10 blur-2xl" />
<Link href="/" className="relative flex items-center gap-4" onClick={onNavigate}>
<div className="relative flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/5 shadow-[0_12px_40px_rgba(0,0,0,0.22)]">
<Image
src="/logo/logo.png"
alt="MindSpace Logo"
width={40}
height={40}
className="h-10 w-10 object-contain"
priority
/>
</div>
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-primary/80">
Private Space
</div>
<div className="truncate text-xl font-semibold tracking-tight text-foreground">
MindSpace
</div>
<div className="text-sm text-muted-foreground">
Dingin, güvenli ve odaklı çalışma alanı
</div>
</div>
</Link>
</div>
<div className="px-4 pt-4">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-muted-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-primary/70">
Focus Layer
</div>
Yaz, gözlemle, planla. Tüm akışı sade ama güçlü bir panelde tut.
</div>
</div>
<nav className="flex-1 space-y-2 overflow-y-auto px-4 py-5">
{routes.map((route) => {
const isActive =
route.href === "/"
? pathname === "/"
: pathname === route.href || pathname.startsWith(route.href + "/");
return (
<Link
key={route.href}
href={route.href}
className="block"
onClick={onNavigate}
>
<motion.div
whileHover={{ x: 6 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className={cn(
"group relative overflow-hidden rounded-2xl border px-4 py-3.5 transition-all",
isActive
? "border-primary/35 bg-primary/[0.14] shadow-[0_16px_40px_rgba(108,91,176,0.22)]"
: "border-white/8 bg-white/[0.03] hover:border-white/16 hover:bg-white/[0.06]",
)}
>
{isActive ? (
<motion.div
layoutId="dashboard-nav-active"
className="absolute inset-0 rounded-2xl bg-[linear-gradient(135deg,rgba(108,91,176,0.2),rgba(108,91,176,0.08),transparent)]"
/>
) : null}
<div className="relative flex items-center gap-3">
<div
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border transition-all",
isActive
? "border-primary/30 bg-primary/20 text-primary-foreground shadow-[0_12px_28px_rgba(108,91,176,0.22)]"
: "border-white/8 bg-white/5 text-muted-foreground group-hover:border-primary/20 group-hover:bg-primary/10 group-hover:text-foreground",
)}
>
<route.icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div
className={cn(
"flex items-center gap-2 text-sm font-semibold",
isActive ? "text-foreground" : "text-foreground/90",
)}
>
{route.label}
<ChevronRight
className={cn(
"h-4 w-4 transition-all",
isActive
? "translate-x-0 text-primary"
: "translate-x-[-4px] opacity-0 group-hover:translate-x-0 group-hover:opacity-100",
)}
/>
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{route.description}
</div>
</div>
</div>
</motion.div>
</Link>
);
})}
</nav>
<div className="border-t border-white/8 p-4 pt-5">
<div className="mb-4 rounded-2xl border border-white/8 bg-white/5 p-3">
<div className="flex items-center gap-3">
<Avatar user={user} size="sm" />
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">
{user.displayName}
</div>
<div className="truncate text-xs text-muted-foreground">
{user.email}
</div>
</div>
</div>
</div>
<form action={signOut}>
<Button
type="submit"
variant="outline"
className={cn(
"h-12 w-full justify-start gap-3 rounded-2xl border-white/10 bg-white/[0.04] px-4 text-sm font-semibold text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] transition-all",
"hover:border-destructive/30 hover:bg-destructive/10 hover:text-destructive",
)}
>
<LogOut className="h-4 w-4" />
Çıkış Yap
</Button>
</form>
{!desktop ? (
<div className="mt-3 text-center text-[11px] uppercase tracking-[0.24em] text-muted-foreground/60">
Secure workspace
</div>
) : null}
</div>
</aside>
);
}
function TopBar({
user,
currentMeta,
todayLabel,
onOpenSidebar,
}: {
user: DashboardShellProps["user"];
currentMeta: RouteMeta;
todayLabel: string;
onOpenSidebar: () => void;
}) {
return (
<div className="overflow-hidden rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,rgba(31,23,43,0.82),rgba(18,13,28,0.68))] px-4 py-4 shadow-[0_20px_80px_rgba(7,5,14,0.3)] ring-1 ring-white/6 backdrop-blur-xl md:px-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<Button
type="button"
variant="outline"
size="icon-lg"
className="mt-0.5 shrink-0 rounded-2xl border-white/10 bg-white/[0.04] lg:hidden"
onClick={onOpenSidebar}
>
<Menu className="h-5 w-5" />
</Button>
<div className="min-w-0">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.26em] text-primary/80">
{currentMeta.eyebrow}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-foreground md:text-[2rem]">
{currentMeta.title}
</h1>
<div className="rounded-full border border-white/8 bg-white/[0.04] px-3 py-1 text-xs font-medium text-muted-foreground">
{todayLabel}
</div>
</div>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
{currentMeta.description}
</p>
</div>
</div>
<div className="hidden items-center gap-3 md:flex">
<div className="rounded-full border border-white/8 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-muted-foreground">
Protected session
</div>
<UserMenu user={user} />
</div>
</div>
<div className="mt-4 flex items-center justify-end md:hidden">
<UserMenu user={user} compact />
</div>
</div>
);
}
function UserMenu({
user,
compact = false,
}: {
user: DashboardShellProps["user"];
compact?: boolean;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"group flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] p-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] transition-all outline-none hover:border-white/16 hover:bg-white/[0.07]",
compact ? "w-full justify-between" : "min-w-[220px]",
)}
>
<div className="flex items-center gap-3">
<Avatar user={user} />
<div className={cn("min-w-0", compact ? "max-w-[160px]" : "max-w-[180px]")}>
<div className="truncate text-sm font-semibold text-foreground">
{user.displayName}
</div>
<div className="truncate text-xs text-muted-foreground">
{user.email}
</div>
</div>
</div>
<div className="mr-1 flex h-8 w-8 items-center justify-center rounded-xl bg-white/5 text-muted-foreground transition-all group-hover:bg-white/10 group-hover:text-foreground">
<PanelLeftClose className="h-4 w-4 rotate-[-90deg]" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-72 rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(31,23,43,0.98),rgba(18,13,28,0.94))] p-2 text-foreground shadow-[0_20px_60px_rgba(7,5,14,0.45)] backdrop-blur-xl"
>
<DropdownMenuLabel className="px-3 py-2 text-foreground">
<div className="flex items-center gap-3">
<Avatar user={user} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{user.displayName}</div>
<div className="truncate text-xs font-normal text-muted-foreground">
{user.email}
</div>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-white/8" />
<DropdownMenuItem asChild className="rounded-xl px-3 py-2.5">
<Link href="/settings" className="flex items-center gap-2">
<Settings2 className="h-4 w-4" />
Ayarlar
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="rounded-xl px-3 py-2.5">
<Link href="/journal" className="flex items-center gap-2">
<BookOpenText className="h-4 w-4" />
Günlük Alanı
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-white/8" />
<div className="px-3 py-2 text-xs leading-5 text-muted-foreground">
Bu menü profil ve hızlı erişim içindir. Ana çıkış aksiyonu soldaki
panelin altındadır.
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function Avatar({
user,
size = "md",
}: {
user: DashboardShellProps["user"];
size?: "sm" | "md";
}) {
const dimensions = size === "sm" ? "h-11 w-11 text-sm" : "h-12 w-12 text-sm";
if (user.avatarUrl) {
return (
<div
className={cn(
"overflow-hidden rounded-2xl border border-white/10 bg-white/5 shadow-[0_12px_36px_rgba(0,0,0,0.18)]",
dimensions,
)}
>
{/* Avatar URL dış kaynaklı olduğu için bu önizlemede native img kullanıyoruz. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={user.avatarUrl}
alt={user.displayName}
className="h-full w-full object-cover"
/>
</div>
);
}
return (
<div
className={cn(
"flex items-center justify-center rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(108,91,176,0.32),rgba(255,255,255,0.08))] font-semibold text-foreground shadow-[0_12px_36px_rgba(0,0,0,0.18)]",
dimensions,
)}
>
{user.shortName}
</div>
);
}
function AmbientBackdrop() {
return (
<>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(108,91,176,0.18),transparent_34%),radial-gradient(circle_at_85%_14%,rgba(255,255,255,0.08),transparent_18%),radial-gradient(circle_at_bottom_right,rgba(108,91,176,0.14),transparent_28%)]" />
<motion.div
animate={{ x: [0, 28, 0], y: [0, -20, 0] }}
transition={{ duration: 14, repeat: Infinity, ease: "easeInOut" }}
className="pointer-events-none absolute left-[-8rem] top-10 h-64 w-64 rounded-full bg-primary/10 blur-[120px]"
/>
<motion.div
animate={{ x: [0, -32, 0], y: [0, 24, 0] }}
transition={{ duration: 17, repeat: Infinity, ease: "easeInOut" }}
className="pointer-events-none absolute bottom-0 right-[-8rem] h-72 w-72 rounded-full bg-white/5 blur-[130px]"
/>
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'
import nextTypeScript from 'eslint-config-next/typescript'
const config = [
...nextCoreWebVitals,
...nextTypeScript,
{
ignores: ['**/*.old.*'],
},
]
export default config
+20 -23
View File
@@ -10,6 +10,10 @@ import type {
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const ADD_TOAST = "ADD_TOAST"
const UPDATE_TOAST = "UPDATE_TOAST"
const DISMISS_TOAST = "DISMISS_TOAST"
const REMOVE_TOAST = "REMOVE_TOAST"
type ToasterToast = ToastProps & {
id: string
@@ -18,13 +22,6 @@ type ToasterToast = ToastProps & {
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
@@ -32,23 +29,21 @@ function genId() {
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
type: typeof ADD_TOAST
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
type: typeof UPDATE_TOAST
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
type: typeof DISMISS_TOAST
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
type: typeof REMOVE_TOAST
toastId?: ToasterToast["id"]
}
@@ -66,7 +61,7 @@ const addToRemoveQueue = (toastId: string) => {
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
type: REMOVE_TOAST,
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
@@ -76,13 +71,13 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
case UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
@@ -90,7 +85,7 @@ export const reducer = (state: State, action: Action): State => {
),
}
case "DISMISS_TOAST": {
case DISMISS_TOAST: {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
@@ -115,7 +110,7 @@ export const reducer = (state: State, action: Action): State => {
),
}
}
case "REMOVE_TOAST":
case REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
@@ -126,6 +121,8 @@ export const reducer = (state: State, action: Action): State => {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
default:
return state
}
}
@@ -147,13 +144,13 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
type: UPDATE_TOAST,
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
const dismiss = () => dispatch({ type: DISMISS_TOAST, toastId: id })
dispatch({
type: "ADD_TOAST",
type: ADD_TOAST,
toast: {
...props,
id,
@@ -182,12 +179,12 @@ function useToast() {
listeners.splice(index, 1)
}
}
}, [state])
}, [])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
dismiss: (toastId?: string) => dispatch({ type: DISMISS_TOAST, toastId }),
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export async function updateSession(request: NextRequest) {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
+1 -1
View File
@@ -17,7 +17,7 @@ export async function createClient() {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch (error) {
} catch {
// The `set` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
-19
View File
@@ -1,19 +0,0 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3 -3
View File
@@ -6,7 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint ."
},
"dependencies": {
"@base-ui/react": "^1.4.1",
@@ -28,7 +28,7 @@
"dexie-react-hooks": "^4.4.0",
"framer-motion": "^11.18.2",
"lucide-react": "^1.14.0",
"next": "^15.1.6",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
@@ -49,7 +49,7 @@
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-config-next": "^15.1.6",
"eslint-config-next": "^16.2.6",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
+123 -67
View File
@@ -66,8 +66,8 @@ importers:
specifier: ^1.14.0
version: 1.14.0(react@19.2.6)
next:
specifier: ^15.1.6
version: 15.5.16(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
specifier: ^16.2.6
version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -124,8 +124,8 @@ importers:
specifier: ^9.18.0
version: 9.39.4(jiti@1.21.7)
eslint-config-next:
specifier: ^15.1.6
version: 15.5.16(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
specifier: ^16.2.6
version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
postcss:
specifier: ^8.5.1
version: 8.5.14
@@ -618,56 +618,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@15.5.16':
resolution: {integrity: sha512-9QMKolCl+JnJtaRAQSXy4RQrhgfe8W7/G1+Hl3QSB/HZY7zQMzTwPDdTRwwio8BS96ps1MHpHhbS8qxoNV3JIQ==}
'@next/env@16.2.6':
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
'@next/eslint-plugin-next@15.5.16':
resolution: {integrity: sha512-pXa+4smRrgzea94YeAR8txf2CYg4pc1HkcoLUigrE5a0j70dVdUYMKfsOGCe8ulDSLvqnm2keMoxKss5RxHokg==}
'@next/eslint-plugin-next@16.2.6':
resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==}
'@next/swc-darwin-arm64@15.5.16':
resolution: {integrity: sha512-wzdER4JZj+31vNkhaZ1Ght3IsNI8DMwj7VqadfIOqJB5sh8FiOqNSopYADQn6mgEPomzDd/DHqBcfo2fmVMYtg==}
'@next/swc-darwin-arm64@16.2.6':
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.5.16':
resolution: {integrity: sha512-PPTo+cvcanxkuDEuDyZGk28ntmu0WjfkxqlG7hw9Mhsiribs4x1C6h2Culn0cJKqsne1gFjjZRK3ax7WYlSxgg==}
'@next/swc-darwin-x64@16.2.6':
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.5.16':
resolution: {integrity: sha512-Jl0IL9P7S8uNl5oI1TqrQmfmLp7OqjWM58000pVnUVIsHrvPP6m9QDW/uNWYUbmd+8IYvc6MTeZKICstBMBpew==}
'@next/swc-linux-arm64-gnu@16.2.6':
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.5.16':
resolution: {integrity: sha512-Zf0BIqv/o5uOWfyRkzgGhyV2Tky7HLt0bG+w7XWdaU1JpyX0tltM3TrSfa/Y9c597SJG4CzN47+u2InhgZZ4vg==}
'@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.5.16':
resolution: {integrity: sha512-HCDDU1TRLeUDV180QQTWrs5Oa4lIcI7XH9nF0UVUVmYLN/boZ6LqyFtm3814gc1fv+lOVyKaw5B6bVC9BpXTSQ==}
'@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.5.16':
resolution: {integrity: sha512-kvXUY1dn5wxKuMkXxQRUbPjEnKxW1PR9uKOm0zpIpj3574+cFfaePhYFmBVtrOuwt+w34OdDzNaJr5Iixf+HBQ==}
'@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.5.16':
resolution: {integrity: sha512-zpOQuF+eyENMXRjglp2hZCIrUjTdO37suEBnDn1mX4PXSuetXZDMLpjKOh4dYSw3SiDTnOoOUwBl5i5Elr6nnQ==}
'@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.5.16':
resolution: {integrity: sha512-LnwKYpiSmIzXlTq76hMeeIzZoDcFwu848p6H+QBkGFJIbZphgzNUPdHruJcHM/bFnaFeco0l1Frie5I27VKglA==}
'@next/swc-win32-x64-msvc@16.2.6':
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1453,9 +1453,6 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1546,6 +1543,9 @@ packages:
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@22.19.18':
resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -2264,10 +2264,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-next@15.5.16:
resolution: {integrity: sha512-9fi/wwYuBQSm2vHDVE8PMPsKIR/xXVOlwMrRp16qra6S/LQVhZ452cjnkPZb6PN/SZ3yJUQp1L4bTYoublvBKw==}
eslint-config-next@16.2.6:
resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
eslint: '>=9.0.0'
typescript: '>=3.3.1'
peerDependenciesMeta:
typescript:
@@ -2326,11 +2326,11 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
eslint-plugin-react-hooks@5.2.0:
resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
engines: {node: '>=10'}
eslint-plugin-react-hooks@7.1.1:
resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
engines: {node: '>=18'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
eslint-plugin-react@7.37.5:
resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
@@ -2612,6 +2612,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
globals@16.4.0:
resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
engines: {node: '>=18'}
globalthis@1.0.4:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
@@ -2662,6 +2666,12 @@ packages:
headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
hono@4.12.18:
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
engines: {node: '>=16.9.0'}
@@ -3115,9 +3125,9 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.5.16:
resolution: {integrity: sha512-aZExBk/V6JCu3NCFc90twdj9L/M3y0+ukeQwUAZbOiqRhAX+h2oMEa0NZFhcpj6HYRYjVS3V2/3xvyOpNnmw7A==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
next@16.2.6:
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -3878,6 +3888,13 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typescript-eslint@8.59.2:
resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -4049,6 +4066,12 @@ packages:
peerDependencies:
zod: ^3.25.28 || ^4
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -4579,34 +4602,34 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@next/env@15.5.16': {}
'@next/env@16.2.6': {}
'@next/eslint-plugin-next@15.5.16':
'@next/eslint-plugin-next@16.2.6':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.5.16':
'@next/swc-darwin-arm64@16.2.6':
optional: true
'@next/swc-darwin-x64@15.5.16':
'@next/swc-darwin-x64@16.2.6':
optional: true
'@next/swc-linux-arm64-gnu@15.5.16':
'@next/swc-linux-arm64-gnu@16.2.6':
optional: true
'@next/swc-linux-arm64-musl@15.5.16':
'@next/swc-linux-arm64-musl@16.2.6':
optional: true
'@next/swc-linux-x64-gnu@15.5.16':
'@next/swc-linux-x64-gnu@16.2.6':
optional: true
'@next/swc-linux-x64-musl@15.5.16':
'@next/swc-linux-x64-musl@16.2.6':
optional: true
'@next/swc-win32-arm64-msvc@15.5.16':
'@next/swc-win32-arm64-msvc@16.2.6':
optional: true
'@next/swc-win32-x64-msvc@15.5.16':
'@next/swc-win32-x64-msvc@16.2.6':
optional: true
'@noble/ciphers@1.3.0': {}
@@ -5425,8 +5448,6 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {}
'@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {}
@@ -5527,6 +5548,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.19.18':
dependencies:
undici-types: 6.21.0
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -5547,7 +5572,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.17
'@types/node': 22.19.18
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
@@ -6278,22 +6303,22 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@15.5.16(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
eslint-config-next@16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.5.16
'@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@next/eslint-plugin-next': 16.2.6
eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@1.21.7))
globals: 16.4.0
typescript-eslint: 8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-webpack
- eslint-plugin-import-x
- supports-color
@@ -6380,9 +6405,16 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(jiti@1.21.7)):
eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.3
eslint: 9.39.4(jiti@1.21.7)
hermes-parser: 0.25.1
zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
@@ -6741,6 +6773,8 @@ snapshots:
globals@14.0.0: {}
globals@16.4.0: {}
globalthis@1.0.4:
dependencies:
define-properties: 1.2.1
@@ -6783,6 +6817,12 @@ snapshots:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.0
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
dependencies:
hermes-estree: 0.25.1
hono@4.12.18: {}
http-errors@2.0.1:
@@ -7182,24 +7222,25 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
next@15.5.16(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@next/env': 15.5.16
'@next/env': 16.2.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001792
postcss: 8.4.31
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.6)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.16
'@next/swc-darwin-x64': 15.5.16
'@next/swc-linux-arm64-gnu': 15.5.16
'@next/swc-linux-arm64-musl': 15.5.16
'@next/swc-linux-x64-gnu': 15.5.16
'@next/swc-linux-x64-musl': 15.5.16
'@next/swc-win32-arm64-msvc': 15.5.16
'@next/swc-win32-x64-msvc': 15.5.16
'@next/swc-darwin-arm64': 16.2.6
'@next/swc-darwin-x64': 16.2.6
'@next/swc-linux-arm64-gnu': 16.2.6
'@next/swc-linux-arm64-musl': 16.2.6
'@next/swc-linux-x64-gnu': 16.2.6
'@next/swc-linux-x64-musl': 16.2.6
'@next/swc-win32-arm64-msvc': 16.2.6
'@next/swc-win32-x64-msvc': 16.2.6
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -8163,6 +8204,17 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typescript-eslint@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.4(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
unbox-primitive@1.1.0:
@@ -8356,6 +8408,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
zod@3.25.76: {}
zod@4.4.3: {}
+13
View File
@@ -0,0 +1,13 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function proxy(request: NextRequest) {
return updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
+19 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -20,9 +24,19 @@
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+1 -1
View File
File diff suppressed because one or more lines are too long