mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 13:21:38 +08:00
Add multiple entries per day, user management, reminders, and AI reflections
- Multiple entries per day: Home page now starts fresh, Save & New button - Admin user management: Add/delete users, reset passwords, toggle admin - Daily reminders: Browser notifications at configurable time - AI reflections: Generate insights from entries using Claude API - Remove cloud sync placeholder (already have user accounts) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
drizzle/0001_chunky_the_liberteens.sql
Normal file
1
drizzle/0001_chunky_the_liberteens.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` ADD `is_admin` integer DEFAULT 0 NOT NULL;
|
||||
3
drizzle/0002_windy_rawhide_kid.sql
Normal file
3
drizzle/0002_windy_rawhide_kid.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `users` ADD `reminder_enabled` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `reminder_time` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `llm_api_key` text;
|
||||
323
drizzle/meta/0001_snapshot.json
Normal file
323
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,323 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "61aec4e4-4f21-46ad-902e-0082516daaee",
|
||||
"prevId": "597c7e60-5f2b-4b3c-befb-4c929ccbc227",
|
||||
"tables": {
|
||||
"entries": {
|
||||
"name": "entries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"text": {
|
||||
"name": "text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mood": {
|
||||
"name": "mood",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rough_day": {
|
||||
"name": "rough_day",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"entries_user_id_users_id_fk": {
|
||||
"name": "entries_user_id_users_id_fk",
|
||||
"tableFrom": "entries",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"entry_tags": {
|
||||
"name": "entry_tags",
|
||||
"columns": {
|
||||
"entry_id": {
|
||||
"name": "entry_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"entry_tags_entry_id_entries_id_fk": {
|
||||
"name": "entry_tags_entry_id_entries_id_fk",
|
||||
"tableFrom": "entry_tags",
|
||||
"tableTo": "entries",
|
||||
"columnsFrom": [
|
||||
"entry_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"entry_tags_tag_id_tags_id_fk": {
|
||||
"name": "entry_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "entry_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"entry_tags_entry_id_tag_id_pk": {
|
||||
"columns": [
|
||||
"entry_id",
|
||||
"tag_id"
|
||||
],
|
||||
"name": "entry_tags_entry_id_tag_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tags_user_id_users_id_fk": {
|
||||
"name": "tags_user_id_users_id_fk",
|
||||
"tableFrom": "tags",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_admin": {
|
||||
"name": "is_admin",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
345
drizzle/meta/0002_snapshot.json
Normal file
345
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a542a0cf-2a43-4e4d-a8b0-e6b14b8b007e",
|
||||
"prevId": "61aec4e4-4f21-46ad-902e-0082516daaee",
|
||||
"tables": {
|
||||
"entries": {
|
||||
"name": "entries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"text": {
|
||||
"name": "text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mood": {
|
||||
"name": "mood",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rough_day": {
|
||||
"name": "rough_day",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"entries_user_id_users_id_fk": {
|
||||
"name": "entries_user_id_users_id_fk",
|
||||
"tableFrom": "entries",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"entry_tags": {
|
||||
"name": "entry_tags",
|
||||
"columns": {
|
||||
"entry_id": {
|
||||
"name": "entry_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"entry_tags_entry_id_entries_id_fk": {
|
||||
"name": "entry_tags_entry_id_entries_id_fk",
|
||||
"tableFrom": "entry_tags",
|
||||
"tableTo": "entries",
|
||||
"columnsFrom": [
|
||||
"entry_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"entry_tags_tag_id_tags_id_fk": {
|
||||
"name": "entry_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "entry_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"entry_tags_entry_id_tag_id_pk": {
|
||||
"columns": [
|
||||
"entry_id",
|
||||
"tag_id"
|
||||
],
|
||||
"name": "entry_tags_entry_id_tag_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tags_user_id_users_id_fk": {
|
||||
"name": "tags_user_id_users_id_fk",
|
||||
"tableFrom": "tags",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_admin": {
|
||||
"name": "is_admin",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"reminder_enabled": {
|
||||
"name": "reminder_enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"reminder_time": {
|
||||
"name": "reminder_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"llm_api_key": {
|
||||
"name": "llm_api_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@
|
||||
"when": 1769235451953,
|
||||
"tag": "0000_flat_captain_marvel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1769255954092,
|
||||
"tag": "0001_chunky_the_liberteens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1769256216333,
|
||||
"tag": "0002_windy_rawhide_kid",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
assetPrefix: "/quietthanks",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
423
src/app/admin/page.tsx
Normal file
423
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
Key,
|
||||
Shield,
|
||||
ShieldOff,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
isAdmin: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [showResetPassword, setShowResetPassword] = useState<UserData | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<UserData | null>(null);
|
||||
|
||||
// Form states
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/users");
|
||||
if (res.status === 403) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
setUsers(await res.json());
|
||||
} else {
|
||||
setError("Failed to load users");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load users");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !user.isAdmin) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
loadUsers();
|
||||
}, [user, router, loadUsers]);
|
||||
|
||||
const handleAddUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: newEmail,
|
||||
password: newPassword,
|
||||
name: newName,
|
||||
isAdmin: newIsAdmin,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowAddUser(false);
|
||||
setNewEmail("");
|
||||
setNewPassword("");
|
||||
setNewName("");
|
||||
setNewIsAdmin(false);
|
||||
loadUsers();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Failed to create user");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to create user");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!showResetPassword) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${showResetPassword.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: resetPassword }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowResetPassword(null);
|
||||
setResetPassword("");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Failed to reset password");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to reset password");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!showDeleteConfirm) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${showDeleteConfirm.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowDeleteConfirm(null);
|
||||
loadUsers();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Failed to delete user");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to delete user");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAdmin = async (targetUser: UserData) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${targetUser.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isAdmin: !targetUser.isAdmin }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadUsers();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Failed to update user");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to update user");
|
||||
}
|
||||
};
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<header className="flex items-center gap-3 mb-6">
|
||||
<Link href="/settings" className="p-2 -ml-2 text-muted hover:text-foreground">
|
||||
<ArrowLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-light">{APP_NAME}</h1>
|
||||
<p className="text-sm text-muted">User Management</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setShowAddUser(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-muted" size={24} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="p-4 bg-surface border border-border rounded-xl flex items-center gap-4"
|
||||
>
|
||||
<div className="p-2 bg-background rounded-lg">
|
||||
<User size={20} className="text-muted" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{u.email}</p>
|
||||
<p className="text-sm text-muted">
|
||||
{u.name || "No name"} • {u.isAdmin && "Admin • "}
|
||||
{new Date(u.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleAdmin(u)}
|
||||
disabled={u.id === user.id}
|
||||
className="p-2 text-muted hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={u.isAdmin ? "Remove admin" : "Make admin"}
|
||||
>
|
||||
{u.isAdmin ? <ShieldOff size={18} /> : <Shield size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowResetPassword(u)}
|
||||
className="p-2 text-muted hover:text-foreground"
|
||||
title="Reset password"
|
||||
>
|
||||
<Key size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(u)}
|
||||
disabled={u.id === user.id}
|
||||
className="p-2 text-muted hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add User Modal */}
|
||||
{showAddUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-surface border border-border rounded-xl p-6 max-w-md w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium">Add User</h2>
|
||||
<button
|
||||
onClick={() => setShowAddUser(false)}
|
||||
className="p-1 text-muted hover:text-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newIsAdmin}
|
||||
onChange={(e) => setNewIsAdmin(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border bg-background"
|
||||
/>
|
||||
<span className="text-sm">Admin privileges</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddUser(false)}
|
||||
className="px-4 py-2 text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="animate-spin" size={14} />}
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{showResetPassword && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-surface border border-border rounded-xl p-6 max-w-md w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium">Reset Password</h2>
|
||||
<button
|
||||
onClick={() => setShowResetPassword(null)}
|
||||
className="p-1 text-muted hover:text-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Reset password for <strong>{showResetPassword.email}</strong>
|
||||
</p>
|
||||
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetPassword(null)}
|
||||
className="px-4 py-2 text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="animate-spin" size={14} />}
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-surface border border-border rounded-xl p-6 max-w-sm w-full">
|
||||
<h2 className="text-lg font-medium mb-2">Delete User?</h2>
|
||||
<p className="text-sm text-muted mb-6">
|
||||
This will permanently delete <strong>{showDeleteConfirm.email}</strong> and
|
||||
all their entries. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
className="px-4 py-2 text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteUser}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="animate-spin" size={14} />}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
110
src/app/api/admin/users/[id]/route.ts
Normal file
110
src/app/api/admin/users/[id]/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { getSession, hashPassword } from "@/lib/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// DELETE /api/admin/users/[id] - Delete a user (admin only)
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (id === user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot delete your own account" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, id));
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete user:", error);
|
||||
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/admin/users/[id] - Update user (reset password, toggle admin)
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
try {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
// Reset password
|
||||
if (body.password) {
|
||||
updates.passwordHash = await hashPassword(body.password);
|
||||
}
|
||||
|
||||
// Update admin status (prevent self-demotion)
|
||||
if (typeof body.isAdmin === "boolean") {
|
||||
if (id === user.id && !body.isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot remove your own admin status" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
updates.isAdmin = body.isAdmin ? 1 : 0;
|
||||
}
|
||||
|
||||
// Update name
|
||||
if (typeof body.name === "string") {
|
||||
updates.name = body.name || null;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: "No updates provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set(updates)
|
||||
.where(eq(schema.users.id, id));
|
||||
|
||||
// Fetch updated user
|
||||
const updated = await db
|
||||
.select({
|
||||
id: schema.users.id,
|
||||
email: schema.users.email,
|
||||
name: schema.users.name,
|
||||
isAdmin: schema.users.isAdmin,
|
||||
createdAt: schema.users.createdAt,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (updated.length === 0) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated[0],
|
||||
isAdmin: updated[0].isAdmin === 1,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update user:", error);
|
||||
return NextResponse.json({ error: "Failed to update user" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
99
src/app/api/admin/users/route.ts
Normal file
99
src/app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { getSession, hashPassword } from "@/lib/auth";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
// GET /api/admin/users - List all users (admin only)
|
||||
export async function GET() {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await db
|
||||
.select({
|
||||
id: schema.users.id,
|
||||
email: schema.users.email,
|
||||
name: schema.users.name,
|
||||
isAdmin: schema.users.isAdmin,
|
||||
createdAt: schema.users.createdAt,
|
||||
})
|
||||
.from(schema.users)
|
||||
.orderBy(desc(schema.users.createdAt));
|
||||
|
||||
return NextResponse.json(
|
||||
users.map((u) => ({
|
||||
...u,
|
||||
isAdmin: u.isAdmin === 1,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/users - Create a new user (admin only)
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { email, password, name, isAdmin } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email and password are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email already registered" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = uuidv4();
|
||||
const passwordHash = await hashPassword(password);
|
||||
const now = Date.now();
|
||||
|
||||
await db.insert(schema.users).values({
|
||||
id: userId,
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
name: name || null,
|
||||
isAdmin: isAdmin ? 1 : 0,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: userId,
|
||||
email: email.toLowerCase(),
|
||||
name: name || null,
|
||||
isAdmin: Boolean(isAdmin),
|
||||
createdAt: now,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create user:", error);
|
||||
return NextResponse.json({ error: "Failed to create user" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/entries - Create or update today's entry
|
||||
// POST /api/entries - Create a new entry or update existing by id
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
@@ -75,31 +75,33 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body: CreateEntryRequest = await request.json();
|
||||
const { date, text, mood, roughDay, tagNames } = body;
|
||||
const { id: existingId, date, text, mood, roughDay, tagNames } = body;
|
||||
|
||||
if (!date || !text) {
|
||||
return NextResponse.json({ error: "Date and text are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check if entry exists for this date for this user
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.entries)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.entries.userId, user.id),
|
||||
eq(schema.entries.date, date)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let entryId: string;
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing entry
|
||||
entryId = existing[0].id;
|
||||
if (existingId) {
|
||||
// Update existing entry - verify ownership
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.entries)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.entries.id, existingId),
|
||||
eq(schema.entries.userId, user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json({ error: "Entry not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
entryId = existingId;
|
||||
await db
|
||||
.update(schema.entries)
|
||||
.set({
|
||||
|
||||
154
src/app/api/reflections/route.ts
Normal file
154
src/app/api/reflections/route.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { eq, and, gte, lte, desc } from "drizzle-orm";
|
||||
|
||||
// POST /api/reflections - Generate an LLM reflection for a time period
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { period } = await request.json(); // "week" | "month" | "year"
|
||||
|
||||
// Get user's API key
|
||||
const users = await db
|
||||
.select({ llmApiKey: schema.users.llmApiKey })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!users[0]?.llmApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "Please add your Claude API key in settings" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate date range
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (period) {
|
||||
case "week":
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "month":
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
break;
|
||||
case "year":
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
break;
|
||||
default:
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const startDateStr = startDate.toISOString().split("T")[0];
|
||||
const endDateStr = now.toISOString().split("T")[0];
|
||||
|
||||
// Fetch entries for the period
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(schema.entries)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.entries.userId, user.id),
|
||||
gte(schema.entries.date, startDateStr),
|
||||
lte(schema.entries.date, endDateStr)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.entries.date));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return NextResponse.json({
|
||||
reflection: "No entries found for this period. Start logging your gratitude to see reflections!",
|
||||
period,
|
||||
entryCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Get tags for entries
|
||||
const entriesWithTags = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const tagRows = await db
|
||||
.select({ name: schema.tags.name })
|
||||
.from(schema.entryTags)
|
||||
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
|
||||
.where(eq(schema.entryTags.entryId, entry.id));
|
||||
return {
|
||||
...entry,
|
||||
tags: tagRows.map((t) => t.name),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Format entries for the prompt
|
||||
const formattedEntries = entriesWithTags.map((e) => {
|
||||
const moodLabel = e.mood ? `Mood: ${e.mood}/5` : "";
|
||||
const roughDay = e.roughDay ? "(rough day)" : "";
|
||||
const tags = e.tags.length > 0 ? `[${e.tags.join(", ")}]` : "";
|
||||
return `${e.date} ${moodLabel} ${roughDay} ${tags}\n${e.text}`;
|
||||
}).join("\n\n---\n\n");
|
||||
|
||||
const periodLabel = period === "week" ? "past week" : period === "month" ? "past month" : "past year";
|
||||
|
||||
// Call Claude API
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": users[0].llmApiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `You are a thoughtful, warm companion helping someone reflect on their gratitude journal. Below are their entries from the ${periodLabel}.
|
||||
|
||||
Please write a brief, heartfelt reflection (2-3 paragraphs) that:
|
||||
1. Identifies patterns or themes in what they're grateful for
|
||||
2. Acknowledges any difficult days with compassion
|
||||
3. Offers a gentle observation or insight
|
||||
4. Ends with an encouraging thought
|
||||
|
||||
Keep the tone calm, supportive, and personal - like a wise friend. Don't be overly cheerful or use excessive positivity. Be genuine.
|
||||
|
||||
Here are the entries:
|
||||
|
||||
${formattedEntries}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error("Claude API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate reflection. Please check your API key." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const reflection = data.content[0]?.text || "Unable to generate reflection.";
|
||||
|
||||
return NextResponse.json({
|
||||
reflection,
|
||||
period,
|
||||
entryCount: entries.length,
|
||||
dateRange: { start: startDateStr, end: endDateStr },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to generate reflection:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate reflection" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/app/api/settings/route.ts
Normal file
77
src/app/api/settings/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// GET /api/settings - Get current user settings
|
||||
export async function GET() {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await db
|
||||
.select({
|
||||
reminderEnabled: schema.users.reminderEnabled,
|
||||
reminderTime: schema.users.reminderTime,
|
||||
hasLlmKey: schema.users.llmApiKey,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
reminderEnabled: users[0].reminderEnabled === 1,
|
||||
reminderTime: users[0].reminderTime || "20:00",
|
||||
hasLlmKey: Boolean(users[0].hasLlmKey),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch settings:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/settings - Update user settings
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (typeof body.reminderEnabled === "boolean") {
|
||||
updates.reminderEnabled = body.reminderEnabled ? 1 : 0;
|
||||
}
|
||||
|
||||
if (typeof body.reminderTime === "string") {
|
||||
updates.reminderTime = body.reminderTime;
|
||||
}
|
||||
|
||||
if (typeof body.llmApiKey === "string") {
|
||||
// Store the API key (in production, encrypt this)
|
||||
updates.llmApiKey = body.llmApiKey || null;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: "No updates provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set(updates)
|
||||
.where(eq(schema.users.id, user.id));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to update settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default function HomePage() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
<EntryForm />
|
||||
<EntryForm isNewEntry={true} />
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<Link
|
||||
|
||||
149
src/app/reflections/page.tsx
Normal file
149
src/app/reflections/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { ArrowLeft, Loader2, Sparkles, Calendar } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Reflection {
|
||||
reflection: string;
|
||||
period: string;
|
||||
entryCount: number;
|
||||
dateRange?: { start: string; end: string };
|
||||
}
|
||||
|
||||
export default function ReflectionsPage() {
|
||||
const [period, setPeriod] = useState<"week" | "month" | "year">("month");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reflection, setReflection] = useState<Reflection | null>(null);
|
||||
|
||||
const generateReflection = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/reflections", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ period }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to generate reflection");
|
||||
return;
|
||||
}
|
||||
|
||||
setReflection(data);
|
||||
} catch {
|
||||
setError("Failed to generate reflection");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const periodLabels = {
|
||||
week: "Past Week",
|
||||
month: "Past Month",
|
||||
year: "Past Year",
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<header className="flex items-center gap-3 mb-6">
|
||||
<Link href="/settings" className="p-2 -ml-2 text-muted hover:text-foreground">
|
||||
<ArrowLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-light">{APP_NAME}</h1>
|
||||
<p className="text-sm text-muted">AI Reflections</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<p className="text-muted">
|
||||
Generate thoughtful reflections on your gratitude entries using AI.
|
||||
Choose a time period and let Claude help you find patterns and insights.
|
||||
</p>
|
||||
|
||||
{/* Period selector */}
|
||||
<div className="flex gap-2">
|
||||
{(["week", "month", "year"] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
period === p
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface border border-border hover:border-muted"
|
||||
}`}
|
||||
>
|
||||
{periodLabels[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
onClick={generateReflection}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-accent text-white rounded-xl hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Reflecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
Generate Reflection
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reflection result */}
|
||||
{reflection && (
|
||||
<div className="p-6 bg-surface border border-border rounded-xl space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Calendar size={16} />
|
||||
<span>
|
||||
{periodLabels[reflection.period as keyof typeof periodLabels]} •{" "}
|
||||
{reflection.entryCount} entries
|
||||
</span>
|
||||
{reflection.dateRange && (
|
||||
<span>
|
||||
({reflection.dateRange.start} to {reflection.dateRange.end})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
{reflection.reflection.split("\n\n").map((paragraph, i) => (
|
||||
<p key={i} className="text-foreground leading-relaxed">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="text-xs text-muted text-center">
|
||||
Reflections are generated using Claude and are not stored.
|
||||
<br />
|
||||
Each generation uses your API key.
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UserSettings {
|
||||
reminderEnabled: boolean;
|
||||
reminderTime: string;
|
||||
hasLlmKey: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
setSettings(await res.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadSettings();
|
||||
|
||||
// Check notification permission
|
||||
if ("Notification" in window) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSetting = async (key: string, value: unknown) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => prev ? { ...prev, [key]: value } : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update setting:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestNotificationPermission = async () => {
|
||||
if ("Notification" in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationPermission(permission);
|
||||
if (permission === "granted") {
|
||||
updateSetting("reminderEnabled", true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReminder = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
if (!settings.reminderEnabled) {
|
||||
// Enabling - need permission first
|
||||
if (notificationPermission !== "granted") {
|
||||
await requestNotificationPermission();
|
||||
} else {
|
||||
updateSetting("reminderEnabled", true);
|
||||
}
|
||||
} else {
|
||||
updateSetting("reminderEnabled", false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveApiKey = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ llmApiKey: apiKey }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => prev ? { ...prev, hasLlmKey: Boolean(apiKey) } : null);
|
||||
setShowApiKeyInput(false);
|
||||
setApiKey("");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save API key:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up reminder check interval
|
||||
useEffect(() => {
|
||||
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||
|
||||
const checkReminder = () => {
|
||||
const now = new Date();
|
||||
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
||||
|
||||
if (now.getHours() === hours && now.getMinutes() === minutes) {
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification(APP_NAME, {
|
||||
body: "Take a moment to reflect on what you're grateful for today.",
|
||||
icon: "/icon.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check every minute
|
||||
const interval = setInterval(checkReminder, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [settings?.reminderEnabled, settings?.reminderTime]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -32,59 +153,161 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications - disabled in MVP */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Admin - only show for admin users */}
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="p-4 bg-surface border border-border rounded-xl flex items-center gap-3 hover:border-muted transition-colors"
|
||||
>
|
||||
<Users size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">Daily reminder</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Get a gentle nudge to check in
|
||||
</p>
|
||||
<h3 className="font-medium">User Management</h3>
|
||||
<p className="text-sm text-muted">Add users, reset passwords</p>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
|
||||
>
|
||||
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Sync - disabled in MVP */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Cloud sync</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Sync across devices (coming soon)
|
||||
</p>
|
||||
{/* Daily Reminder */}
|
||||
{!isLoading && settings && (
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">Daily reminder</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Get a gentle nudge to check in
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleReminder}
|
||||
disabled={isSaving}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
settings.reminderEnabled ? "bg-accent" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
settings.reminderEnabled ? "left-7" : "left-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
|
||||
>
|
||||
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
|
||||
</button>
|
||||
{settings.reminderEnabled && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<label className="text-sm text-muted">Remind me at:</label>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.reminderTime}
|
||||
onChange={(e) => updateSetting("reminderTime", e.target.value)}
|
||||
className="px-3 py-1 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{notificationPermission === "denied" && (
|
||||
<p className="mt-2 text-sm text-red-400">
|
||||
Notifications are blocked. Please enable them in your browser settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM Summaries - disabled in MVP */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">AI summaries</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Monthly reflections with LLM (coming soon)
|
||||
</p>
|
||||
{/* AI Reflections */}
|
||||
{!isLoading && settings && (
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">AI reflections</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Generate insights from your entries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{settings.hasLlmKey ? (
|
||||
<Check size={20} className="text-green-500" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="px-3 py-1 text-sm bg-accent text-white rounded-lg hover:bg-accent/90"
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
|
||||
>
|
||||
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
|
||||
</button>
|
||||
|
||||
{settings.hasLlmKey && !showApiKeyInput && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-green-500">API key configured</span>
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<span className="text-muted">•</span>
|
||||
<Link
|
||||
href="/reflections"
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
View reflections
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApiKeyInput && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">
|
||||
Claude API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveApiKey}
|
||||
disabled={isSaving || !apiKey}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://console.anthropic.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
console.anthropic.com
|
||||
</a>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowApiKeyInput(false);
|
||||
setApiKey("");
|
||||
}}
|
||||
className="text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-6 border-t border-border text-center text-sm text-muted">
|
||||
<p>{APP_NAME} v0.1.0</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
import { useEntry } from "@/hooks/useEntry";
|
||||
import { MoodSelector } from "./MoodSelector";
|
||||
import { TagInput } from "./TagInput";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { Check, Loader2, Plus, Save } from "lucide-react";
|
||||
|
||||
interface EntryFormProps {
|
||||
date?: string;
|
||||
entryId?: string;
|
||||
isNewEntry?: boolean;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function EntryForm({ date, entryId }: EntryFormProps) {
|
||||
export function EntryForm({ date, entryId, isNewEntry = false, onSaved }: EntryFormProps) {
|
||||
const {
|
||||
entry,
|
||||
text,
|
||||
mood,
|
||||
roughDay,
|
||||
@@ -24,7 +27,21 @@ export function EntryForm({ date, entryId }: EntryFormProps) {
|
||||
updateRoughDay,
|
||||
addTag,
|
||||
removeTag,
|
||||
} = useEntry({ date, entryId });
|
||||
save,
|
||||
reset,
|
||||
} = useEntry({ date, entryId, isNewEntry });
|
||||
|
||||
const handleSave = async () => {
|
||||
const saved = await save();
|
||||
if (saved && onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndNew = async () => {
|
||||
await save();
|
||||
reset();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -34,6 +51,8 @@ export function EntryForm({ date, entryId }: EntryFormProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const hasContent = text.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Main prompt */}
|
||||
@@ -80,21 +99,62 @@ export function EntryForm({ date, entryId }: EntryFormProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save status */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
{isSaving ? (
|
||||
{/* Save buttons */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
{isNewEntry ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={14} />
|
||||
Saving...
|
||||
<button
|
||||
onClick={handleSaveAndNew}
|
||||
disabled={isSaving || !hasContent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Plus size={16} />
|
||||
)}
|
||||
Save & New
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasContent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-foreground rounded-lg hover:border-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Save size={16} />
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
) : lastSaved ? (
|
||||
<>
|
||||
<Check size={14} className="text-green-500" />
|
||||
Saved
|
||||
</>
|
||||
) : text.trim() ? (
|
||||
<span className="text-muted/50">Auto-saves as you type</span>
|
||||
) : null}
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasContent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save status */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted ml-auto">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={14} />
|
||||
Saving...
|
||||
</>
|
||||
) : lastSaved ? (
|
||||
<>
|
||||
<Check size={14} className="text-green-500" />
|
||||
Saved
|
||||
</>
|
||||
) : hasContent ? (
|
||||
<span className="text-muted/50">Auto-saves as you type</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,10 @@ import { AUTOSAVE_DELAY } from "@/lib/constants";
|
||||
interface UseEntryOptions {
|
||||
date?: string;
|
||||
entryId?: string;
|
||||
isNewEntry?: boolean; // If true, start with blank form
|
||||
}
|
||||
|
||||
export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions = {}) {
|
||||
const targetDate = date || getLocalDate();
|
||||
const [entry, setEntry] = useState<EntryWithTags | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
@@ -19,12 +20,17 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
const [roughDay, setRoughDay] = useState(false);
|
||||
const [tagNames, setTagNames] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(!isNewEntry);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const pendingSaveRef = useRef(false);
|
||||
|
||||
// Load entry
|
||||
// Load entry (only if editing existing or viewing by date)
|
||||
useEffect(() => {
|
||||
if (isNewEntry) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function loadEntry() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -35,13 +41,6 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
if (res.ok) {
|
||||
data = await res.json();
|
||||
}
|
||||
} else {
|
||||
// Load today's entry by date
|
||||
const res = await fetch(`/api/entries?since=${targetDate}`);
|
||||
if (res.ok) {
|
||||
const entries: EntryWithTags[] = await res.json();
|
||||
data = entries.find((e) => e.date === targetDate) || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
@@ -59,17 +58,18 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
}
|
||||
|
||||
loadEntry();
|
||||
}, [targetDate, entryId]);
|
||||
}, [targetDate, entryId, isNewEntry]);
|
||||
|
||||
// Save function
|
||||
const save = useCallback(async () => {
|
||||
if (!text.trim()) return;
|
||||
if (!text.trim()) return null;
|
||||
|
||||
setIsSaving(true);
|
||||
pendingSaveRef.current = false;
|
||||
|
||||
try {
|
||||
const body: CreateEntryRequest = {
|
||||
id: entry?.id, // Include id if updating existing
|
||||
date: entry?.date || targetDate,
|
||||
text: text.trim(),
|
||||
mood,
|
||||
@@ -87,13 +87,15 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
const saved: EntryWithTags = await res.json();
|
||||
setEntry(saved);
|
||||
setLastSaved(new Date());
|
||||
return saved;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save entry:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
|
||||
return null;
|
||||
}, [text, mood, roughDay, tagNames, entry?.id, entry?.date, targetDate]);
|
||||
|
||||
// Debounced save
|
||||
const debouncedSave = useDebounce(() => {
|
||||
@@ -152,6 +154,17 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
[markDirty]
|
||||
);
|
||||
|
||||
// Reset form for new entry
|
||||
const reset = useCallback(() => {
|
||||
setEntry(null);
|
||||
setText("");
|
||||
setMood(null);
|
||||
setRoughDay(false);
|
||||
setTagNames([]);
|
||||
setLastSaved(null);
|
||||
pendingSaveRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Save on unmount if pending
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -161,6 +174,7 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: entry?.id,
|
||||
date: entry?.date || targetDate,
|
||||
text: text.trim(),
|
||||
mood,
|
||||
@@ -170,7 +184,7 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
|
||||
}, [text, mood, roughDay, tagNames, entry?.id, entry?.date, targetDate]);
|
||||
|
||||
return {
|
||||
entry,
|
||||
@@ -187,5 +201,6 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||
addTag,
|
||||
removeTag,
|
||||
save,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
@@ -68,6 +69,7 @@ export async function getSession(): Promise<AuthUser | null> {
|
||||
userId: schema.users.id,
|
||||
email: schema.users.email,
|
||||
name: schema.users.name,
|
||||
isAdmin: schema.users.isAdmin,
|
||||
expiresAt: schema.sessions.expiresAt,
|
||||
})
|
||||
.from(schema.sessions)
|
||||
@@ -83,6 +85,7 @@ export async function getSession(): Promise<AuthUser | null> {
|
||||
id: result[0].userId,
|
||||
email: result[0].email,
|
||||
name: result[0].name,
|
||||
isAdmin: result[0].isAdmin === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +125,7 @@ export async function registerUser(
|
||||
id: userId,
|
||||
email: email.toLowerCase(),
|
||||
name: name || null,
|
||||
isAdmin: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -154,6 +158,7 @@ export async function loginUser(
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
isAdmin: user.isAdmin === 1,
|
||||
},
|
||||
sessionId,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@ export const users = sqliteTable("users", {
|
||||
email: text("email").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
name: text("name"),
|
||||
isAdmin: integer("is_admin").notNull().default(0), // 1 = admin
|
||||
reminderEnabled: integer("reminder_enabled").notNull().default(0),
|
||||
reminderTime: text("reminder_time"), // HH:MM format
|
||||
llmApiKey: text("llm_api_key"), // Encrypted API key for LLM
|
||||
createdAt: integer("created_at").notNull(), // Unix ms
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface EntryWithTags extends Entry {
|
||||
|
||||
// API request/response types
|
||||
export interface CreateEntryRequest {
|
||||
id?: string; // If provided, updates existing entry
|
||||
date: string;
|
||||
text: string;
|
||||
mood?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user