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:
co-authored by
Copilot
parent
3432bbab33
commit
c73bf0e499
+105
@@ -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
@@ -1,35 +1,58 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss/base";
|
||||||
@tailwind components;
|
@import "tailwindcss/components";
|
||||||
@tailwind utilities;
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
color-scheme: light;
|
:root {
|
||||||
background: #f5f7f4;
|
--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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
@layer base {
|
||||||
box-sizing: border-box;
|
* {
|
||||||
}
|
@apply border-border;
|
||||||
|
}
|
||||||
html {
|
body {
|
||||||
min-height: 100%;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -1,9 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Mood Tracker MVP",
|
title: "MindSpace",
|
||||||
description: "LocalStorage tabanli minimal mood dashboard uygulamasi.",
|
description: "AI Destekli Kişisel Yaşam ve Planlama Dashboard'u",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -12,8 +19,25 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="tr">
|
<html lang="tr" className={cn("font-sans", geist.variable)} suppressHydrationWarning>
|
||||||
<body>{children}</body>
|
<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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+171
-405
@@ -1,442 +1,208 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { useMemo } from "react";
|
||||||
import Image from "next/image";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import logo from "../assets/logo.png";
|
import { Activity, Brain, PenTool, CheckCircle } from "lucide-react";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
Cell,
|
|
||||||
Legend,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
LineChart,
|
||||||
Pie,
|
Line,
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
type Mood = "happy" | "neutral" | "sad" | "angry";
|
const moodScores: Record<string, number> = { happy: 4, neutral: 3, sad: 2, angry: 1 };
|
||||||
type MoodScore = 4 | 3 | 2 | 1;
|
|
||||||
type Energy = 1 | 2 | 3 | 4 | 5;
|
|
||||||
|
|
||||||
type MoodEntry = {
|
export default function DashboardPage() {
|
||||||
id: string;
|
const journals = useLiveQuery(() => db.journals.orderBy("date").toArray()) || [];
|
||||||
date: string;
|
const tasks = useLiveQuery(() => db.tasks.toArray()) || [];
|
||||||
mood: Mood;
|
|
||||||
mood_score: MoodScore;
|
|
||||||
energy: Energy;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STORAGE_KEY = "mood-tracker-entries";
|
const pendingTasksCount = tasks.filter(t => t.status === "todo").length;
|
||||||
|
|
||||||
const moodConfig: Record<
|
// Bugüne ait enerji seviyesi
|
||||||
Mood,
|
const today = new Date().toISOString().split("T")[0];
|
||||||
{ label: string; icon: string; score: MoodScore; color: string }
|
const todaysJournal = journals.find(j => j.date === today);
|
||||||
> = {
|
const todaysEnergy = todaysJournal ? todaysJournal.energy : "--";
|
||||||
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<
|
// 1. Grafik: Son 7 günün Ruh Hali ve Enerji trendi
|
||||||
[Mood, (typeof moodConfig)[Mood]]
|
const trendData = useMemo(() => {
|
||||||
>;
|
const dataMap: Record<string, { date: string, moodSum: number, energySum: number, count: number }> = {};
|
||||||
|
|
||||||
const today = () => new Date().toISOString().slice(0, 10);
|
journals.forEach(j => {
|
||||||
|
if (!dataMap[j.date]) {
|
||||||
const createId = () =>
|
dataMap[j.date] = { date: j.date, moodSum: 0, energySum: 0, count: 0 };
|
||||||
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 {
|
dataMap[j.date].moodSum += moodScores[j.mood] || 3;
|
||||||
window.localStorage.removeItem(STORAGE_KEY);
|
dataMap[j.date].energySum += j.energy;
|
||||||
} finally {
|
dataMap[j.date].count += 1;
|
||||||
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 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 (
|
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">
|
<div className="space-y-6 max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<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 flex-col gap-2">
|
||||||
<div className="flex items-center gap-4">
|
<h1 className="text-3xl font-bold tracking-tight">Bugün Nasılsın?</h1>
|
||||||
<Image
|
<p className="text-muted-foreground">Kişisel özetin ve yapay zeka analizlerin burada görünecek.</p>
|
||||||
src={logo}
|
</div>
|
||||||
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]">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<motion.form
|
<Card>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<CardTitle className="text-sm font-medium">Günlük Kayıtları</CardTitle>
|
||||||
transition={{ duration: 0.35 }}
|
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||||
onSubmit={handleSubmit}
|
</CardHeader>
|
||||||
className="rounded-lg border border-ink/10 bg-white p-5 shadow-soft"
|
<CardContent>
|
||||||
>
|
<div className="text-2xl font-bold">{journals.length}</div>
|
||||||
<div className="mb-5">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
<h2 className="text-xl font-semibold text-ink">Bugünün kaydı</h2>
|
{journals.length === 0 ? "Henüz kayıt girilmedi" : "Toplam kayıt eklendi"}
|
||||||
<p className="mt-1 text-sm text-ink/60">
|
|
||||||
Aynı tarih tekrar kaydedilirse eski kayıt güncellenir.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-ink" htmlFor="date">
|
<Card>
|
||||||
Tarih
|
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||||
</label>
|
<CardTitle className="text-sm font-medium">Aktif Görevler</CardTitle>
|
||||||
<input
|
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
id="date"
|
</CardHeader>
|
||||||
type="date"
|
<CardContent>
|
||||||
value={date}
|
<div className="text-2xl font-bold">{pendingTasksCount}</div>
|
||||||
onChange={(event) => setDate(event.target.value)}
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
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"
|
Bekleyen görev
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
</p>
|
||||||
) : null}
|
</CardContent>
|
||||||
</motion.form>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-5">
|
<Card>
|
||||||
<ChartCard title="Mood ve enerji trendi">
|
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||||
{entries.length > 0 ? (
|
<CardTitle className="text-sm font-medium">Bugünkü Enerji</CardTitle>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
<LineChart data={lineData} margin={{ left: 0, right: 16 }}>
|
</CardHeader>
|
||||||
<XAxis dataKey="date" tickLine={false} axisLine={false} />
|
<CardContent>
|
||||||
<YAxis
|
<div className="text-2xl font-bold">{todaysEnergy}</div>
|
||||||
domain={[1, 5]}
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
tickCount={5}
|
{todaysJournal ? "/ 5 Seviyesinde" : "Kayıt bekleniyor"}
|
||||||
tickLine={false}
|
</p>
|
||||||
axisLine={false}
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
<Card>
|
||||||
<Line
|
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||||
type="monotone"
|
<CardTitle className="text-sm font-medium">Local AI Durumu</CardTitle>
|
||||||
dataKey="mood"
|
<Brain className="h-4 w-4 text-muted-foreground" />
|
||||||
name="Mood score"
|
</CardHeader>
|
||||||
stroke="#2f7d63"
|
<CardContent>
|
||||||
strokeWidth={3}
|
<div className="text-2xl font-bold text-green-500">Hazır</div>
|
||||||
dot={{ r: 4 }}
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
/>
|
Ollama / Veri bekliyor
|
||||||
<Line
|
</p>
|
||||||
type="monotone"
|
</CardContent>
|
||||||
dataKey="energy"
|
</Card>
|
||||||
name="Enerji"
|
</div>
|
||||||
stroke="#d8644a"
|
|
||||||
strokeWidth={3}
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7 ">
|
||||||
dot={{ r: 4 }}
|
{/* 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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</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]">
|
{/* Bar Chart & Insight */}
|
||||||
<ChartCard title="Mood dağılımı">
|
<div className="lg:col-span-3 space-y-4 flex flex-col">
|
||||||
{pieData.length > 0 ? (
|
<Card className="p-6 flex-1 flex flex-col">
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<div className="mb-4">
|
||||||
<PieChart>
|
<h3 className="text-lg font-medium mb-1">AI Konu Dağılımı</h3>
|
||||||
<Pie
|
<p className="text-sm text-muted-foreground">Günlüklerinden çıkarılan en sık 5 etiket</p>
|
||||||
data={pieData}
|
</div>
|
||||||
dataKey="value"
|
<div className="h-[150px] w-full mt-auto">
|
||||||
nameKey="name"
|
{tagData.length > 0 ? (
|
||||||
innerRadius={56}
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
outerRadius={92}
|
<BarChart data={tagData} layout="vertical" margin={{ left: -20 }}>
|
||||||
paddingAngle={3}
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#88888833" />
|
||||||
>
|
<XAxis type="number" hide />
|
||||||
{pieData.map((item) => (
|
<YAxis dataKey="name" type="category" axisLine={false} tickLine={false} tick={{ fontSize: 13 }} width={90} />
|
||||||
<Cell key={item.name} fill={item.color} />
|
<Tooltip
|
||||||
))}
|
cursor={{fill: 'transparent'}}
|
||||||
</Pie>
|
contentStyle={{ borderRadius: "8px", backgroundColor: "#fff", color: "#000", border: "none" }}
|
||||||
<Tooltip />
|
/>
|
||||||
<Legend />
|
<Bar dataKey="Değer" fill="#8b5cf6" radius={[0, 4, 4, 0]} barSize={20} />
|
||||||
</PieChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</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">
|
<Card className="p-6 bg-primary/5 border-primary/20 flex-1">
|
||||||
{insights.length > 0 ? (
|
<h3 className="text-lg font-medium mb-3 flex items-center gap-2">
|
||||||
<ul className="space-y-3">
|
<Brain className="w-5 h-5 text-primary" /> Son AI İçgörüsü
|
||||||
{insights.map((insight) => (
|
</h3>
|
||||||
<li
|
<p className="text-sm text-foreground/80 leading-relaxed italic">
|
||||||
key={insight}
|
{lastInsight ? `"${lastInsight}"` : "Henüz bir içgörü oluşmadı. Biraz günlük yaz, AI analiz yapsın."}
|
||||||
className="rounded-md border border-ink/10 bg-mist px-3 py-3 text-sm leading-6 text-ink/75"
|
</p>
|
||||||
>
|
</Card>
|
||||||
{insight}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<EmptyState text="Insight üretmek için en az bir kayıt ekle." />
|
|
||||||
)}
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</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>
|
</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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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!
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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)$).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
Generated
+5888
-119
File diff suppressed because it is too large
Load Diff
+26
-1
@@ -9,16 +9,41 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"framer-motion": "^11.18.2",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"next": "^15.1.6",
|
"next": "^15.1.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/react": "^19.0.7",
|
"@types/react": "^19.0.7",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-next": "^15.1.6",
|
"eslint-config-next": "^15.1.6",
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,8 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
const config: Config = {
|
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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
@@ -11,13 +12,51 @@ const config: Config = {
|
|||||||
coral: "#d8644a",
|
coral: "#d8644a",
|
||||||
sun: "#e3aa38",
|
sun: "#e3aa38",
|
||||||
sky: "#4f7fbf",
|
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: {
|
boxShadow: {
|
||||||
soft: "0 18px 50px rgba(23, 33, 31, 0.08)",
|
soft: "0 18px 50px rgba(23, 33, 31, 0.08)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
+5
-1
@@ -17,7 +17,11 @@
|
|||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user