feat: implement Supabase database schema and core authentication flow with registration and login pages

This commit is contained in:
Poyraz Avsever
2026-05-07 11:11:15 +03:00
parent 4126b41064
commit 904375ea5c
21 changed files with 8832 additions and 127 deletions
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { Send, Bot, User, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export default function ChatPage() {
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Mesajları eski tarihten yeniye göre sıralayarak al (Sohbet akışı)
const messages = useLiveQuery(
() => db.chat_messages.orderBy("created_at").toArray(),
[],
);
// Ollama'ya veya harici API'ye mesaj gönder
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
const userMessageContent = input.trim();
setInput("");
setIsLoading(true);
const userMessage = {
id: uuidv4(),
role: "user" as const,
content: userMessageContent,
created_at: new Date().toISOString(),
};
try {
await db.chat_messages.add(userMessage);
// LocalStorage'dan veya Environment ayarlarını okuyup backend API'ye gönderiyoruz
const provider = localStorage.getItem("mindspace_ai_provider") || "groq";
const apiKey =
localStorage.getItem("mindspace_api_key") ||
process.env.NEXT_PUBLIC_GROQ_API_KEY ||
"";
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
apiKey,
userMessageContent,
}),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Sunucu hatası oluştu.");
}
const data = await res.json();
const assistantMessage = {
id: uuidv4(),
role: "assistant" as const,
content:
data.reply ||
"Şu an cevap veremiyorum, lütfen ayarlarınızı kontrol edin.",
created_at: new Date().toISOString(),
};
await db.chat_messages.add(assistantMessage);
} catch (error) {
console.error(error);
await db.chat_messages.add({
id: uuidv4(),
role: "assistant" as const,
content:
"API Bağlantısı kurulamadı. Lütfen internetini veya Ayarlar sayfasından API anahtarını kontrol et.",
created_at: new Date().toISOString(),
});
} finally {
setIsLoading(false);
}
};
const handleClearChat = async () => {
await db.chat_messages.clear();
};
return (
<div className="flex flex-col h-[calc(100vh-4rem)] p-4 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Terapist (Sohbet)
</h1>
<p className="text-sm text-muted-foreground">
Ollama veya bulut destekli akıllı asistanınızla konuşun.
</p>
</div>
<Button
variant="outline"
size="icon"
onClick={handleClearChat}
title="Sohbeti Temizle"
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 rounded-xl border border-border bg-card shadow-sm mb-4">
{(!messages || messages.length === 0) && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Bot className="w-12 h-12 mb-2 opacity-50" />
<p>Sohbet henüz başlamadı. İlk mesajınızı yollayın!</p>
</div>
)}
{messages?.map((m) => (
<div
key={m.id}
className={`flex w-full ${m.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`flex gap-2 max-w-[80%] ${m.role === "user" ? "flex-row-reverse" : "flex-row"}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${m.role === "user" ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"}`}
>
{m.role === "user" ? (
<User className="w-4 h-4" />
) : (
<Bot className="w-4 h-4" />
)}
</div>
<div
className={`p-3 rounded-xl ${m.role === "user" ? "bg-primary text-primary-foreground rounded-tr-none" : "bg-muted text-foreground rounded-tl-none border border-border"}`}
>
{m.content}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex w-full justify-start">
<div className="flex gap-2 max-w-[80%]">
<div className="w-8 h-8 rounded-full bg-secondary text-secondary-foreground flex items-center justify-center shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="p-3 rounded-xl bg-muted text-foreground rounded-tl-none border border-border flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary/50 animate-bounce" />
<div className="w-2 h-2 rounded-full bg-primary/50 animate-bounce delay-75" />
<div className="w-2 h-2 rounded-full bg-primary/50 animate-bounce delay-150" />
</div>
</div>
</div>
)}
</div>
<form onSubmit={handleSend} className="flex gap-2 items-center">
<Input
disabled={isLoading}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Bugün nasıl hissediyorsun? Ya da eski günlüklere dayanarak bir şeyler sor..."
className="flex-1"
/>
<Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
<Send className="w-4 h-4" />
</Button>
</form>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "@/lib/db";
import { analyzeJournalWithLocalAI } from "@/lib/ai";
import {
Card,
CardContent,
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";
const moods = [
{ id: "happy", label: "Mutlu", emoji: "😊" },
{ id: "neutral", label: "Nötr", emoji: "😐" },
{ id: "sad", label: "Üzgün", emoji: "😔" },
{ id: "angry", label: "Sinirli", emoji: "😠" },
];
export default function JournalPage() {
const [content, setContent] = useState("");
const [mood, setMood] = useState("happy");
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)
const journals = useLiveQuery(() =>
db.journals.orderBy("date").reverse().toArray(),
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
setIsSubmitting(true);
const now = new Date().toISOString();
const entryId = uuidv4();
const currentContent = content; // Analiz için metni kopyala
try {
// 1. Veriyi veritabanına hemen ekle (Kullanıcı beklemesin)
await db.journals.add({
id: entryId,
date: now.split("T")[0],
mood,
energy: parseInt(energy, 10),
content: currentContent,
created_at: now,
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) {
await db.journals.update(entryId, {
ai_tags: aiResult.ai_tags,
ai_sentiment_score: aiResult.ai_sentiment_score,
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) {
for (const taskTitle of aiResult.suggested_tasks) {
await db.tasks.add({
id: uuidv4(),
journal_id: entryId,
title: `AI Önerisi: ${taskTitle}`,
status: "todo",
ai_generated: true,
date: now.split("T")[0],
created_at: now,
});
}
}
}
} catch (aiErr) {
console.error("Arka plan AI analizi hatası:", aiErr);
}
} catch (error) {
console.error("Günlük kaydedilemedi:", error);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (id: string) => {
if (confirm("Bu günlüğü silmek istediğinden emin misin?")) {
await db.journals.delete(id);
}
};
return (
<div className="space-y-6 max-w-4xl mx-auto animate-in 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">
Zihnini boşalt, hislerini kaydet. Verilerin sadece cihazında kalır.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yeni Kayıt</CardTitle>
<CardDescription>Bugün nasıl hissediyorsun?</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Ruh Hali</Label>
<div className="flex gap-2">
{moods.map((m) => (
<button
key={m.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"
}`}
title={m.label}
>
{m.emoji}
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Enerji Seviyesi (1-5): {energy}</Label>
<Input
type="range"
min="1"
max="5"
step="1"
value={energy}
onChange={(e) => setEnergy(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="content">Düşüncelerini buraya dök...</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[150px] resize-y"
placeholder="Örneğin: Bugün toplantıda işler ters gitti..."
/>
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-full md:w-auto"
>
{isSubmitting ? "Kaydediliyor..." : "Kaydet"}
</Button>
</form>
</CardContent>
</Card>
<div className="space-y-4 pt-4">
<h2 className="text-xl font-bold">Geçmiş Kayıtlar</h2>
{!journals ? (
<p className="text-sm text-muted-foreground">Yükleniyor...</p>
) : journals.length === 0 ? (
<p className="text-sm text-muted-foreground">
Henüz kayıt bulunmuyor.
</p>
) : (
<div className="grid gap-4">
{journals.map((journal) => (
<Card key={journal.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<span>
{moods.find((m) => m.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">
Enerji: {journal.energy}/5
</span>
</div>
</CardHeader>
<CardContent className="pt-2">
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{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"
>
{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>
)}
<div className="flex justify-end mt-4">
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-600 hover:bg-red-500/10"
onClick={() => handleDelete(journal.id)}
>
Sil
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
export default function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
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>
);
}
+442
View File
@@ -0,0 +1,442 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import logo from "../assets/logo.png";
import {
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { FormEvent, useEffect, useMemo, useState } from "react";
type Mood = "happy" | "neutral" | "sad" | "angry";
type MoodScore = 4 | 3 | 2 | 1;
type Energy = 1 | 2 | 3 | 4 | 5;
type MoodEntry = {
id: string;
date: string;
mood: Mood;
mood_score: MoodScore;
energy: Energy;
};
const STORAGE_KEY = "mood-tracker-entries";
const moodConfig: Record<
Mood,
{ label: string; icon: string; score: MoodScore; color: string }
> = {
happy: { label: "Mutlu", icon: "😊", score: 4, color: "#2f7d63" },
neutral: { label: "Nötr", icon: "😐", score: 3, color: "#4f7fbf" },
sad: { label: "Üzgün", icon: "😔", score: 2, color: "#e3aa38" },
angry: { label: "Sinirli", icon: "😠", score: 1, color: "#d8644a" },
};
const moodOptions = Object.entries(moodConfig) as Array<
[Mood, (typeof moodConfig)[Mood]]
>;
const today = () => new Date().toISOString().slice(0, 10);
const createId = () =>
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
export default function Home() {
const [entries, setEntries] = useState<MoodEntry[]>([]);
const [date, setDate] = useState(today);
const [mood, setMood] = useState<Mood>("happy");
const [energy, setEnergy] = useState<Energy>(3);
const [message, setMessage] = useState("");
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const rawEntries = window.localStorage.getItem(STORAGE_KEY);
if (!rawEntries) {
setIsLoaded(true);
return;
}
try {
const parsedEntries = JSON.parse(rawEntries) as MoodEntry[];
if (Array.isArray(parsedEntries)) {
setEntries(sortEntries(parsedEntries));
}
} catch {
window.localStorage.removeItem(STORAGE_KEY);
} finally {
setIsLoaded(true);
}
}, []);
useEffect(() => {
if (!isLoaded) {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}, [entries, isLoaded]);
const lineData = useMemo(
() =>
entries.map((entry) => ({
date: formatShortDate(entry.date),
mood: entry.mood_score,
energy: entry.energy,
})),
[entries],
);
const pieData = useMemo(
() =>
moodOptions
.map(([key, config]) => ({
name: config.label,
value: entries.filter((entry) => entry.mood === key).length,
color: config.color,
}))
.filter((item) => item.value > 0),
[entries],
);
const insights = useMemo(() => buildInsights(entries), [entries]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!date) {
setMessage("Lütfen tarih seç.");
return;
}
if (!mood) {
setMessage("Lütfen ruh halini seç.");
return;
}
if (energy < 1 || energy > 5) {
setMessage("Enerji seviyesi 1 ile 5 arasında olmalı.");
return;
}
const moodScore = moodConfig[mood].score;
setEntries((currentEntries) => {
const existingEntry = currentEntries.find((entry) => entry.date === date);
const nextEntry: MoodEntry = {
id: existingEntry?.id ?? createId(),
date,
mood,
mood_score: moodScore,
energy,
};
const nextEntries = existingEntry
? currentEntries.map((entry) =>
entry.date === date ? nextEntry : entry,
)
: [...currentEntries, nextEntry];
return sortEntries(nextEntries);
});
setMessage("Kayıt kaydedildi. Dashboard güncellendi.");
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8">
<header className="flex flex-col gap-2 border-b border-ink/10 pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="flex items-center gap-4">
<Image
src={logo}
alt="Mood Tracker logo"
className="h-16 w-16 rounded-md object-contain sm:h-20 sm:w-20"
priority
/>
<h1 className="text-3xl font-bold text-ink sm:text-4xl">
Günlük ruh hali dashboard'u
</h1>
</div>
<div className="rounded-md border border-ink/10 bg-white px-4 py-3 text-sm text-ink/70 shadow-soft">
{entries.length} kayıt
</div>
</header>
<section className="grid gap-5 lg:grid-cols-[360px_1fr]">
<motion.form
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
onSubmit={handleSubmit}
className="rounded-lg border border-ink/10 bg-white p-5 shadow-soft"
>
<div className="mb-5">
<h2 className="text-xl font-semibold text-ink">Bugünün kaydı</h2>
<p className="mt-1 text-sm text-ink/60">
Aynı tarih tekrar kaydedilirse eski kayıt güncellenir.
</p>
</div>
<label className="block text-sm font-medium text-ink" htmlFor="date">
Tarih
</label>
<input
id="date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
className="mt-2 w-full rounded-md border border-ink/15 bg-mist px-3 py-2 outline-none transition focus:border-leaf focus:ring-2 focus:ring-leaf/20"
/>
<fieldset className="mt-5">
<legend className="text-sm font-medium text-ink">Ruh hali</legend>
<div className="mt-2 grid grid-cols-2 gap-2">
{moodOptions.map(([key, config]) => (
<button
key={key}
type="button"
onClick={() => setMood(key)}
className={`flex min-h-20 flex-col items-center justify-center rounded-md border px-3 py-3 text-center transition ${
mood === key
? "border-leaf bg-leaf text-white"
: "border-ink/10 bg-mist text-ink hover:border-leaf/60"
}`}
>
<span className="text-2xl" aria-hidden="true">
{config.icon}
</span>
<span className="mt-1 text-sm font-semibold">
{config.label}
</span>
</button>
))}
</div>
</fieldset>
<label
className="mt-5 block text-sm font-medium text-ink"
htmlFor="energy"
>
Enerji seviyesi: {energy}
</label>
<input
id="energy"
type="range"
min="1"
max="5"
step="1"
value={energy}
onChange={(event) => setEnergy(Number(event.target.value) as Energy)}
className="mt-3 w-full accent-leaf"
/>
<div className="mt-1 flex justify-between text-xs text-ink/50">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
</div>
<button
type="submit"
className="mt-6 w-full rounded-md bg-ink px-4 py-3 text-sm font-semibold text-white transition hover:bg-leaf"
>
Kaydet
</button>
{message ? (
<p className="mt-3 rounded-md bg-mist px-3 py-2 text-sm text-ink/70">
{message}
</p>
) : null}
</motion.form>
<div className="grid gap-5">
<ChartCard title="Mood ve enerji trendi">
{entries.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={lineData} margin={{ left: 0, right: 16 }}>
<XAxis dataKey="date" tickLine={false} axisLine={false} />
<YAxis
domain={[1, 5]}
tickCount={5}
tickLine={false}
axisLine={false}
/>
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="mood"
name="Mood score"
stroke="#2f7d63"
strokeWidth={3}
dot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="energy"
name="Enerji"
stroke="#d8644a"
strokeWidth={3}
dot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState text="Trend grafiği için ilk mood kaydını ekle." />
)}
</ChartCard>
<div className="grid gap-5 xl:grid-cols-[1fr_1fr]">
<ChartCard title="Mood dağılımı">
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
innerRadius={56}
outerRadius={92}
paddingAngle={3}
>
{pieData.map((item) => (
<Cell key={item.name} fill={item.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<EmptyState text="Dağılım grafiği kayıt eklendikten sonra görünür." />
)}
</ChartCard>
<ChartCard title="Insight">
{insights.length > 0 ? (
<ul className="space-y-3">
{insights.map((insight) => (
<li
key={insight}
className="rounded-md border border-ink/10 bg-mist px-3 py-3 text-sm leading-6 text-ink/75"
>
{insight}
</li>
))}
</ul>
) : (
<EmptyState text="Insight üretmek için en az bir kayıt ekle." />
)}
</ChartCard>
</div>
</div>
</section>
</main>
);
}
function ChartCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<motion.section
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
className="rounded-lg border border-ink/10 bg-white p-5 shadow-soft"
>
<h2 className="mb-4 text-lg font-semibold text-ink">{title}</h2>
{children}
</motion.section>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="flex min-h-56 items-center justify-center rounded-md border border-dashed border-ink/15 bg-mist px-4 text-center text-sm text-ink/55">
{text}
</div>
);
}
function sortEntries(entries: MoodEntry[]) {
return [...entries].sort((a, b) => a.date.localeCompare(b.date));
}
function formatShortDate(date: string) {
return new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "short",
}).format(new Date(`${date}T00:00:00`));
}
function buildInsights(entries: MoodEntry[]) {
if (entries.length === 0) {
return [];
}
const latestSeven = [...entries]
.sort((a, b) => b.date.localeCompare(a.date))
.slice(0, 7);
const moodAverage = average(latestSeven.map((entry) => entry.mood_score));
const energyAverage = average(latestSeven.map((entry) => entry.energy));
const mostFrequentMood = getMostFrequentMood(entries);
const insights = [
moodAverage >= 3
? "Son 7 kayıtta genel ruh halin pozitif görünüyor."
: "Son 7 kayıtta ruh hali ortalaman düşük görünüyor.",
];
if (energyAverage < 3) {
insights.push("Enerji seviyen son kayıtlarda düşük seyrediyor.");
} else {
insights.push("Enerji seviyen son kayıtlarda dengeli görünüyor.");
}
if (mostFrequentMood) {
insights.push(
`En sık görülen ruh halin: ${moodConfig[mostFrequentMood].label}.`,
);
}
return insights;
}
function average(values: number[]) {
return values.reduce((total, value) => total + value, 0) / values.length;
}
function getMostFrequentMood(entries: MoodEntry[]) {
const counts = entries.reduce<Record<Mood, number>>(
(currentCounts, entry) => {
currentCounts[entry.mood] += 1;
return currentCounts;
},
{ happy: 0, neutral: 0, sad: 0, angry: 0 },
);
return moodOptions.reduce<Mood | null>((winner, [moodKey]) => {
if (!winner || counts[moodKey] > counts[winner]) {
return moodKey;
}
return winner;
}, null);
}
+302
View File
@@ -0,0 +1,302 @@
"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 {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from "recharts";
const moodScores: Record<string, number> = {
happy: 4,
neutral: 3,
sad: 2,
angry: 1,
};
export default function DashboardPage() {
const journals =
useLiveQuery(() => db.journals.orderBy("date").toArray()) || [];
const tasks = useLiveQuery(() => db.tasks.toArray()) || [];
const pendingTasksCount = tasks.filter((t) => t.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 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 };
}
dataMap[j.date].moodSum += moodScores[j.mood] || 3;
dataMap[j.date].energySum += j.energy;
dataMap[j.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)),
}))
.slice(-7); // Sadece son 7 günü göster
}, [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;
});
}
});
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
}, [journals]);
// Son günlüğe ait AI Summary
const lastInsight = [...journals]
.reverse()
.find((j) => j.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="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Bugün Nasılsın?</h1>
<p className="text-muted-foreground">
Kişisel özetin ve yapay zeka analizlerin burada görünecek.
</p>
</div>
<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>
<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">
{journals.length === 0
? "Henüz kayıt girilmedi"
: "Toplam kayıt eklendi"}
</p>
</CardContent>
</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>
<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>
</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>
<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>
</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>
<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">
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="mb-4">
<h3 className="text-lg font-medium mb-1">
Ruh Hali & Enerji Trendi
</h3>
<p className="text-sm text-muted-foreground">
Son 7 günlük ortalamalar (1-5 Arası Puanlama)
</p>
</div>
<div className="h-[250px] w-full">
{trendData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#88888833"
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12 }}
/>
<YAxis
domain={[1, 5]}
axisLine={false}
tickLine={false}
tick={{ fontSize: 12 }}
width={30}
/>
<Tooltip
contentStyle={{
borderRadius: "8px",
backgroundColor: "#fff",
color: "#000",
border: "none",
}}
itemStyle={{ fontWeight: "500" }}
/>
<Legend
wrapperStyle={{ paddingTop: "10px", fontSize: "14px" }}
/>
<Line
type="monotone"
dataKey="Mood"
stroke="#3b82f6"
strokeWidth={3}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="Enerji"
stroke="#10b981"
strokeWidth={3}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
</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>
)}
</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="mb-4">
<h3 className="text-lg font-medium mb-1">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">
{tagData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={tagData}
layout="vertical"
margin={{ left: -20 }}
>
<CartesianGrid
strokeDasharray="3 3"
horizontal={false}
stroke="#88888833"
/>
<XAxis type="number" hide />
<YAxis
dataKey="name"
type="category"
axisLine={false}
tickLine={false}
tick={{ fontSize: 13 }}
width={90}
/>
<Tooltip
cursor={{ fill: "transparent" }}
contentStyle={{
borderRadius: "8px",
backgroundColor: "#fff",
color: "#000",
border: "none",
}}
/>
<Bar
dataKey="Değer"
fill="#8b5cf6"
radius={[0, 4, 4, 0]}
barSize={20}
/>
</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>
)}
</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ü
</h3>
<p className="text-sm text-foreground/80 leading-relaxed italic">
{lastInsight
? `"${lastInsight}"`
: "Henüz bir içgörü oluşmadı. Biraz günlük yaz, AI analiz yapsın."}
</p>
</Card>
</div>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function updateProfile(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Kullanıcı bulunamadı.' }
}
const firstName = formData.get('firstName') as string
const lastName = formData.get('lastName') as string
const avatarFile = formData.get('avatar') as File | null
let avatarUrl = 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
.from('avatars')
.upload(fileName, avatarFile, { upsert: true })
if (uploadError) {
return { error: 'Profil fotoğrafı yüklenirken hata oluştu: ' + uploadError.message }
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName)
avatarUrl = publicUrl
}
// Update profile
const updateData: any = {
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 })
if (error) {
return { error: 'Profil güncellenirken hata oluştu: ' + error.message }
}
revalidatePath('/settings')
return { success: true }
}
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
})
if (error) {
return { error: 'Şifre güncellenirken hata oluştu: ' + error.message }
}
return { success: true }
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Bot, Save, User, KeyRound } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { updateProfile, updatePassword } from "./actions";
export default function SettingsPage() {
const [aiProvider, setAiProvider] = useState("groq");
const [apiKey, setApiKey] = useState("");
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 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 || "");
}
}
}
fetchProfile();
}, []);
const handleSaveAI = () => {
localStorage.setItem("mindspace_ai_provider", aiProvider);
localStorage.setItem("mindspace_api_key", apiKey);
setSaveStatus("Ayarlar başarıyla kaydedildi!");
setTimeout(() => setSaveStatus(""), 3000);
};
const handleProfileAction = async (formData: FormData) => {
const res = await updateProfile(formData);
if (res?.error) {
setProfileSaveStatus("Hata: " + res.error);
} else {
setProfileSaveStatus("Profil başarıyla güncellendi!");
if (formData.get("avatar") && (formData.get("avatar") as File).size > 0) {
window.location.reload();
}
}
setTimeout(() => setProfileSaveStatus(""), 3000);
};
const handlePasswordAction = async (formData: FormData) => {
const res = await updatePassword(formData);
if (res?.error) {
setPasswordSaveStatus("Hata: " + res.error);
} else {
setPasswordSaveStatus("Şifre başarıyla güncellendi!");
formRef.current?.reset();
}
setTimeout(() => setPasswordSaveStatus(""), 3000);
};
return (
<div className="flex flex-col gap-6 p-4 max-w-2xl mx-auto w-full">
<div>
<h1 className="text-3xl font-bold text-foreground">Ayarlar</h1>
<p className="text-muted-foreground mt-1">
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 items-center gap-3 border-b border-border/50 pb-4">
<User className="w-6 h-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">
{avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="w-16 h-16 rounded-full object-cover border border-border" />
) : (
<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="space-y-1 flex-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)} />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Soyad</Label>
<Input id="lastName" name="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="flex items-center gap-4 mt-4">
<Button type="submit" className="w-max">
<Save className="w-4 h-4 mr-2" />
Profili Kaydet
</Button>
{profileSaveStatus && (
<span className={`text-sm ${profileSaveStatus.startsWith("Hata") ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
{profileSaveStatus}
</span>
)}
</div>
</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 items-center gap-3 border-b border-border/50 pb-4">
<KeyRound className="w-6 h-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 />
</div>
<div className="flex items-center gap-4 mt-4">
<Button type="submit" className="w-max">
<Save className="w-4 h-4 mr-2" />
Şifreyi Güncelle
</Button>
{passwordSaveStatus && (
<span className={`text-sm ${passwordSaveStatus.startsWith("Hata") ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
{passwordSaveStatus}
</span>
)}
</div>
</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 items-center gap-3 border-b border-border/50 pb-4">
<Bot className="w-6 h-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}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sağlayıcı seçin" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ollama">
Ollama (Yerel & Gizlilik Odaklı)
</SelectItem>
<SelectItem value="openai">OpenAI (GPT-4o vb.)</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.
</p>
</div>
{aiProvider !== "ollama" && (
<div className="space-y-2">
<Label>API Anahtarı ({aiProvider.toUpperCase()})</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.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.
</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" />
Kaydet
</Button>
{saveStatus && (
<span className="text-sm text-green-600 dark:text-green-400">
{saveStatus}
</span>
)}
</div>
</div>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "@/lib/db";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Trash2 } from "lucide-react";
export default function TasksPage() {
const [title, setTitle] = useState("");
const tasks = useLiveQuery(
() => db.tasks.toArray(), // Şimdilik hepsini çekiyoruz, sıralama yapılabilir
);
const handleAddTask = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
const now = new Date().toISOString();
try {
await db.tasks.add({
id: uuidv4(),
title,
status: "todo",
ai_generated: false,
date: now.split("T")[0],
created_at: now,
});
setTitle("");
} catch (error) {
console.error("Görev eklenemedi:", error);
}
};
const toggleTaskStatus = async (id: string, currentStatus: string) => {
await db.tasks.update(id, {
status: currentStatus === "todo" ? "completed" : "todo",
});
};
const deleteTask = async (id: string) => {
await db.tasks.delete(id);
};
const pendingTasks = tasks?.filter((t) => t.status === "todo") || [];
const completedTasks = tasks?.filter((t) => t.status === "completed") || [];
return (
<div className="space-y-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Görevler & Planlar
</h1>
<p className="text-muted-foreground">
Kişisel hedeflerin ve YZ tarafından önerilen aksiyonlar.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yeni Görev</CardTitle>
<CardDescription>Aklındakini aksiyona dönüştür.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAddTask} className="flex gap-2">
<Input
placeholder="Örn: 15 dakika yürüyüş yap..."
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-1"
/>
<Button type="submit">Ekle</Button>
</form>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2 pt-4">
{/* Yapılacaklar */}
<div className="space-y-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
Yapılacaklar{" "}
<span className="text-sm px-2 py-0.5 rounded-full bg-primary/20 text-primary">
{pendingTasks.length}
</span>
</h2>
<div className="space-y-2">
{pendingTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">
Bekleyen görev yok.
</p>
) : (
pendingTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-card shadow-sm group transition-all hover:border-primary/50"
>
<Checkbox
checked={false}
onCheckedChange={() =>
toggleTaskStatus(task.id, task.status)
}
className="w-5 h-5"
/>
<span className="flex-1 text-sm font-medium">
{task.title}
</span>
<button
onClick={() => deleteTask(task.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 text-muted-foreground hover:text-red-500 transition-all rounded-md hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
{/* Tamamlananlar */}
<div className="space-y-4">
<h2 className="text-xl font-semibold opacity-70 flex items-center gap-2">
Tamamlananlar{" "}
<span className="text-sm px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{completedTasks.length}
</span>
</h2>
<div className="space-y-2">
{completedTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">
Hiç görev tamamlanmadı.
</p>
) : (
completedTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-muted/40 shadow-sm group"
>
<Checkbox
checked={true}
onCheckedChange={() =>
toggleTaskStatus(task.id, task.status)
}
className="w-5 h-5 data-[state=checked]:bg-muted-foreground data-[state=checked]:border-muted-foreground"
/>
<span className="flex-1 text-sm line-through text-muted-foreground">
{task.title}
</span>
<button
onClick={() => deleteTask(task.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 text-muted-foreground hover:text-red-500 transition-all rounded-md hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}