feat: add form components and UI elements for enhanced user interaction

- Introduced Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage components for structured form handling.
- Added Input, Textarea, and Select components for user input.
- Implemented Label and Separator components for better UI organization.
- Created Skeleton component for loading states.
- Developed Toast and Toaster components for user notifications.
- Integrated useToast hook for managing toast notifications.
- Established AI analysis functionality with analyzeJournalWithLocalAI.
- Set up Dexie for local database management with Journal and Task models.
- Configured Supabase client for server-side and browser-side interactions.
- Defined database schema for journals, tasks, chat sessions, and messages with Row Level Security policies.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Poyraz Avsever
2026-05-06 16:20:14 +03:00
co-authored by Copilot
parent 3432bbab33
commit c73bf0e499
40 changed files with 9104 additions and 569 deletions
+105
View File
@@ -0,0 +1,105 @@
# MindSpace Proje Yol Haritası (Roadmap)
Bu doküman, `PRD.md` gereksinimlerinin **Supabase**, **Gemini API** ve **Shadcn UI** kullanılarak uygulanması için detaylı, faz bazlı geliştirme planını içermektedir.
---
## Faz 1: Proje Kurulumu ve Altyapı
**Amacı:** Geliştirme ortamının hazırlanması, UI bileşen sisteminin kurulması ve veritabanı / yetkilendirme (Auth) altyapısının Supabase ile entegre edilmesi.
1. **Next.js Kurulumu**
- Next.js (App Router), React 19, TypeScript ve Tailwind CSS kullanılarak projenin başlatılması.
- Dizin yapısının oluşturulması (`src/app`, `src/components`, `src/lib`, `src/hooks`).
2. **Shadcn UI ve Stil Entegrasyonu**
- Shadcn UI CLI ile başlatılması.
- Temel bileşenlerin (Button, Card, Input, Label, Dialog, Form, Toast vb.) eklenmesi.
- `next-themes` kullanılarak Dark/Light mod geçişinin ayarlanması (Varsayılan olarak Dark Mode ağırlıklı tasarım).
3. **Supabase Proje Kurulumu**
- Supabase projesinin oluşturulması ve API key'lerin `.env.local` dosyasına eklenmesi.
- `@supabase/ssr` veya `@supabase/supabase-js` ile client ve server bileşenleri için istemcilerin oluşturulması.
4. **Veritabanı Şemasının Oluşturulması (Supabase SQL)**
- `users` tablosu (Supabase Auth ile senkronize çalışacak yapı).
- `journals` tablosu: Kullanıcının günlükleri (id, user_id, date, mood, energy, content, ai_tags, ai_sentiment_score, ai_summary vb.).
- `tasks` tablosu: Görev yönetimi (id, user_id, title, description, status, source_journal_id).
- `chat_sessions` ve `chat_messages` tabloları (Geçmiş sohbetleri tutmak için).
5. **Kimlik Doğrulama (Authentication)**
- Supabase Auth kullanılarak Email/Şifre ile kayıt olma (Register) ve giriş yapma (Login) sayfalarının geliştirilmesi.
- Auth middleware ile yetkisiz kullanıcıların Dashboard'a erişiminin engellenmesi.
---
## Faz 2: Dashboard ve Temel Düzen (Layout)
**Amacı:** Kullanıcının giriş yaptığında karşılaşacağı ana navigasyon ve dashboard iskeletinin modern bir görünümle inşa edilmesi.
1. **Ana Layout ve Navigasyon**
- Masaüstü için sol veya üst menü bar (Sidebar/Navbar).
- Menü linkleri: Dashboard, Günlük, Görevler, Sohbet, Ayarlar.
- Responsive tasarım ile mobilde hamburger menü yapısı.
2. **Dashboard İskeleti**
- CSS Grid/Flexbox kullanarak kartların yerleşimi.
- Profil bilgisi ve Çıkış Yap butonu içeren bir üst bilgi (header) alanı.
3. **Boş Durum (Empty State) Yönetimi**
- Kullanıcının henüz günlüğü, görevi veya AI analizi yoksa gösterilecek yönlendirici ve estetik bileşenlerin tasarlanması (örn. "Henüz veri yok, ilk günlüğünü yaz").
---
## Faz 3: Günlük (Journal) ve Görev (Task) Modülleri
**Amacı:** Kullanıcının duygu, enerji ve günlük notlarını yazabilmesi, ayrıca manuel olarak görev ekleyip takip edebilmesi.
1. **Günlük (Journal) Modülü**
- **Oluşturma Ekranı:** Günlük yazma formu. Shadcn form bileşenleriyle validasyon sağlanması.
- **Mood & Enerji Seçimi:** Buton grupları veya slider kullanılarak duygu durumu ve enerji (1-5) seçimi.
- **Listeleme:** Kaydedilen günlüklerin tarih sırasına göre listelenmesi.
- **Detay Görünümü:** Seçilen günlüğün tam sayfa veya modal içinde okunması, silinmesi ve düzenlenmesi.
2. **Görev (Task) Modülü**
- **Görev Ekleme:** Basit başlık ve detay içeren görev ekleme formu.
- **Görev Durumu:** "Todo", "In Progress", "Completed" durumlarının listelenmesi ve checkbox veya sürükle bırak tarzında güncellenmesi.
- **Günlükle İlişkilendirme:** Görevlerin varsa kaynak günlük ID'sini göstermesi.
---
## Faz 4: Gemini API Entegrasyonu (AI Analizi)
**Amacı:** Kullanıcının girdiği günlük metninin Google Gemini API ile analiz edilip etiketler, duygu skoru, özet ve görev önerileri üretmesi.
1. **Gemini API Kurulumu**
- `@google/generative-ai` SDK'sının projeye dahil edilmesi.
- API Key'in `.env.local` içinde güvenli yapılandırılması.
2. **Analiz Endpoint'inin Geliştirilmesi**
- `POST /api/analyze-journal` gibi bir route oluşturularak, kullanıcının günlüğünün modele gönderilmesi.
- **Prompt Engineering:** Gemini'ye gönderilecek prompt'un, yanıtın stabil bir JSON formatında (tags, sentiment_score, summary, reflection, suggested_tasks) dönmesini garantileyecek şekilde tasarlanması.
3. **Veritabanı ve UI Entegrasyonu**
- Dönen AI analiz sonuçlarının Supabase'deki `journals` tablosuna (ai_tags, ai_summary vb.) Update edilmesi.
- Günlük detay ekranında AI analiz sonucunun görsel bir kart olarak sunulması.
4. **Görev Önerileri (Suggested Tasks) Akışı**
- AI'ın günlüğe bakarak çıkardığı görev önerilerinin kullanıcıya sunulması ("Bunu görev listene eklemek ister misin?").
- Onaylanan önerilerin `tasks` tablosuna kaydedilmesi.
---
## Faz 5: Dashboard Veri Görselleştirme
**Amacı:** Toplanan verilerin (mood, enerji, etiketler) grafikler yardımıyla analiz edilmesi ve özetlerin sunulması.
1. **Recharts Entegrasyonu ve Veri Hazırlama**
- Recharts kütüphanesinin eklenmesi.
- Supabase'den son 7 ve 30 güne ait verilerin çekilip grafiklere uygun formata dönüştürülmesi.
2. **Mood ve Enerji Trend Grafikleri**
- Günlere göre duygu skorları ve enerji (1-5) seviyesini gösteren çizgi veya bar grafikleri.
3. **Etiket Dağılımı Grafiği**
- AI tarafından en çok oluşturulan etiketlerin pasta (Pie) veya yatay çubuk (Bar) grafik ile gösterilmesi.
4. **Dashboard Özet Kartları**
- "Son Günlüklerin", "Aktif Görevlerin" ve "Haftalık AI İçgörüsünün" özet widget'lar halinde Dashboard ekranında sergilenmesi.
---
## Faz 6: AI Chat Modülü (Sohbet)
**Amacı:** Kullanıcının geçmiş günlüklerini bağlam olarak sunup, bir "Dijital Yansıma Asistanı" (Gemini) ile sohbet etmesi.
1. **Sohbet UI Tasarımı**
- Shadcn UI ile scrollable mesaj listesi, kullanıcı giriş alanı ve mesaj baloncuklarının tasarlanması.
2. **Bağlam (Context) Yönetimi**
- Kullanıcı bir soru sorduğunda, Supabase'den son X günlüğün veya içeriğe uygun günlüklerin çekilerek Gemini API'ye sistem mesajı/context olarak verilmesi.
3. **Sohbet Akışı ve Veri Kaydı**
- Gemini API üzerinden yanıtların tercihen "stream" (akış) yöntemiyle hızlıca UI'da gösterilmesi.
- Tamamlanan konuşmaların `chat_sessions` ve `chat_messages` tablolarına kaydedilerek, sayfa yenilendiğinde eski sohbetin görünmesinin sağlanması.
4. **Güvenlik ve Sınırlar**
- Prompt seviyesinde Gemini'ye "Tıbbi veya klinik tavsiye verme, sadece yansıtıcı ol" komutunun sıkıca verilmesi.
+54 -31
View File
@@ -1,35 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
:root {
color-scheme: light;
background: #f5f7f4;
@layer base {
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
}
body {
min-height: 100%;
margin: 0;
background:
linear-gradient(135deg, rgba(47, 125, 99, 0.08), transparent 34%),
linear-gradient(315deg, rgba(216, 100, 74, 0.08), transparent 30%),
#f5f7f4;
color: #17211f;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
button,
input,
select {
font: inherit;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+216
View File
@@ -0,0 +1,216 @@
"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>
);
}
+28 -4
View File
@@ -1,9 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/theme-provider";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: "Mood Tracker MVP",
description: "LocalStorage tabanli minimal mood dashboard uygulamasi.",
title: "MindSpace",
description: "AI Destekli Kişisel Yaşam ve Planlama Dashboard'u",
};
export default function RootLayout({
@@ -12,8 +19,25 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="tr">
<body>{children}</body>
<html lang="tr" className={cn("font-sans", geist.variable)} suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<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>
</ThemeProvider>
</body>
</html>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
// Ideally, pass this error to the UI via URL params or state
redirect('/login?error=true')
}
revalidatePath('/', 'layout')
redirect('/')
}
export async function signup(formData: FormData) {
const supabase = await createClient()
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/login?error=true')
}
revalidatePath('/', 'layout')
redirect('/')
}
+42
View File
@@ -0,0 +1,42 @@
import { login, signup } from './actions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
export default function LoginPage() {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">MindSpace</CardTitle>
<CardDescription className="text-center">
Hesabınıza giriş yapın veya yeni hesap oluşturun
</CardDescription>
</CardHeader>
<CardContent>
<form>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-posta</Label>
<Input id="email" name="email" type="email" placeholder="ornek@mail.com" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Şifre</Label>
<Input id="password" name="password" type="password" required />
</div>
</div>
<div className="flex flex-col space-y-2 mt-6">
<Button formAction={login} className="w-full">
Giriş Yap
</Button>
<Button formAction={signup} variant="outline" className="w-full">
Kayıt Ol
</Button>
</div>
</form>
</CardContent>
</Card>
</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);
}
+168 -402
View File
@@ -1,442 +1,208 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import logo from "../assets/logo.png";
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 {
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend
} 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;
const moodScores: Record<string, number> = { happy: 4, neutral: 3, sad: 2, angry: 1 };
type MoodEntry = {
id: string;
date: string;
mood: Mood;
mood_score: MoodScore;
energy: Energy;
};
export default function DashboardPage() {
const journals = useLiveQuery(() => db.journals.orderBy("date").toArray()) || [];
const tasks = useLiveQuery(() => db.tasks.toArray()) || [];
const STORAGE_KEY = "mood-tracker-entries";
const pendingTasksCount = tasks.filter(t => t.status === "todo").length;
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" },
};
// 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 : "--";
const moodOptions = Object.entries(moodConfig) as Array<
[Mood, (typeof moodConfig)[Mood]]
>;
// 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 }> = {};
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;
journals.forEach(j => {
if (!dataMap[j.date]) {
dataMap[j.date] = { date: j.date, moodSum: 0, energySum: 0, count: 0 };
}
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);
dataMap[j.date].moodSum += moodScores[j.mood] || 3;
dataMap[j.date].energySum += j.energy;
dataMap[j.date].count += 1;
});
setMessage("Kayıt kaydedildi. Dashboard güncellendi.");
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 (
<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 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="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.
<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>
</div>
</CardContent>
</Card>
<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}
<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>
) : null}
</motion.form>
</CardContent>
</Card>
<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 }}
<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>
) : (
<EmptyState text="Trend grafiği için ilk mood kaydını ekle." />
<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>
)}
</ChartCard>
</div>
</Card>
<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>
{/* 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>
) : (
<EmptyState text="Dağılım grafiği kayıt eklendikten sonra görünür." />
<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>
)}
</ChartCard>
</div>
</Card>
<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>
<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>
</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);
}
+134
View File
@@ -0,0 +1,134 @@
"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>
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { User } from "lucide-react";
export function Header() {
return (
<header className="h-16 border-b border-border bg-background flex items-center justify-between px-6 shrink-0">
<div className="text-sm text-muted-foreground font-medium">
Kişisel Odak Alanı
</div>
<div className="flex items-center gap-3">
<div className="text-sm font-medium">Lokal Kullanıcı</div>
<div className="flex items-center justify-center h-9 w-9 bg-muted text-muted-foreground rounded-full">
<User className="h-5 w-5" />
</div>
</div>
</header>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Book, CheckSquare, MessageCircle, Settings, BrainCircuit } from "lucide-react";
import { cn } from "@/lib/utils";
const routes = [
{ name: "Dashboard", path: "/", icon: LayoutDashboard },
{ name: "Günlük", path: "/journal", icon: Book },
{ name: "Görevler", path: "/tasks", icon: CheckSquare },
{ name: "Sohbet", path: "/chat", icon: MessageCircle },
{ name: "Ayarlar", path: "/settings", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-64 border-r border-border bg-card min-h-screen flex flex-col shrink-0">
<div className="h-16 flex items-center px-6 border-b border-border text-primary">
<BrainCircuit className="h-6 w-6 mr-2" />
<h2 className="text-xl font-bold tracking-tight">MindSpace</h2>
</div>
<nav className="flex-1 p-4 space-y-1">
{routes.map((route) => {
const isActive = pathname === route.path || pathname?.startsWith(route.path + "/");
// Home route exact match check
const isExactActive = route.path === "/" ? pathname === "/" : isActive;
return (
<Link
key={route.path}
href={route.path}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-sm font-medium",
isExactActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<route.icon className="h-5 w-5" />
{route.name}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-border/50 text-xs text-muted-foreground/60 text-center">
Local-First AI Workspace
</div>
</aside>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+168
View File
@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+269
View File
@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+178
View File
@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+192
View File
@@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+129
View File
@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+35
View File
@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+194
View File
@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
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() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+48
View File
@@ -0,0 +1,48 @@
export interface AIAnalysisResult {
ai_tags: string[];
ai_sentiment_score: number;
ai_summary: string;
suggested_tasks: string[];
}
export async function analyzeJournalWithLocalAI(content: string, model: string = "llama3"): Promise<AIAnalysisResult | null> {
const prompt = `
Sen şefkatli bir yapay zeka asistanı ve kişisel yansıma (reflection) yardımcısısın.
Aşağıdaki günlük girdisini analiz edip tam olarak belirtilen JSON formatında yanıt vermelisin. Düz metin kullanma, SADECE geçerli bir JSON objesi döndür.
Günlük Metni: "${content}"
İstenen JSON Formatı:
{
"ai_tags": ["#duygu", "#konu", vb.],
"ai_sentiment_score": 0.5, // -1.0 (çok negatif) ile +1.0 (çok pozitif) arası ondalıklı bir değer
"ai_summary": "Kullanıcının durumunu özetleyen 1-2 cümlelik şefkatli geri bildirim.",
"suggested_tasks": ["yapılabilecek pratik aksiyon 1", "aksiyon 2"] // Eylem gerektirmiyorsa boş dizi []
}
`;
try {
const response = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: model, // Gerekirse "gemini-3-flash-preview:latest" gibi kendi modeliniz de olabilir
prompt: prompt,
stream: false,
format: "json"
})
});
if (!response.ok) {
throw new Error(`Ollama API Hatası: ${response.statusText}`);
}
const data = await response.json();
const parsedResult = JSON.parse(data.response);
return parsedResult as AIAnalysisResult;
} catch (error) {
console.error("AI Analizi başarısız oldu (Ollama açık olmayabilir):", error);
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
import Dexie, { type Table } from "dexie";
// Veritabanı Modelleri
export interface Journal {
id: string;
date: string;
mood: string;
energy: number;
content: string;
ai_tags?: string[];
ai_sentiment_score?: number;
ai_summary?: string;
created_at: string;
updated_at: string;
}
export interface Task {
id: string;
journal_id?: string;
title: string;
status: "todo" | "in_progress" | "completed";
ai_generated: boolean;
date: string;
created_at: string;
}
export class MindSpaceDB extends Dexie {
journals!: Table<Journal>;
tasks!: Table<Task>;
constructor() {
super("MindSpaceDatabase");
// Schema tanımlamaları.
// IndexedDB'de sadece indekslenecek (üzerinde arama/sıralama yapılacak) alanları belirtiriz.
this.version(1).stores({
journals: "id, date, mood",
tasks: "id, status, date, journal_id"
});
}
}
export const db = new MindSpaceDB();
+8
View File
@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
+50
View File
@@ -0,0 +1,50 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// IMPORTANT: Avoid writing any logic between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
const {
data: { user },
} = await supabase.auth.getUser()
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
+29
View File
@@ -0,0 +1,29 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch (error) {
// The `set` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+19
View File
@@ -0,0 +1,19 @@
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)$).*)',
],
}
+5888 -119
View File
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -9,16 +9,41 @@
"lint": "next lint"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15",
"@supabase/ssr": "^0.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
"framer-motion": "^11.18.2",
"lucide-react": "^1.14.0",
"next": "^15.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0"
"react-hook-form": "^7.75.0",
"recharts": "^2.15.4",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^14.0.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-config-next": "^15.1.6",
+123
View File
@@ -0,0 +1,123 @@
-- Enable necessary extensions
create extension if not exists "uuid-ossp";
-- 1. Create journals table
create table public.journals (
id uuid default uuid_generate_v4() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
date timestamp with time zone default timezone('utc'::text, now()) not null,
mood text not null,
energy smallint not null,
content text not null,
ai_tags text[] default array[]::text[],
ai_sentiment_score numeric,
ai_summary text,
ai_reflection text,
analysis_status text default 'pending'::text,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- 2. Create tasks table
create table public.tasks (
id uuid default uuid_generate_v4() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
date timestamp with time zone default timezone('utc'::text, now()) not null,
title text not null,
description text,
status text default 'todo'::text,
ai_generated boolean default false,
source_journal_id uuid references public.journals(id) on delete set null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- 3. Create chat_sessions table
create table public.chat_sessions (
id uuid default uuid_generate_v4() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- 4. Create chat_messages table
create table public.chat_messages (
id uuid default uuid_generate_v4() primary key,
session_id uuid references public.chat_sessions(id) on delete cascade not null,
role text not null,
content text not null,
context_journal_ids uuid[] default array[]::uuid[],
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- RLS (Row Level Security) Policies
alter table public.journals enable row level security;
alter table public.tasks enable row level security;
alter table public.chat_sessions enable row level security;
alter table public.chat_messages enable row level security;
-- Journals Policies
create policy "Users can view their own journals." on public.journals
for select using (auth.uid() = user_id);
create policy "Users can insert their own journals." on public.journals
for insert with check (auth.uid() = user_id);
create policy "Users can update their own journals." on public.journals
for update using (auth.uid() = user_id);
create policy "Users can delete their own journals." on public.journals
for delete using (auth.uid() = user_id);
-- Tasks Policies
create policy "Users can view their own tasks." on public.tasks
for select using (auth.uid() = user_id);
create policy "Users can insert their own tasks." on public.tasks
for insert with check (auth.uid() = user_id);
create policy "Users can update their own tasks." on public.tasks
for update using (auth.uid() = user_id);
create policy "Users can delete their own tasks." on public.tasks
for delete using (auth.uid() = user_id);
-- Chat Sessions Policies
create policy "Users can view their own chat sessions." on public.chat_sessions
for select using (auth.uid() = user_id);
create policy "Users can insert their own chat sessions." on public.chat_sessions
for insert with check (auth.uid() = user_id);
create policy "Users can update their own chat sessions." on public.chat_sessions
for update using (auth.uid() = user_id);
create policy "Users can delete their own chat sessions." on public.chat_sessions
for delete using (auth.uid() = user_id);
-- Chat Messages Policies
-- Assuming users can only access messages in their own sessions
create policy "Users can view their own chat messages." on public.chat_messages
for select using (
exists (
select 1 from public.chat_sessions
where chat_sessions.id = chat_messages.session_id
and chat_sessions.user_id = auth.uid()
)
);
create policy "Users can insert their own chat messages." on public.chat_messages
for insert with check (
exists (
select 1 from public.chat_sessions
where chat_sessions.id = session_id
and chat_sessions.user_id = auth.uid()
)
);
create policy "Users can update their own chat messages." on public.chat_messages
for update using (
exists (
select 1 from public.chat_sessions
where chat_sessions.id = session_id
and chat_sessions.user_id = auth.uid()
)
);
create policy "Users can delete their own chat messages." on public.chat_messages
for delete using (
exists (
select 1 from public.chat_sessions
where chat_sessions.id = session_id
and chat_sessions.user_id = auth.uid()
)
);
+41 -2
View File
@@ -1,7 +1,8 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"],
darkMode: ["class"],
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
@@ -11,13 +12,51 @@ const config: Config = {
coral: "#d8644a",
sun: "#e3aa38",
sky: "#4f7fbf",
border: "var(--border)",
input: "var(--input)",
ring: "var(--ring)",
background: "var(--background)",
foreground: "var(--foreground)",
primary: {
DEFAULT: "var(--primary)",
foreground: "var(--primary-foreground)",
},
secondary: {
DEFAULT: "var(--secondary)",
foreground: "var(--secondary-foreground)",
},
destructive: {
DEFAULT: "var(--destructive)",
foreground: "var(--destructive-foreground)",
},
muted: {
DEFAULT: "var(--muted)",
foreground: "var(--muted-foreground)",
},
accent: {
DEFAULT: "var(--accent)",
foreground: "var(--accent-foreground)",
},
popover: {
DEFAULT: "var(--popover)",
foreground: "var(--popover-foreground)",
},
card: {
DEFAULT: "var(--card)",
foreground: "var(--card-foreground)",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
boxShadow: {
soft: "0 18px 50px rgba(23, 33, 31, 0.08)",
},
},
},
plugins: [],
plugins: [require("tailwindcss-animate")],
};
export default config;
+5 -1
View File
@@ -17,7 +17,11 @@
{
"name": "next"
}
]
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]