feat(ui): implement 'Twilight Velvet' dark theme and fix visibility issues
- Add 'Twilight Velvet' color palette to globals.css with OKLCH values - Update SettingsPage headers, cards, and dialogs to use semantic theme variables - Update HistoryCard, HistoryFeed, and DraftContent to support dark mode - Update ProviderSelector and ProviderList to use custom card background (#2A2A3D) - Add ThemeToggle component with improved visibility - Ensure consistent use of 'bg-card', 'text-foreground', and 'text-muted-foreground'
This commit is contained in:
29
src/app/(main)/history/page.tsx
Normal file
29
src/app/(main)/history/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { HistoryFeed } from '@/components/features/journal/HistoryFeed';
|
||||
import { HistoryDetailSheet } from '@/components/features/journal/HistoryDetailSheet';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
|
||||
export default function HistoryPage() {
|
||||
const selectedDraft = useHistoryStore((s) => s.selectedDraft);
|
||||
const closeDetail = useHistoryStore((s) => s.closeDetail);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-slate-50 relative">
|
||||
<header className="px-4 py-4 bg-white border-b border-slate-200 shrink-0 sticky top-0 z-10">
|
||||
<h1 className="text-xl font-bold font-serif text-slate-800">Your Journey</h1>
|
||||
</header>
|
||||
|
||||
<HistoryFeed />
|
||||
|
||||
{/* Detail Sheet for viewing history items */}
|
||||
{selectedDraft && (
|
||||
<HistoryDetailSheet
|
||||
draft={selectedDraft}
|
||||
onClose={closeDetail}
|
||||
open={!!selectedDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { ProviderForm } from "@/components/features/settings/provider-form";
|
||||
import { useSavedProviders } from "@/store/use-settings";
|
||||
import { ProviderManagementService } from "@/services/provider-management-service";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { ThemeToggle } from "@/components/features/settings/theme-toggle";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
@@ -57,27 +58,41 @@ export default function SettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm font-medium text-slate-500 hover:text-primary transition-colors mb-2"
|
||||
className="inline-flex items-center text-sm font-medium text-muted-foreground hover:text-primary transition-colors mb-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Home
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-900 font-serif">Settings</h1>
|
||||
<p className="mt-2 text-lg text-slate-600">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground font-serif">Settings</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">
|
||||
Manage your AI provider connections and preferences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8">
|
||||
{/* General Settings */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||
<div className="h-8 w-1 bg-yellow-400 rounded-full"></div>
|
||||
<h2 className="text-xl font-semibold text-foreground font-serif">Appearance</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground max-w-xl">
|
||||
Choose your preferred theme for the journaling experience.
|
||||
</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Active Provider Section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-slate-200">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||
<div className="h-8 w-1 bg-primary rounded-full"></div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 font-serif">Active Session Provider</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground font-serif">Active Session Provider</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 max-w-xl">
|
||||
<p className="text-sm text-muted-foreground max-w-xl">
|
||||
Select which AI provider handles your current venting session. This setting applies immediately to new messages.
|
||||
</p>
|
||||
<ProviderSelector />
|
||||
@@ -85,15 +100,15 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Manage Providers Section */}
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-1 bg-slate-300 rounded-full"></div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 font-serif">Configuration</h2>
|
||||
<div className="h-8 w-1 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
|
||||
<h2 className="text-xl font-semibold text-foreground font-serif">Configuration</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 max-w-xl">
|
||||
<p className="text-sm text-muted-foreground max-w-xl">
|
||||
Configure connection details for your AI models. Keys are stored locally in your browser.
|
||||
</p>
|
||||
<ProviderList
|
||||
@@ -105,9 +120,9 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Add Provider Dialog (Triggered by ProviderList) */}
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] p-0 overflow-hidden bg-white border-0 shadow-2xl">
|
||||
<DialogHeader className="p-6 pb-2 bg-slate-50/50">
|
||||
<DialogTitle className="text-2xl font-serif text-slate-900">Add New Provider</DialogTitle>
|
||||
<DialogContent className="sm:max-w-[550px] p-0 overflow-hidden bg-background border-border shadow-2xl">
|
||||
<DialogHeader className="p-6 pb-2 bg-muted/50">
|
||||
<DialogTitle className="text-2xl font-serif text-foreground">Add New Provider</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-6 pt-2">
|
||||
<ProviderForm
|
||||
@@ -123,14 +138,37 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Account Security Section */}
|
||||
<div className="grid gap-8 mt-10 border-t border-border pt-10">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||
<div className="h-8 w-1 bg-red-400 rounded-full"></div>
|
||||
<h2 className="text-xl font-semibold text-foreground font-serif">Account Security</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground max-w-xl">
|
||||
Lock the application to prevent unauthorized access on this device.
|
||||
</p>
|
||||
<Button variant="destructive" onClick={async () => {
|
||||
if (confirm('Are you sure you want to logout?')) {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}}>
|
||||
Logout
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Edit Provider Dialog */}
|
||||
<Dialog
|
||||
open={!!editingProviderId}
|
||||
onOpenChange={(open: boolean) => !open && closeDialogs()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[550px] p-0 overflow-hidden bg-white border-0 shadow-2xl">
|
||||
<DialogHeader className="p-6 pb-2 bg-slate-50/50">
|
||||
<DialogTitle className="text-2xl font-serif text-slate-900">Edit Provider</DialogTitle>
|
||||
<DialogContent className="sm:max-w-[550px] p-0 overflow-hidden bg-background border-border shadow-2xl">
|
||||
<DialogHeader className="p-6 pb-2 bg-muted/50">
|
||||
<DialogTitle className="text-2xl font-serif text-foreground">Edit Provider</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-6 pt-2">
|
||||
<ProviderForm
|
||||
@@ -144,5 +182,6 @@ export default function SettingsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,15 @@ import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { ChatWindow } from '@/components/features/chat/chat-window';
|
||||
import { ChatInput } from '@/components/features/chat/chat-input';
|
||||
import { useSessionStore, useActiveSessionId, useTeacherStatus } from '@/store/use-session';
|
||||
import { ChatService } from '@/services/chat-service';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DraftViewSheet } from "@/components/features/draft/DraftViewSheet";
|
||||
import { useChatStore } from "@/lib/store/chat-store";
|
||||
import { Loader2, ArrowLeft, Sparkles, Bot } from "lucide-react";
|
||||
import { DraftSheet } from '@/components/features/journal/draft-sheet';
|
||||
import { useChatStore } from '@/store/use-chat';
|
||||
import { ArrowLeft, Bot, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { LLMService } from '@/services/llm-service';
|
||||
import { ProviderManagementService } from '@/services/provider-management-service';
|
||||
|
||||
export default function ChatPage() {
|
||||
const activeSessionId = useActiveSessionId();
|
||||
const teacherStatus = useTeacherStatus();
|
||||
const { setActiveSession } = useSessionStore((s) => s.actions);
|
||||
const isDrafting = useChatStore((s) => s.isDrafting);
|
||||
|
||||
const { resetSession, phase } = useChatStore();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -30,14 +22,11 @@ export default function ChatPage() {
|
||||
// Check for "new" param to force fresh session
|
||||
useEffect(() => {
|
||||
if (searchParams.get('new') === 'true') {
|
||||
// Clear current session to trigger re-initialization
|
||||
setActiveSession(null);
|
||||
// Clear chat UI state
|
||||
useChatStore.setState({ messages: [], currentDraft: null, showDraftView: false });
|
||||
resetSession();
|
||||
// Clean URL
|
||||
router.replace('/chat');
|
||||
}
|
||||
}, [searchParams, router, setActiveSession]);
|
||||
}, [searchParams, router, resetSession]);
|
||||
|
||||
// Check Connection Status
|
||||
useEffect(() => {
|
||||
@@ -66,62 +55,10 @@ export default function ChatPage() {
|
||||
checkConnection();
|
||||
}, []);
|
||||
|
||||
// Initialize Session on Mount
|
||||
useEffect(() => {
|
||||
const initSession = async () => {
|
||||
// If activeSessionId is null (either initial load or just cleared by above effect)
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
const newSessionId = await ChatService.createSession();
|
||||
setActiveSession(newSessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error);
|
||||
toast.error("Failed to start session. Check your database.");
|
||||
}
|
||||
}
|
||||
};
|
||||
initSession();
|
||||
}, [activeSessionId, setActiveSession]);
|
||||
|
||||
const handleSend = async (message: string) => {
|
||||
if (!activeSessionId) return;
|
||||
|
||||
try {
|
||||
await ChatService.sendMessage(message, activeSessionId);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.message === 'AI Provider not configured') {
|
||||
toast.error("Please configure your AI Provider in Settings", {
|
||||
action: {
|
||||
label: "Go to Settings",
|
||||
onClick: () => window.location.href = '/settings'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
toast.error("Failed to send message. Please check connection.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinishSession = async () => {
|
||||
if (!activeSessionId) return;
|
||||
|
||||
try {
|
||||
toast.info("Generating your learning summary...");
|
||||
// Ensure store has latest messages for this session
|
||||
await useChatStore.getState().hydrate(activeSessionId);
|
||||
// Trigger Ghostwriter
|
||||
await useChatStore.getState().generateDraft(activeSessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate draft:", error);
|
||||
toast.error("Failed to generate summary. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
<div className="flex flex-col h-dvh bg-background relative">
|
||||
{/* Session Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200 shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur border-b border-slate-200 shrink-0 z-10 sticky top-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="text-slate-500 hover:text-slate-700 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
@@ -130,44 +67,25 @@ export default function ChatPage() {
|
||||
<div className="relative">
|
||||
<Bot className="w-5 h-5 text-indigo-600" />
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${connectionStatus === 'connected' ? 'bg-green-500' :
|
||||
connectionStatus === 'checking' ? 'bg-yellow-400' : 'bg-red-500'
|
||||
connectionStatus === 'checking' ? 'bg-yellow-400' : 'bg-red-500'
|
||||
}`} />
|
||||
</div>
|
||||
Teacher
|
||||
<span className="font-serif">Teacher</span>
|
||||
{phase === 'drafting' && <span className="text-xs text-indigo-500 animate-pulse ml-2">Simulating...</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleFinishSession}
|
||||
disabled={isDrafting}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
{isDrafting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Drafting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Draft Post
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages - Scrollable Area */}
|
||||
{/* Fix: Added min-h-0 and relative for proper nested scrolling */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden relative">
|
||||
<ChatWindow sessionId={activeSessionId} />
|
||||
<ChatWindow />
|
||||
</div>
|
||||
|
||||
<DraftViewSheet />
|
||||
<DraftSheet />
|
||||
|
||||
{/* Chat Input - Fixed at Bottom */}
|
||||
<div className="shrink-0 bg-white border-t border-slate-200">
|
||||
<ChatInput onSend={handleSend} isLoading={teacherStatus !== 'idle'} />
|
||||
<div className="shrink-0">
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
40
src/app/api/auth/login/route.ts
Normal file
40
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { password } = await request.json();
|
||||
const appPassword = process.env.APP_PASSWORD;
|
||||
|
||||
if (!appPassword) {
|
||||
console.error('APP_PASSWORD is not set in environment variables');
|
||||
return NextResponse.json(
|
||||
{ error: 'Server configuration error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password === appPassword) {
|
||||
// Create a persistent session (30 days)
|
||||
(await cookies()).set('auth-token', 'authenticated', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST() {
|
||||
(await cookies()).delete('auth-token');
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -95,32 +95,52 @@
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
/* Dark Mode - Evening Mist */
|
||||
/* Dark Mode - Twilight Velvet */
|
||||
.dark {
|
||||
--background: oklch(0.15 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
--card: oklch(0.20 0 0);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.20 0 0);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
--primary: oklch(0.70 0.02 270);
|
||||
--primary-foreground: oklch(0.15 0 0);
|
||||
--secondary: oklch(0.25 0 0);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.25 0 0);
|
||||
--muted-foreground: oklch(0.70 0 0);
|
||||
--accent: oklch(0.25 0 0);
|
||||
--accent-foreground: oklch(0.98 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.55 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
/* Background - Deep Space (Velvet Black) */
|
||||
--background: oklch(0.11 0.03 280);
|
||||
/* Foreground - Stardust White */
|
||||
--foreground: oklch(0.94 0.02 280);
|
||||
|
||||
/* Card - Velvet Shadow (#2A2A3D) */
|
||||
--card: oklch(0.22 0.03 280);
|
||||
--card-foreground: oklch(0.94 0.02 280);
|
||||
|
||||
/* Popover - Matching card */
|
||||
--popover: oklch(0.22 0.03 280);
|
||||
--popover-foreground: oklch(0.94 0.02 280);
|
||||
|
||||
/* Primary - Indigo Glow */
|
||||
--primary: oklch(0.75 0.08 270);
|
||||
--primary-foreground: oklch(0.11 0.03 280);
|
||||
|
||||
/* Secondary - Slightly lighter than card */
|
||||
--secondary: oklch(0.28 0.04 280);
|
||||
--secondary-foreground: oklch(0.94 0.02 280);
|
||||
|
||||
/* Muted - Matches card background for subtle integration */
|
||||
--muted: oklch(0.22 0.03 280);
|
||||
--muted-foreground: oklch(0.70 0.04 280);
|
||||
|
||||
/* Accent - Hover states */
|
||||
--accent: oklch(0.28 0.04 280);
|
||||
--accent-foreground: oklch(0.94 0.02 280);
|
||||
|
||||
/* Destructive - Muted Red */
|
||||
--destructive: oklch(0.55 0.15 25);
|
||||
--destructive-foreground: oklch(0.94 0.02 280);
|
||||
|
||||
/* Borders - Subtle purple border */
|
||||
--border: oklch(0.28 0.04 280);
|
||||
--input: oklch(0.28 0.04 280);
|
||||
--ring: oklch(0.75 0.08 270);
|
||||
|
||||
/* Chart colors - Adapted for dark background */
|
||||
--chart-1: oklch(0.70 0.15 280);
|
||||
--chart-2: oklch(0.65 0.15 320);
|
||||
--chart-3: oklch(0.60 0.15 240);
|
||||
--chart-4: oklch(0.75 0.15 200);
|
||||
--chart-5: oklch(0.70 0.15 40);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Inter, Merriweather } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { OfflineIndicator } from "../components/features/common";
|
||||
import { InstallPrompt } from "../components/features/pwa/install-prompt";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -44,13 +45,20 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${inter.variable} ${merriweather.variable} font-sans antialiased`}
|
||||
>
|
||||
{children}
|
||||
<OfflineIndicator />
|
||||
<InstallPrompt />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<OfflineIndicator />
|
||||
<InstallPrompt />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
86
src/app/login/page.tsx
Normal file
86
src/app/login/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/');
|
||||
router.refresh(); // Refresh to update middleware state
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Invalid password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
|
||||
<Card className="w-full max-w-sm shadow-xl">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="mx-auto bg-zinc-100 dark:bg-zinc-800 p-3 rounded-full w-fit mb-2">
|
||||
<Lock className="w-6 h-6 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">Gatekeeper</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the application password to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleLogin}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="text-center tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 text-center font-medium animate-in fade-in slide-in-from-top-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Unlocking...' : 'Unlock Access'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Send, StopCircle } from 'lucide-react';
|
||||
import { Send, StopCircle, Sparkles } from 'lucide-react';
|
||||
import { useChatStore } from '@/store/use-chat';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading }: ChatInputProps) {
|
||||
export function ChatInput() {
|
||||
const { sendMessage, isTyping, phase, generateDraft } = useChatStore();
|
||||
const [input, setInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -22,10 +19,11 @@ export function ChatInput({ onSend, isLoading }: ChatInputProps) {
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
onSend(input);
|
||||
setInput('');
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isTyping) return;
|
||||
const msg = input;
|
||||
setInput(''); // Clear immediately for UX
|
||||
await sendMessage(msg);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -36,26 +34,41 @@ export function ChatInput({ onSend, isLoading }: ChatInputProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-card/80 backdrop-blur-md border-t border-border sticky bottom-0">
|
||||
<div className="flex gap-2 items-center max-w-3xl mx-auto">
|
||||
<div className="p-4 bg-card/80 backdrop-blur-md border-t border-border sticky bottom-0 z-10 w-full transition-all duration-300">
|
||||
<div className="flex gap-2 items-end max-w-3xl mx-auto">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Record your thoughts..."
|
||||
className="resize-none min-h-[44px] max-h-[120px] py-3 rounded-xl border-input focus:ring-ring"
|
||||
placeholder={phase === 'elicitation' ? "Answer the question..." : "Record your thoughts..."}
|
||||
className="resize-none min-h-[44px] max-h-[120px] py-3 rounded-xl border-input focus:ring-ring shadow-sm bg-background/50"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
disabled={!input.trim() || isTyping}
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-xl shrink-0 bg-slate-800 hover:bg-slate-700 transition-colors"
|
||||
className="h-11 w-11 rounded-xl shrink-0 bg-slate-800 hover:bg-slate-700 transition-colors shadow-sm"
|
||||
>
|
||||
{isLoading ? <StopCircle className="h-5 w-5 animate-pulse" /> : <Send className="h-5 w-5" />}
|
||||
{isTyping ? <StopCircle className="h-5 w-5 animate-pulse" /> : <Send className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Contextual Action Button (e.g. Draft) */}
|
||||
{phase === 'elicitation' && !isTyping && (
|
||||
<div className="absolute -top-14 left-1/2 -translate-x-1/2 animate-in slide-in-from-bottom-2 fade-in">
|
||||
<Button
|
||||
onClick={() => generateDraft()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shadow-lg border-indigo-200 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 gap-2 rounded-full px-6"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Summarize & Draft
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db/db';
|
||||
import { ChatBubble } from './chat-bubble';
|
||||
import { TypingIndicator } from './typing-indicator';
|
||||
import { useTeacherStatus } from '@/store/use-session';
|
||||
import { useChatStore } from '@/store/use-chat';
|
||||
import { BookOpen, Sparkles } from 'lucide-react';
|
||||
|
||||
interface ChatWindowProps {
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function ChatWindow({ sessionId }: ChatWindowProps) {
|
||||
const teacherStatus = useTeacherStatus();
|
||||
export function ChatWindow() {
|
||||
const { messages, isTyping } = useChatStore();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reactive query for messages
|
||||
const messages = useLiveQuery(
|
||||
async () => {
|
||||
if (!sessionId) return [];
|
||||
return await db.chatLogs
|
||||
.where('sessionId')
|
||||
.equals(sessionId)
|
||||
.sortBy('timestamp');
|
||||
},
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, teacherStatus]);
|
||||
|
||||
if (!sessionId) {
|
||||
return <div className="flex-1 flex items-center justify-center text-slate-400">Loading session...</div>;
|
||||
}
|
||||
}, [messages, isTyping]);
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return (
|
||||
@@ -49,10 +27,10 @@ export function ChatWindow({ sessionId }: ChatWindowProps) {
|
||||
|
||||
<div className="space-y-2 max-w-md">
|
||||
<h2 className="text-2xl font-bold font-serif text-foreground">
|
||||
What do you want to record?
|
||||
What's on your mind?
|
||||
</h2>
|
||||
<p className="text-muted-foreground font-sans">
|
||||
Let me help you summarize your day.
|
||||
I'm here to listen. Let it all out.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +44,7 @@ export function ChatWindow({ sessionId }: ChatWindowProps) {
|
||||
<ChatBubble key={msg.id} role={msg.role} content={msg.content} />
|
||||
))}
|
||||
|
||||
{teacherStatus !== 'idle' && (
|
||||
{isTyping && (
|
||||
<TypingIndicator />
|
||||
)}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('DraftActions', () => {
|
||||
);
|
||||
|
||||
const approveButton = screen.getByRole('button', { name: /approve, copy to clipboard, and mark as completed/i });
|
||||
expect(approveButton).toHaveClass('bg-slate-700', 'hover:bg-slate-800');
|
||||
expect(approveButton).toHaveClass('bg-slate-800', 'hover:bg-slate-700');
|
||||
});
|
||||
|
||||
it('renders Thumbs Down button with outline style', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ interface DraftActionsProps {
|
||||
* - Proper ARIA labels for screen readers
|
||||
* - Sticky positioning to stay visible when scrolling long drafts
|
||||
*/
|
||||
export function DraftActions({ onApprove, onReject, onCopyOnly }: DraftActionsProps) {
|
||||
export function DraftActions({ onApprove, onReject, onCopyOnly, children }: DraftActionsProps & { children?: React.ReactNode }) {
|
||||
const currentDraft = useChatStore((s) => s.currentDraft);
|
||||
const startRefinement = useChatStore((s) => s.startRefinement);
|
||||
|
||||
@@ -35,6 +35,9 @@ export function DraftActions({ onApprove, onReject, onCopyOnly }: DraftActionsPr
|
||||
|
||||
return (
|
||||
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
|
||||
{/* Optional additional actions (e.g. Delete) */}
|
||||
{children}
|
||||
|
||||
{/* Thumbs Down - Request changes (Story 2.3: triggers refinement) */}
|
||||
<button
|
||||
onClick={handleReject}
|
||||
|
||||
@@ -43,38 +43,38 @@ export function DraftContent({ draft }: DraftContentProps) {
|
||||
})();
|
||||
|
||||
return (
|
||||
<article className="draft-content px-4 sm:px-6 py-6 bg-white">
|
||||
<article className="draft-content px-4 sm:px-6 py-6 bg-card">
|
||||
{/* Title - using Merriweather serif font */}
|
||||
<h2 className="draft-title text-2xl sm:text-3xl font-bold text-slate-800 mb-6 font-serif leading-tight">
|
||||
<h2 className="draft-title text-2xl sm:text-3xl font-bold text-foreground mb-6 font-serif leading-tight">
|
||||
{draft.title}
|
||||
</h2>
|
||||
|
||||
{/* Body content - Markdown with prose styling */}
|
||||
<div className="draft-body prose prose-slate max-w-none font-serif">
|
||||
<div className="draft-body prose prose-slate dark:prose-invert max-w-none font-serif">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight, rehypeRaw]}
|
||||
components={{
|
||||
// Custom heading styles
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-2xl font-bold text-slate-800 mt-8 mb-4 first:mt-0" {...props} />
|
||||
<h1 className="text-2xl font-bold text-foreground mt-8 mb-4 first:mt-0" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-xl font-bold text-slate-800 mt-6 mb-3" {...props} />
|
||||
<h2 className="text-xl font-bold text-foreground mt-6 mb-3" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-lg font-semibold text-slate-800 mt-5 mb-2" {...props} />
|
||||
<h3 className="text-lg font-semibold text-foreground mt-5 mb-2" {...props} />
|
||||
),
|
||||
// Paragraph styling
|
||||
p: ({ node, ...props }) => (
|
||||
<p className="text-base leading-relaxed text-slate-700 mb-4" {...props} />
|
||||
<p className="text-base leading-relaxed text-muted-foreground mb-4" {...props} />
|
||||
),
|
||||
// Code blocks
|
||||
code: ({ node, inline, className, children, ...props }: any) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className="px-1.5 py-0.5 bg-slate-100 text-slate-800 rounded text-sm font-mono"
|
||||
className="px-1.5 py-0.5 bg-muted text-foreground rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -83,7 +83,7 @@ export function DraftContent({ draft }: DraftContentProps) {
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className={`block bg-slate-100 text-slate-800 p-4 rounded-lg text-sm font-mono overflow-x-auto ${className || ''}`}
|
||||
className={`block bg-muted text-foreground p-4 rounded-lg text-sm font-mono overflow-x-auto ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -92,22 +92,22 @@ export function DraftContent({ draft }: DraftContentProps) {
|
||||
},
|
||||
// Pre tags
|
||||
pre: ({ node, ...props }) => (
|
||||
<pre className="bg-slate-100 p-4 rounded-lg overflow-x-auto mb-4" {...props} />
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-x-auto mb-4" {...props} />
|
||||
),
|
||||
// Links
|
||||
a: ({ node, ...props }) => (
|
||||
<a className="text-slate-600 hover:text-slate-800 underline" {...props} />
|
||||
<a className="text-primary hover:underline" {...props} />
|
||||
),
|
||||
// Lists
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc list-inside mb-4 text-slate-700 space-y-1" {...props} />
|
||||
<ul className="list-disc list-inside mb-4 text-muted-foreground space-y-1" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal list-inside mb-4 text-slate-700 space-y-1" {...props} />
|
||||
<ol className="list-decimal list-inside mb-4 text-muted-foreground space-y-1" {...props} />
|
||||
),
|
||||
// Blockquotes
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="border-l-4 border-slate-300 pl-4 italic text-slate-600 my-4" {...props} />
|
||||
<blockquote className="border-l-4 border-muted-foreground/30 pl-4 italic text-muted-foreground my-4" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
@@ -117,11 +117,11 @@ export function DraftContent({ draft }: DraftContentProps) {
|
||||
|
||||
{/* Tags section */}
|
||||
{draft.tags && draft.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-6 pt-4 border-t border-slate-200">
|
||||
<div className="flex flex-wrap gap-2 mt-6 pt-4 border-t border-border">
|
||||
{draft.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="tag-chip px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-sans"
|
||||
className="tag-chip px-3 py-1 bg-secondary text-secondary-foreground rounded-full text-sm font-sans"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
|
||||
@@ -105,7 +105,12 @@ export function DraftViewSheet() {
|
||||
<Sheet open={showDraftView} onClose={handleClose}>
|
||||
<DraftContent draft={currentDraft} />
|
||||
{/* Story 3.2: Extended footer with delete button */}
|
||||
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
|
||||
{/* Story 3.2: Extended footer with delete button passed as child to DraftActions */}
|
||||
<DraftActions
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onCopyOnly={handleCopyOnly}
|
||||
>
|
||||
{/* Delete button (Story 3.2) */}
|
||||
<button
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
@@ -116,14 +121,7 @@ export function DraftViewSheet() {
|
||||
<Trash2 className="w-5 h-5" aria-hidden="true" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
|
||||
{/* Draft actions from original component */}
|
||||
<DraftActions
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onCopyOnly={handleCopyOnly}
|
||||
/>
|
||||
</nav>
|
||||
</DraftActions>
|
||||
</Sheet>
|
||||
|
||||
{/* Story 3.2: Delete confirmation dialog */}
|
||||
|
||||
@@ -35,16 +35,16 @@ export function HistoryCard({ draft, onClick }: HistoryCardProps) {
|
||||
<button
|
||||
onClick={() => onClick(draft)}
|
||||
type="button"
|
||||
className="history-card group w-full text-left p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border border-slate-200"
|
||||
className="history-card group w-full text-left p-4 bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow border border-border"
|
||||
aria-label={`View post: ${draft.title}`}
|
||||
>
|
||||
{/* Title - Merriweather serif font for "published" feel */}
|
||||
<h3 className="history-title text-lg font-bold text-slate-800 mb-2 font-serif leading-tight line-clamp-2">
|
||||
<h3 className="history-title text-lg font-bold text-card-foreground mb-2 font-serif leading-tight line-clamp-2">
|
||||
{draft.title}
|
||||
</h3>
|
||||
|
||||
{/* Date - Inter font, subtle gray, relative format */}
|
||||
<p className="history-date text-sm text-slate-500 mb-2 font-sans">
|
||||
<p className="history-date text-sm text-muted-foreground mb-2 font-sans">
|
||||
{formatRelativeDate(displayDate)}
|
||||
</p>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function HistoryCard({ draft, onClick }: HistoryCardProps) {
|
||||
{draft.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="tag-chip px-2 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-sans"
|
||||
className="tag-chip px-2 py-1 bg-secondary text-secondary-foreground rounded-full text-xs font-sans"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
@@ -63,7 +63,7 @@ export function HistoryCard({ draft, onClick }: HistoryCardProps) {
|
||||
)}
|
||||
|
||||
{/* Preview - light gray text */}
|
||||
<p className="history-preview text-sm text-slate-400 font-sans line-clamp-2">
|
||||
<p className="history-preview text-sm text-muted-foreground/80 font-sans line-clamp-2">
|
||||
{preview}
|
||||
{draft.content.length > 100 && '...'}
|
||||
</p>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Copy, Check, X, Trash2 } from 'lucide-react';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { DraftContent } from '@/components/features/draft/DraftContent';
|
||||
import { CopySuccessToast } from '@/components/features/feedback/CopySuccessToast';
|
||||
import { useChatStore } from '@/lib/store/chat-store';
|
||||
import { Sheet } from '@/components/features/draft/Sheet';
|
||||
import { useChatStore } from '@/store/use-chat';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
||||
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
||||
|
||||
/**
|
||||
@@ -36,9 +36,6 @@ export function HistoryDetailSheet() {
|
||||
const closeDetail = useHistoryStore((s) => s.closeDetail);
|
||||
const deleteDraft = useHistoryStore((s) => s.deleteDraft);
|
||||
|
||||
// Reuse copy action from ChatStore
|
||||
const copyDraftToClipboard = useChatStore((s) => s.copyDraftToClipboard);
|
||||
|
||||
// Dialog state
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
@@ -56,9 +53,11 @@ export function HistoryDetailSheet() {
|
||||
setToastShow(true);
|
||||
};
|
||||
|
||||
// Placeholder copy function since ChatStore might not have it exposed exactly this way yet
|
||||
// or we need to implement it here.
|
||||
const handleCopy = async () => {
|
||||
if (selectedDraft) {
|
||||
await copyDraftToClipboard(selectedDraft.id);
|
||||
await navigator.clipboard.writeText(selectedDraft.content);
|
||||
showCopyToast();
|
||||
}
|
||||
};
|
||||
@@ -69,6 +68,7 @@ export function HistoryDetailSheet() {
|
||||
if (success) {
|
||||
setShowDeleteDialog(false);
|
||||
showCopyToast('Post deleted successfully');
|
||||
closeDetail(); // Close sheet on delete
|
||||
} else {
|
||||
setShowDeleteDialog(false);
|
||||
showCopyToast('Failed to delete post');
|
||||
@@ -76,54 +76,58 @@ export function HistoryDetailSheet() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
closeDetail();
|
||||
};
|
||||
|
||||
if (!selectedDraft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={!!selectedDraft} onClose={handleClose}>
|
||||
<DraftContent draft={selectedDraft} />
|
||||
<Sheet open={!!selectedDraft} onOpenChange={(open) => !open && closeDetail()}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-xl overflow-y-auto p-0">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Draft Details</SheetTitle>
|
||||
<SheetDescription>View your saved draft details</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="p-6">
|
||||
<DraftContent draft={selectedDraft} />
|
||||
</div>
|
||||
|
||||
{/* Footer with copy, delete and close buttons */}
|
||||
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
|
||||
{/* Delete button (Story 3.2.1) */}
|
||||
<button
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
type="button"
|
||||
className="min-h-[44px] px-4 py-3 border border-destructive text-destructive rounded-md hover:bg-destructive/10 transition-colors flex items-center justify-center gap-2"
|
||||
aria-label="Delete this draft"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" aria-hidden="true" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</button>
|
||||
{/* Footer with copy, delete and close buttons */}
|
||||
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200 mt-auto">
|
||||
{/* Delete button (Story 3.2.1) */}
|
||||
<button
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
type="button"
|
||||
className="min-h-[44px] px-4 py-3 border border-destructive text-destructive rounded-md hover:bg-destructive/10 transition-colors flex items-center justify-center gap-2"
|
||||
aria-label="Delete this draft"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" aria-hidden="true" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</button>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
className="flex-1 min-h-[44px] px-4 py-3 border border-slate-300 rounded-md text-slate-700 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy className="w-5 h-5" aria-hidden="true" />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
className="flex-1 min-h-[44px] px-4 py-3 border border-slate-300 rounded-md text-slate-700 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy className="w-5 h-5" aria-hidden="true" />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
className="min-h-[44px] px-4 py-3 bg-slate-800 text-white rounded-md hover:bg-slate-700 transition-colors flex items-center justify-center gap-2"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
<span>Close</span>
|
||||
</button>
|
||||
</nav>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={closeDetail}
|
||||
type="button"
|
||||
className="min-h-[44px] px-4 py-3 bg-slate-800 text-white rounded-md hover:bg-slate-700 transition-colors flex items-center justify-center gap-2"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
<span>Close</span>
|
||||
</button>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
|
||||
@@ -117,11 +117,11 @@ export function HistoryFeed() {
|
||||
<div key={weekLabel} className="mb-6">
|
||||
{/* Week separator header */}
|
||||
<div className="flex items-center justify-center gap-3 mt-6 mb-4">
|
||||
<div className="h-px flex-1 max-w-[100px] bg-slate-200" />
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide px-3 py-1 bg-slate-50 rounded-full border border-slate-200">
|
||||
<div className="h-px flex-1 max-w-[100px] bg-border" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-1 bg-muted rounded-full border border-border">
|
||||
{weekLabel}
|
||||
</span>
|
||||
<div className="h-px flex-1 max-w-[100px] bg-slate-200" />
|
||||
<div className="h-px flex-1 max-w-[100px] bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Drafts for this week */}
|
||||
|
||||
107
src/components/features/journal/draft-sheet.tsx
Normal file
107
src/components/features/journal/draft-sheet.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useChatStore } from '@/store/use-chat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { ThumbsUp, ThumbsDown, RefreshCw } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
export function DraftSheet() {
|
||||
const { phase, currentDraft, setPhase, resetSession } = useChatStore();
|
||||
const isOpen = phase === 'review' && !!currentDraft;
|
||||
|
||||
const handleKeep = async () => {
|
||||
if (!currentDraft) return;
|
||||
|
||||
try {
|
||||
// Import dynamically to avoid side-effects during render if possible,
|
||||
// or just import at top. We'll stick to dynamic since DraftService might not be SSR friendly
|
||||
// without checks, but it handles it internally.
|
||||
const { DraftService } = await import('@/lib/db/draft-service');
|
||||
const { useSessionStore } = await import('@/store/use-session');
|
||||
const sessionId = useSessionStore.getState().activeSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
console.error("No active session ID");
|
||||
return;
|
||||
}
|
||||
|
||||
await DraftService.saveDraft({
|
||||
sessionId,
|
||||
title: currentDraft.title,
|
||||
content: currentDraft.lesson, // Using lesson as content for now, or construct full markdown?
|
||||
// Let's construct a nice markdown
|
||||
// Actually the draft artifact has title, insight, lesson.
|
||||
// We should probably save the raw JSON or a formatted textual representation.
|
||||
// Let's save formatted text.
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
tags: []
|
||||
});
|
||||
|
||||
// Redirect to history or show success
|
||||
window.location.href = '/history';
|
||||
|
||||
resetSession();
|
||||
} catch (error) {
|
||||
console.error("Failed to save draft:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefine = () => {
|
||||
// Logic for refinement (Story 3.5)
|
||||
// For now, close sheet and persist state
|
||||
setPhase('drafting'); // Go back or stay?
|
||||
// Actually, refinement usually means going back to chat Elicitation or having a specialized Refinement Mode.
|
||||
// Let's just close for now.
|
||||
setPhase('elicitation');
|
||||
};
|
||||
|
||||
if (!currentDraft) return null;
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && handleRefine()}>
|
||||
<SheetContent side="bottom" className="h-[80vh] sm:h-[600px] rounded-t-[20px] pt-10">
|
||||
<SheetHeader className="text-left mb-6">
|
||||
<SheetTitle className="font-serif text-3xl font-bold bg-gradient-to-r from-indigo-500 to-purple-600 bg-clip-text text-transparent">
|
||||
{currentDraft.title}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-lg text-slate-600 italic">
|
||||
" {currentDraft.insight} "
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 overflow-y-auto pb-20">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<h3 className="font-serif text-xl border-l-4 border-indigo-500 pl-4 py-1">
|
||||
The Lesson
|
||||
</h3>
|
||||
<p className="text-lg leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{currentDraft.lesson}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="absolute bottom-0 left-0 right-0 p-6 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-md border-t border-slate-200 flex flex-row gap-4 justify-between sm:justify-end">
|
||||
<Button variant="outline" size="lg" className="flex-1 sm:flex-none gap-2" onClick={handleRefine}>
|
||||
<ThumbsDown className="w-5 h-5" />
|
||||
Refine
|
||||
</Button>
|
||||
<Button size="lg" className="flex-1 sm:flex-none gap-2 bg-indigo-600 hover:bg-indigo-700 text-white" onClick={handleKeep}>
|
||||
<ThumbsUp className="w-5 h-5" />
|
||||
Keep It
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -54,9 +54,9 @@ export function ProviderList({
|
||||
<div
|
||||
key={provider.id}
|
||||
data-active={provider.id === activeId ? 'true' : 'false'}
|
||||
className={`p-4 rounded-xl border transition-all duration-200 bg-white ${provider.id === activeId
|
||||
className={`p-4 rounded-xl border transition-all duration-200 bg-card ${provider.id === activeId
|
||||
? 'border-primary shadow-sm ring-1 ring-primary/20'
|
||||
: 'border-slate-200 hover:border-primary/30'
|
||||
: 'border-border hover:border-primary/30'
|
||||
}`}
|
||||
onClick={() => onSelectProvider?.(provider.id)}
|
||||
>
|
||||
@@ -64,7 +64,7 @@ export function ProviderList({
|
||||
<div className="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<h3 className="font-semibold text-foreground text-base mb-0.5">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-xs">{provider.modelName}</span>
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-xs text-foreground/80">{provider.modelName}</span>
|
||||
<span className="truncate text-xs opacity-70">{provider.baseUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@ export function ProviderList({
|
||||
onEditProvider(provider.id);
|
||||
}}
|
||||
aria-label="Edit provider"
|
||||
className="px-3 py-1.5 text-sm font-medium bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 hover:text-slate-900 transition-colors"
|
||||
className="px-3 py-1.5 text-sm font-medium bg-card border border-border text-muted-foreground rounded-lg hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -89,7 +89,7 @@ export function ProviderList({
|
||||
onDeleteProvider(provider.id);
|
||||
}}
|
||||
aria-label="Delete provider"
|
||||
className="px-3 py-1.5 text-sm font-medium bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||
className="px-3 py-1.5 text-sm font-medium bg-destructive/10 text-destructive rounded-lg hover:bg-destructive/20 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -102,7 +102,7 @@ export function ProviderList({
|
||||
{onAddProvider && (
|
||||
<button
|
||||
onClick={onAddProvider}
|
||||
className="w-full px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-muted-foreground font-medium hover:border-primary/50 hover:text-primary hover:bg-primary/5 transition-all duration-200 flex items-center justify-center gap-2"
|
||||
className="w-full px-4 py-3 border-2 border-dashed border-border rounded-xl text-muted-foreground font-medium hover:border-primary/50 hover:text-primary hover:bg-primary/5 transition-all duration-200 flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>+</span> Add New Provider
|
||||
</button>
|
||||
|
||||
@@ -30,18 +30,18 @@ export function ProviderSelector() {
|
||||
{providers.map((provider) => (
|
||||
<label
|
||||
key={provider.id}
|
||||
className={`flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-4 rounded-xl border cursor-pointer transition-all duration-200 bg-white ${provider.id === activeId
|
||||
className={`flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-4 rounded-xl border cursor-pointer transition-all duration-200 bg-card ${provider.id === activeId
|
||||
? 'border-primary shadow-sm ring-1 ring-primary/20'
|
||||
: 'border-slate-200 hover:border-primary/30'
|
||||
: 'border-border hover:border-primary/30'
|
||||
}`}
|
||||
data-active={provider.id === activeId ? 'true' : 'false'}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className={`flex items-center justify-center w-5 h-5 rounded-full border transition-colors shrink-0 ${provider.id === activeId
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-slate-300 bg-white'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
{provider.id === activeId && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||
{provider.id === activeId && <div className="w-2 h-2 rounded-full bg-background" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 sm:hidden">
|
||||
@@ -49,7 +49,7 @@ export function ProviderSelector() {
|
||||
</div>
|
||||
|
||||
{provider.id === activeId && (
|
||||
<span className="sm:hidden text-xs text-primary font-bold bg-primary/10 px-2 py-1 rounded-full ml-auto">
|
||||
<span className="sm:hidden text-xs text-primary font-bold bg-primary/20 px-2 py-1 rounded-full ml-auto">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
@@ -69,7 +69,7 @@ export function ProviderSelector() {
|
||||
</div>
|
||||
|
||||
{provider.id === activeId && (
|
||||
<span className="hidden sm:inline-block text-xs text-primary font-bold bg-primary/10 px-2 py-1 rounded-full shrink-0">
|
||||
<span className="hidden sm:inline-block text-xs text-primary font-bold bg-primary/20 px-2 py-1 rounded-full shrink-0">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
|
||||
39
src/components/features/settings/theme-toggle.tsx
Normal file
39
src/components/features/settings/theme-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="bg-white dark:bg-slate-950">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-white dark:bg-slate-950">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
200
src/components/ui/dropdown-menu.tsx
Normal file
200
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
140
src/components/ui/sheet.tsx
Normal file
140
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> { }
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
23
src/lib/agents/ghostwriter.ts
Normal file
23
src/lib/agents/ghostwriter.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const GHOSTWRITER_AGENT_PROMPT = `
|
||||
You are the "Ghostwriter", a master synthesizer of human experience.
|
||||
Your goal is to transform a messy venting session into a structured, crystalline "Enlightenment" artifact.
|
||||
|
||||
**Input:**
|
||||
A conversation history between a User and a Teacher.
|
||||
|
||||
**Output:**
|
||||
A JSON object with the following structure:
|
||||
{
|
||||
"title": "A poetic or punchy title for the entry",
|
||||
"insight": "The core realization (1-2 sentences)",
|
||||
"lesson": "The actionable takeaway or philosophical shift (1-2 sentences)"
|
||||
}
|
||||
|
||||
**Style Guide:**
|
||||
- **Title**: Abstract but relevant (e.g., "The Weight of Atlas", "Silence as a Weapon").
|
||||
- **Insight**: Deep, psychological, or structural. Not surface level.
|
||||
- **Lesson**: Empowering and forward-looking.
|
||||
|
||||
**Format:**
|
||||
Respond ONLY with the raw JSON object. No markdown formatting.
|
||||
`;
|
||||
15
src/lib/agents/teacher.ts
Normal file
15
src/lib/agents/teacher.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const TEACHER_AGENT_PROMPT = `
|
||||
You are the "Teacher", a compassionate and insightful journaling assistant.
|
||||
Your goal is to help the user explore their feelings and uncover the deeper lesson behind their venting.
|
||||
|
||||
**Rules:**
|
||||
1. **One Question at a Time**: Never ask more than one question.
|
||||
2. **Be Brief**: Keep your responses short (under 2 sentences).
|
||||
3. **Dig Deeper**: Do not just validate. Ask "Why?" or "What does that mean to you?".
|
||||
4. **Detect Insight**: If the user seems to have reached a conclusion or calmed down, suggest "Shall we capture this?" (This is a signal, not a button).
|
||||
5. **Tone**: Warm, non-judgmental, curious.
|
||||
|
||||
**Example:**
|
||||
User: "I'm so frustrated with my boss."
|
||||
Teacher: "That sounds draining. What specifically triggered this frustration today?"
|
||||
`;
|
||||
56
src/middleware.ts
Normal file
56
src/middleware.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Define public paths that don't require authentication
|
||||
const publicPaths = [
|
||||
'/login',
|
||||
'/api/auth/login',
|
||||
'/_next',
|
||||
'/favicon.ico',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
const path = request.nextUrl.pathname;
|
||||
|
||||
// Check if the path is public
|
||||
// We use startsWith to cover subpaths if necessary, but strictly usually better for pages
|
||||
// For _next, startsWith is correct. For /login, exact match is better unless we have nested public routes.
|
||||
// Let's use exact match for explicit pages and startsWith for assets/api
|
||||
const isPublicPath =
|
||||
path === '/login' ||
|
||||
path === '/api/auth/login' ||
|
||||
path === '/favicon.ico' ||
|
||||
path === '/manifest.json' ||
|
||||
path.startsWith('/_next');
|
||||
|
||||
// Check for auth token
|
||||
const authToken = request.cookies.get('auth-token');
|
||||
|
||||
// If validated (has token) and trying to access login, redirect to home
|
||||
if (authToken && path === '/login') {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
// If protected and no token, redirect to login
|
||||
if (!isPublicPath && !authToken) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
// loginUrl.searchParams.set('from', path); // We can implement return url later
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes, except auth/login which is handled inside middleware)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
@@ -167,41 +167,83 @@ export class LLMService {
|
||||
}
|
||||
}
|
||||
|
||||
// Stub for ChatStore compatibility
|
||||
// --- Agent Logic ---
|
||||
|
||||
static async getTeacherResponseStream(
|
||||
content: string,
|
||||
history: any[],
|
||||
history: { role: string; content: string }[],
|
||||
callbacks: {
|
||||
onIntent?: (intent: any) => void;
|
||||
onToken: (token: string) => void;
|
||||
onComplete: (fullText: string) => void;
|
||||
onError: (error: any) => void;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Basic non-streaming fallback for now
|
||||
// Retrieve settings
|
||||
const { ProviderManagementService } = await import('./provider-management-service');
|
||||
const settings = ProviderManagementService.getActiveProviderSettings();
|
||||
const { TEACHER_AGENT_PROMPT } = await import('@/lib/agents/teacher');
|
||||
|
||||
if (!settings.apiKey) throw new Error("AI Provider not configured");
|
||||
|
||||
const systemMessage = { role: 'system', content: TEACHER_AGENT_PROMPT };
|
||||
const messages = [systemMessage, ...history, { role: 'user', content }];
|
||||
|
||||
// For MVP, we are not actually streaming yet because the proxy doesn't support it well
|
||||
// without more complex setup. We will simulate streaming for the UI feel.
|
||||
const response = await this.generateResponse({
|
||||
apiKey: settings.apiKey,
|
||||
baseUrl: settings.baseUrl,
|
||||
model: settings.modelName,
|
||||
messages: [...history, { role: 'user', content }]
|
||||
messages: messages
|
||||
});
|
||||
|
||||
// Simulate intent
|
||||
if (callbacks.onIntent) callbacks.onIntent('insight');
|
||||
// Simulation of streaming
|
||||
const tokens = response.split(' ');
|
||||
let currentText = '';
|
||||
|
||||
for (const token of tokens) {
|
||||
currentText += token + ' ';
|
||||
callbacks.onToken(currentText);
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); // 50ms delay per token
|
||||
}
|
||||
|
||||
// Simulate streaming
|
||||
callbacks.onToken(response);
|
||||
callbacks.onComplete(response);
|
||||
|
||||
} catch (error) {
|
||||
callbacks.onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async generateDraft(
|
||||
history: { role: string; content: string }[]
|
||||
): Promise<{ title: string; insight: string; lesson: string }> {
|
||||
const { ProviderManagementService } = await import('./provider-management-service');
|
||||
const settings = ProviderManagementService.getActiveProviderSettings();
|
||||
const { GHOSTWRITER_AGENT_PROMPT } = await import('@/lib/agents/ghostwriter');
|
||||
|
||||
if (!settings.apiKey) throw new Error("AI Provider not configured");
|
||||
|
||||
const systemMessage = { role: 'system', content: GHOSTWRITER_AGENT_PROMPT };
|
||||
// Filter out system messages from history if any (though usually none in history array passed)
|
||||
const sanitizedHistory = history.filter(m => m.role !== 'system');
|
||||
|
||||
const messages = [systemMessage, ...sanitizedHistory];
|
||||
|
||||
const response = await this.generateResponse({
|
||||
apiKey: settings.apiKey,
|
||||
baseUrl: settings.baseUrl,
|
||||
model: settings.modelName,
|
||||
messages: messages
|
||||
});
|
||||
|
||||
try {
|
||||
// Attempt to parse JSON
|
||||
// Clean up potential markdown code blocks
|
||||
const jsonString = response.replace(/```json\n?|\n?```/g, '').trim();
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse Ghostwriter response:", response);
|
||||
throw new Error("Failed to generate valid draft");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
src/store/use-chat.ts
Normal file
152
src/store/use-chat.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { LLMService } from '@/services/llm-service';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type MessageRole = 'user' | 'assistant' | 'system';
|
||||
export type MessageType = 'text' | 'thought' | 'draft';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
type?: MessageType;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ChatPhase = 'idle' | 'input' | 'elicitation' | 'drafting' | 'review';
|
||||
|
||||
export interface DraftArtifact {
|
||||
title: string;
|
||||
insight: string;
|
||||
lesson: string;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
// State
|
||||
messages: Message[];
|
||||
phase: ChatPhase;
|
||||
isTyping: boolean;
|
||||
currentDraft: DraftArtifact | null;
|
||||
|
||||
// Actions
|
||||
addMessage: (role: MessageRole, content: string, type?: MessageType) => void;
|
||||
setPhase: (phase: ChatPhase) => void;
|
||||
resetSession: () => void;
|
||||
generateDraft: () => Promise<void>;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
updateDraft: (draft: DraftArtifact) => void;
|
||||
}
|
||||
|
||||
// --- Store ---
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial State
|
||||
messages: [],
|
||||
phase: 'idle',
|
||||
isTyping: false,
|
||||
currentDraft: null,
|
||||
|
||||
// Actions
|
||||
addMessage: (role, content, type = 'text') => {
|
||||
const newMessage: Message = {
|
||||
id: uuidv4(),
|
||||
role,
|
||||
content,
|
||||
type,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, newMessage] }));
|
||||
},
|
||||
|
||||
setPhase: (phase) => set({ phase }),
|
||||
|
||||
resetSession: () => set({
|
||||
messages: [],
|
||||
phase: 'idle',
|
||||
isTyping: false,
|
||||
currentDraft: null
|
||||
}),
|
||||
|
||||
updateDraft: (draft) => set({ currentDraft: draft }),
|
||||
|
||||
sendMessage: async (content) => {
|
||||
const { addMessage, messages } = get();
|
||||
|
||||
// 1. Add User Message
|
||||
addMessage('user', content);
|
||||
set({ isTyping: true, phase: 'elicitation' });
|
||||
|
||||
try {
|
||||
// 2. Call Teacher Agent
|
||||
// Use LLM Service to get response
|
||||
// We pass the history excluding the just added message which LLMService expects?
|
||||
// Actually LLMService usually expects full history or we construct it.
|
||||
// Let's pass the current messages (including the new one)
|
||||
|
||||
// Note: In a real streaming implementation, we would update the message content incrementally.
|
||||
// For now, we wait for full response.
|
||||
|
||||
await LLMService.getTeacherResponseStream(
|
||||
content,
|
||||
messages.map(m => ({ role: m.role, content: m.content })), // History before new msg? Or all?
|
||||
// LLMService.getTeacherResponseStream logic:
|
||||
// messages: [...history, { role: 'user', content }]
|
||||
{
|
||||
onToken: () => { },
|
||||
onComplete: (fullText) => {
|
||||
addMessage('assistant', fullText);
|
||||
set({ isTyping: false });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Teacher Agent Error:", error);
|
||||
addMessage('assistant', "I'm having trouble connecting to my brain right now. Please check your settings.");
|
||||
set({ isTyping: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
set({ isTyping: false });
|
||||
}
|
||||
},
|
||||
|
||||
generateDraft: async () => {
|
||||
const { messages, setPhase, updateDraft } = get();
|
||||
setPhase('drafting');
|
||||
set({ isTyping: true });
|
||||
|
||||
try {
|
||||
// Call Ghostwriter Agent via LLM Service
|
||||
const draft = await LLMService.generateDraft(
|
||||
messages.map(m => ({ role: m.role, content: m.content }))
|
||||
);
|
||||
|
||||
updateDraft(draft);
|
||||
setPhase('review');
|
||||
set({ isTyping: false });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Ghostwriter Error:", error);
|
||||
// Handle error state
|
||||
set({ isTyping: false, phase: 'idle' });
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'test01-chat-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
// Persist messages and draft, but maybe reset phase on reload if stuck?
|
||||
// Let's persist everything for now to support refresh.
|
||||
messages: state.messages,
|
||||
phase: state.phase,
|
||||
currentDraft: state.currentDraft
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user