diff --git a/App.tsx b/App.tsx index e7a896c..a5d681a 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,9 @@ -import React, { useEffect } from 'react'; +'use client'; + +import React, { useEffect, useState } from 'react'; import { AppMode } from './types'; import { useAppStore } from './store'; +import { useAuth } from './contexts/AuthContext'; import Header from './components/Header'; import ModelingView from './components/ModelingView'; import AnalyzingView from './components/AnalyzingView'; @@ -9,8 +12,15 @@ import ModelListView from './components/ModelListView'; import CreateNewModelModal from './components/CreateNewModelModal'; import HistoryPanel from './components/HistoryPanel'; import ConversationalAnalyst from './components/ConversationalAnalyst'; +import { AuthPage } from './components/auth/AuthPage'; +import { Scratchpad } from './components/Scratchpad'; +import { AnalysisHistory } from './components/AnalysisHistory'; const App: React.FC = () => { + const { user, isLoading: isAuthLoading, isAuthenticated } = useAuth(); + const [isScratchpadOpen, setIsScratchpadOpen] = useState(false); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const { model, mode, @@ -25,8 +35,14 @@ const App: React.FC = () => { // Runs once on mount useEffect(() => { initializeTheme(); - fetchAvailableModels(); - }, []); // Remove dependencies to prevent infinite loops + }, []); + + // Fetch models when authenticated + useEffect(() => { + if (isAuthenticated) { + fetchAvailableModels(); + } + }, [isAuthenticated]); const renderModelContent = () => { if (!model) { @@ -45,12 +61,27 @@ const App: React.FC = () => { } }; + // Show auth loading state + if (isAuthLoading) { + return ( +
+
+ Loading... +
+ ); + } + + // Show auth page if not authenticated + if (!isAuthenticated) { + return ; + } + // Show full-screen loader only on initial application load if (isLoading && availableModels.length === 0 && !model) { return (
- Loading... + Loading models...
); } @@ -64,12 +95,30 @@ const App: React.FC = () => { ) : ( <> -
+
setIsScratchpadOpen(true)} + onOpenHistory={() => setIsHistoryOpen(true)} + />
{renderModelContent()}
{isChatAnalystOpen && } + + {/* Scratchpad panel */} + setIsScratchpadOpen(false)} + /> + + {/* Analysis History panel */} + setIsHistoryOpen(false)} + /> )} diff --git a/KINSHIP.md b/KINSHIP.md new file mode 100644 index 0000000..97e2268 --- /dev/null +++ b/KINSHIP.md @@ -0,0 +1,7 @@ +# KINSHIP + +## repo `jgwill/veritas` cloned in: `/workspace/repos/jgwill/veritas` + +* Next major version migrated of this repo +* Should probably become where we will work + diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..14c2d3e --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,67 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +function generateToken(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let token = '' + for (let i = 0; i < 64; i++) { + token += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return token +} + +export async function POST(request: Request) { + try { + const { email, password } = await request.json() + + if (!email || !password) { + return NextResponse.json( + { error: 'Email and password are required' }, + { status: 400 } + ) + } + + // Verify credentials + const users = await sql` + SELECT id, email, display_name + FROM users + WHERE email = ${email.toLowerCase()} + AND password_hash = crypt(${password}, password_hash) + ` + + if (users.length === 0) { + return NextResponse.json( + { error: 'Invalid email or password' }, + { status: 401 } + ) + } + + const user = users[0] + + // Create new session + const token = generateToken() + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days + + await sql` + INSERT INTO sessions (user_id, token, expires_at) + VALUES (${user.id}, ${token}, ${expiresAt.toISOString()}) + ` + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + display_name: user.display_name + }, + token + }) + } catch (error) { + console.error('Login error:', error) + return NextResponse.json( + { error: 'Login failed. Please try again.' }, + { status: 500 } + ) + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..9533f6f --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,27 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +export async function POST(request: Request) { + try { + const authHeader = request.headers.get('Authorization') + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7) + + // Delete the session + await sql` + DELETE FROM sessions WHERE token = ${token} + ` + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Logout error:', error) + return NextResponse.json( + { error: 'Logout failed' }, + { status: 500 } + ) + } +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..a491733 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,78 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +function generateToken(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let token = '' + for (let i = 0; i < 64; i++) { + token += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return token +} + +export async function POST(request: Request) { + try { + const { email, password, displayName } = await request.json() + + if (!email || !password) { + return NextResponse.json( + { error: 'Email and password are required' }, + { status: 400 } + ) + } + + if (password.length < 6) { + return NextResponse.json( + { error: 'Password must be at least 6 characters' }, + { status: 400 } + ) + } + + // Check if user already exists + const existingUsers = await sql` + SELECT id FROM users WHERE email = ${email.toLowerCase()} + ` + + if (existingUsers.length > 0) { + return NextResponse.json( + { error: 'An account with this email already exists' }, + { status: 409 } + ) + } + + // Create user with hashed password + const users = await sql` + INSERT INTO users (email, password_hash, display_name) + VALUES (${email.toLowerCase()}, crypt(${password}, gen_salt('bf')), ${displayName || null}) + RETURNING id, email, display_name, created_at + ` + + const user = users[0] + + // Create session + const token = generateToken() + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days + + await sql` + INSERT INTO sessions (user_id, token, expires_at) + VALUES (${user.id}, ${token}, ${expiresAt.toISOString()}) + ` + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + display_name: user.display_name + }, + token + }) + } catch (error) { + console.error('Registration error:', error) + return NextResponse.json( + { error: 'Registration failed. Please try again.' }, + { status: 500 } + ) + } +} diff --git a/app/api/auth/validate/route.ts b/app/api/auth/validate/route.ts new file mode 100644 index 0000000..5cac4a8 --- /dev/null +++ b/app/api/auth/validate/route.ts @@ -0,0 +1,50 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +export async function GET(request: Request) { + try { + const authHeader = request.headers.get('Authorization') + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'No authorization token provided' }, + { status: 401 } + ) + } + + const token = authHeader.substring(7) + + // Find valid session + const sessions = await sql` + SELECT s.user_id, u.id, u.email, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ${token} AND s.expires_at > NOW() + ` + + if (sessions.length === 0) { + return NextResponse.json( + { error: 'Invalid or expired session' }, + { status: 401 } + ) + } + + const user = sessions[0] + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + display_name: user.display_name + } + }) + } catch (error) { + console.error('Session validation error:', error) + return NextResponse.json( + { error: 'Session validation failed' }, + { status: 500 } + ) + } +} diff --git a/app/api/models/[id]/notes/route.ts b/app/api/models/[id]/notes/route.ts new file mode 100644 index 0000000..f14069c --- /dev/null +++ b/app/api/models/[id]/notes/route.ts @@ -0,0 +1,97 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +async function getUserFromToken(request: Request) { + const authHeader = request.headers.get('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const token = authHeader.substring(7) + const sessions = await sql` + SELECT u.id, u.email, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ${token} AND s.expires_at > NOW() + ` + + return sessions.length > 0 ? sessions[0] : null +} + +// GET - List all notes for a model +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: modelId } = await params + + // Verify model ownership + const models = await sql` + SELECT id FROM models WHERE id = ${modelId} AND user_id = ${user.id} + ` + + if (models.length === 0) { + return NextResponse.json({ error: 'Model not found' }, { status: 404 }) + } + + const notes = await sql` + SELECT id, model_id, title, content, element_id, note_type, is_pinned, created_at, updated_at + FROM scratchpad_notes + WHERE model_id = ${modelId} AND user_id = ${user.id} + ORDER BY is_pinned DESC, updated_at DESC + ` + + return NextResponse.json({ notes }) + } catch (error) { + console.error('Error fetching notes:', error) + return NextResponse.json({ error: 'Failed to fetch notes' }, { status: 500 }) + } +} + +// POST - Create a new note +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: modelId } = await params + const { title, content, elementId, noteType } = await request.json() + + // Verify model ownership + const models = await sql` + SELECT id FROM models WHERE id = ${modelId} AND user_id = ${user.id} + ` + + if (models.length === 0) { + return NextResponse.json({ error: 'Model not found' }, { status: 404 }) + } + + if (!content) { + return NextResponse.json({ error: 'Content is required' }, { status: 400 }) + } + + const notes = await sql` + INSERT INTO scratchpad_notes (model_id, user_id, title, content, element_id, note_type) + VALUES (${modelId}, ${user.id}, ${title || null}, ${content}, ${elementId || null}, ${noteType || 'general'}) + RETURNING id, model_id, title, content, element_id, note_type, is_pinned, created_at, updated_at + ` + + return NextResponse.json({ note: notes[0] }) + } catch (error) { + console.error('Error creating note:', error) + return NextResponse.json({ error: 'Failed to create note' }, { status: 500 }) + } +} diff --git a/app/api/models/[id]/route.ts b/app/api/models/[id]/route.ts new file mode 100644 index 0000000..c26696a --- /dev/null +++ b/app/api/models/[id]/route.ts @@ -0,0 +1,117 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +async function getUserFromToken(request: Request) { + const authHeader = request.headers.get('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const token = authHeader.substring(7) + const sessions = await sql` + SELECT u.id, u.email, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ${token} AND s.expires_at > NOW() + ` + + return sessions.length > 0 ? sessions[0] : null +} + +// GET - Get a specific model +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + const models = await sql` + SELECT id, user_id, template_id, name, description, model_type, model_data, is_archived, created_at, updated_at + FROM models + WHERE id = ${id} AND user_id = ${user.id} + ` + + if (models.length === 0) { + return NextResponse.json({ error: 'Model not found' }, { status: 404 }) + } + + return NextResponse.json({ model: models[0] }) + } catch (error) { + console.error('Error fetching model:', error) + return NextResponse.json({ error: 'Failed to fetch model' }, { status: 500 }) + } +} + +// PUT - Update a model +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const { name, description, modelData, isArchived } = await request.json() + + // Verify ownership + const existing = await sql` + SELECT id FROM models WHERE id = ${id} AND user_id = ${user.id} + ` + + if (existing.length === 0) { + return NextResponse.json({ error: 'Model not found' }, { status: 404 }) + } + + const models = await sql` + UPDATE models + SET + name = COALESCE(${name}, name), + description = COALESCE(${description}, description), + model_data = COALESCE(${modelData ? JSON.stringify(modelData) : null}, model_data), + is_archived = COALESCE(${isArchived}, is_archived), + updated_at = NOW() + WHERE id = ${id} AND user_id = ${user.id} + RETURNING id, user_id, template_id, name, description, model_type, model_data, is_archived, created_at, updated_at + ` + + return NextResponse.json({ model: models[0] }) + } catch (error) { + console.error('Error updating model:', error) + return NextResponse.json({ error: 'Failed to update model' }, { status: 500 }) + } +} + +// DELETE - Delete a model +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + await sql` + DELETE FROM models WHERE id = ${id} AND user_id = ${user.id} + ` + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting model:', error) + return NextResponse.json({ error: 'Failed to delete model' }, { status: 500 }) + } +} diff --git a/app/api/models/[id]/snapshots/route.ts b/app/api/models/[id]/snapshots/route.ts new file mode 100644 index 0000000..48a1178 --- /dev/null +++ b/app/api/models/[id]/snapshots/route.ts @@ -0,0 +1,97 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +async function getUserFromToken(request: Request) { + const authHeader = request.headers.get('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const token = authHeader.substring(7) + const sessions = await sql` + SELECT u.id, u.email, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ${token} AND s.expires_at > NOW() + ` + + return sessions.length > 0 ? sessions[0] : null +} + +// GET - List all snapshots for a model +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: modelId } = await params + + // Verify model ownership + const models = await sql` + SELECT id FROM models WHERE id = ${modelId} AND user_id = ${user.id} + ` + + if (models.length === 0) { + return NextResponse.json({ error: 'Model not found' }, { status: 404 }) + } + + const snapshots = await sql` + SELECT id, model_id, snapshot_name, snapshot_date, elements_data, summary_notes, created_at + FROM analysis_snapshots + WHERE model_id = ${modelId} AND user_id = ${user.id} + ORDER BY snapshot_date DESC + ` + + return NextResponse.json({ snapshots }) + } catch (error) { + console.error('Error fetching snapshots:', error) + return NextResponse.json({ error: 'Failed to fetch snapshots' }, { status: 500 }) + } +} + +// POST - Create a new snapshot (save current analysis state) +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: modelId } = await params + const { snapshotName, elementsData, summaryNotes } = await request.json() + + // Verify model ownership + const models = await sql` + SELECT id FROM models WHERE id = ${modelId} AND user_id = ${user.id} + ` + + if (models.length === 0) { + return NextResponse.json({ error: 'Model not found' }, { status: 404 }) + } + + if (!elementsData) { + return NextResponse.json({ error: 'Elements data is required' }, { status: 400 }) + } + + const snapshots = await sql` + INSERT INTO analysis_snapshots (model_id, user_id, snapshot_name, elements_data, summary_notes) + VALUES (${modelId}, ${user.id}, ${snapshotName || null}, ${JSON.stringify(elementsData)}, ${summaryNotes || null}) + RETURNING id, model_id, snapshot_name, snapshot_date, elements_data, summary_notes, created_at + ` + + return NextResponse.json({ snapshot: snapshots[0] }) + } catch (error) { + console.error('Error creating snapshot:', error) + return NextResponse.json({ error: 'Failed to create snapshot' }, { status: 500 }) + } +} diff --git a/app/api/models/route.ts b/app/api/models/route.ts new file mode 100644 index 0000000..8049927 --- /dev/null +++ b/app/api/models/route.ts @@ -0,0 +1,71 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +// Helper to get user from token +async function getUserFromToken(request: Request) { + const authHeader = request.headers.get('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const token = authHeader.substring(7) + const sessions = await sql` + SELECT u.id, u.email, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ${token} AND s.expires_at > NOW() + ` + + return sessions.length > 0 ? sessions[0] : null +} + +// GET - List all models for user +export async function GET(request: Request) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const models = await sql` + SELECT id, name, description, model_type, is_archived, template_id, created_at, updated_at + FROM models + WHERE user_id = ${user.id} AND is_archived = false + ORDER BY updated_at DESC + ` + + return NextResponse.json({ models }) + } catch (error) { + console.error('Error fetching models:', error) + return NextResponse.json({ error: 'Failed to fetch models' }, { status: 500 }) + } +} + +// POST - Create a new model +export async function POST(request: Request) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { name, description, modelType, modelData, templateId } = await request.json() + + if (!name || !modelType || !modelData) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) + } + + const models = await sql` + INSERT INTO models (user_id, template_id, name, description, model_type, model_data) + VALUES (${user.id}, ${templateId || null}, ${name}, ${description || null}, ${modelType}, ${JSON.stringify(modelData)}) + RETURNING id, user_id, template_id, name, description, model_type, model_data, is_archived, created_at, updated_at + ` + + return NextResponse.json({ model: models[0] }) + } catch (error) { + console.error('Error creating model:', error) + return NextResponse.json({ error: 'Failed to create model' }, { status: 500 }) + } +} diff --git a/app/api/notes/[noteId]/route.ts b/app/api/notes/[noteId]/route.ts new file mode 100644 index 0000000..9c6b289 --- /dev/null +++ b/app/api/notes/[noteId]/route.ts @@ -0,0 +1,82 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +async function getUserFromToken(request: Request) { + const authHeader = request.headers.get('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const token = authHeader.substring(7) + const sessions = await sql` + SELECT u.id, u.email, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ${token} AND s.expires_at > NOW() + ` + + return sessions.length > 0 ? sessions[0] : null +} + +// PUT - Update a note +export async function PUT( + request: Request, + { params }: { params: Promise<{ noteId: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { noteId } = await params + const { title, content, isPinned, noteType } = await request.json() + + const notes = await sql` + UPDATE scratchpad_notes + SET + title = COALESCE(${title}, title), + content = COALESCE(${content}, content), + is_pinned = COALESCE(${isPinned}, is_pinned), + note_type = COALESCE(${noteType}, note_type), + updated_at = NOW() + WHERE id = ${noteId} AND user_id = ${user.id} + RETURNING id, model_id, title, content, element_id, note_type, is_pinned, created_at, updated_at + ` + + if (notes.length === 0) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }) + } + + return NextResponse.json({ note: notes[0] }) + } catch (error) { + console.error('Error updating note:', error) + return NextResponse.json({ error: 'Failed to update note' }, { status: 500 }) + } +} + +// DELETE - Delete a note +export async function DELETE( + request: Request, + { params }: { params: Promise<{ noteId: string }> } +) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { noteId } = await params + + await sql` + DELETE FROM scratchpad_notes WHERE id = ${noteId} AND user_id = ${user.id} + ` + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting note:', error) + return NextResponse.json({ error: 'Failed to delete note' }, { status: 500 }) + } +} diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts new file mode 100644 index 0000000..8c266d6 --- /dev/null +++ b/app/api/templates/route.ts @@ -0,0 +1,70 @@ +import { neon } from '@neondatabase/serverless' +import { NextResponse } from 'next/server' + +const sql = neon(process.env.DATABASE_URL!) + +async function getUserFromToken(request: Request) { + const authHeader = request.headers.get('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const token = authHeader.substring(7) + const sessions = await sql` + SELECT u.id, u.email, u.display_name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ${token} AND s.expires_at > NOW() + ` + + return sessions.length > 0 ? sessions[0] : null +} + +// GET - List all available templates (system templates + user's own templates) +export async function GET(request: Request) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const templates = await sql` + SELECT id, name, description, model_type, template_data, is_system, created_by, created_at + FROM model_templates + WHERE is_system = true OR created_by = ${user.id} + ORDER BY is_system DESC, name ASC + ` + + return NextResponse.json({ templates }) + } catch (error) { + console.error('Error fetching templates:', error) + return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 }) + } +} + +// POST - Create a new template from an existing model +export async function POST(request: Request) { + try { + const user = await getUserFromToken(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { name, description, modelType, templateData } = await request.json() + + if (!name || !modelType || !templateData) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) + } + + const templates = await sql` + INSERT INTO model_templates (name, description, model_type, template_data, is_system, created_by) + VALUES (${name}, ${description || null}, ${modelType}, ${JSON.stringify(templateData)}, false, ${user.id}) + RETURNING id, name, description, model_type, template_data, is_system, created_by, created_at + ` + + return NextResponse.json({ template: templates[0] }) + } catch (error) { + console.error('Error creating template:', error) + return NextResponse.json({ error: 'Failed to create template' }, { status: 500 }) + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 13aa373..f4f3685 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type React from "react" import type { Metadata } from "next" import { Inter } from "next/font/google" import "./globals.css" +import { AuthProvider } from "../contexts/AuthContext" const inter = Inter({ subsets: ["latin"] }) @@ -18,7 +19,11 @@ export default function RootLayout({ }) { return ( - {children} + + + {children} + + ) } diff --git a/components/AnalysisHistory.tsx b/components/AnalysisHistory.tsx new file mode 100644 index 0000000..d6f258e --- /dev/null +++ b/components/AnalysisHistory.tsx @@ -0,0 +1,236 @@ +'use client'; + +import React, { useState, useEffect } from 'react' +import { Button } from './ui/button' +import { Input } from './ui/input' +import { getAuthToken } from '../services/authService' +import { History, Save, Calendar, ChevronRight, Loader2, X, Eye } from 'lucide-react' + +interface Snapshot { + id: string + model_id: string + snapshot_name: string | null + snapshot_date: string + elements_data: any + summary_notes: string | null + created_at: string +} + +interface AnalysisHistoryProps { + modelId: string + currentElementsData: any + isOpen: boolean + onClose: () => void + onRestoreSnapshot?: (elementsData: any) => void +} + +export function AnalysisHistory({ + modelId, + currentElementsData, + isOpen, + onClose, + onRestoreSnapshot +}: AnalysisHistoryProps) { + const [snapshots, setSnapshots] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [newSnapshotName, setNewSnapshotName] = useState('') + const [newSnapshotNotes, setNewSnapshotNotes] = useState('') + const [selectedSnapshot, setSelectedSnapshot] = useState(null) + + useEffect(() => { + if (isOpen && modelId) { + fetchSnapshots() + } + }, [isOpen, modelId]) + + const fetchSnapshots = async () => { + const token = getAuthToken() + if (!token) return + + try { + const response = await fetch(`/api/models/${modelId}/snapshots`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + if (response.ok) { + const data = await response.json() + setSnapshots(data.snapshots) + } + } catch (error) { + console.error('Error fetching snapshots:', error) + } finally { + setIsLoading(false) + } + } + + const saveSnapshot = async () => { + const token = getAuthToken() + if (!token) return + + setIsSaving(true) + try { + const response = await fetch(`/api/models/${modelId}/snapshots`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + snapshotName: newSnapshotName || `Analysis ${new Date().toLocaleDateString()}`, + elementsData: currentElementsData, + summaryNotes: newSnapshotNotes || null + }) + }) + + if (response.ok) { + const data = await response.json() + setSnapshots([data.snapshot, ...snapshots]) + setNewSnapshotName('') + setNewSnapshotNotes('') + } + } catch (error) { + console.error('Error saving snapshot:', error) + } finally { + setIsSaving(false) + } + } + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + const getEvaluationSummary = (elementsData: any[]) => { + if (!Array.isArray(elementsData)) return { evaluated: 0, total: 0 } + const evaluated = elementsData.filter(el => el.TwoFlagAnswered || el.ThreeFlagAnswered).length + return { evaluated, total: elementsData.length } + } + + if (!isOpen) return null + + return ( +
+
+
+ +

Analysis History

+
+ +
+ + {/* Save current state */} +
+

Save Current Analysis

+ setNewSnapshotName(e.target.value)} + className="bg-background" + /> +