mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
Compare commits
4 Commits
83ee6eefec
...
3ca83f304f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca83f304f | ||
|
|
90f54766a5 | ||
|
|
ad8b45ee1f | ||
|
|
e35545b156 |
@@ -28,6 +28,8 @@ FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATABASE_PATH=/app/data/quietthanks.db
|
||||
|
||||
|
||||
@@ -9,3 +9,25 @@ services:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/quietthanks.db
|
||||
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BIKukAq5-KPwJAMpksxD7UNL8XfF-oJOI0CLGGZQAY93igZgf1PYa9MVvS8GaBv-vv9ckcXPCEKdzWDCtOyQpKg
|
||||
- VAPID_PRIVATE_KEY=IBkQ14BLKFCg2PmGOWheC7xfYHS5J49vXS8duHCeDBw
|
||||
- VAPID_EMAIL=mailto:admin@example.com
|
||||
- TZ=Australia/Perth
|
||||
|
||||
scheduler:
|
||||
image: alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
quietthanks:
|
||||
condition: service_started
|
||||
environment:
|
||||
- TZ=Australia/Perth
|
||||
entrypoint: /bin/sh
|
||||
command: >
|
||||
-c "apk add --no-cache curl &&
|
||||
while true; do
|
||||
echo 'Checking for notifications...' &&
|
||||
curl -s -X POST http://quietthanks:3000/api/notifications/send &&
|
||||
echo '' &&
|
||||
sleep 60;
|
||||
done"
|
||||
|
||||
11
drizzle/0004_sweet_gravity.sql
Normal file
11
drizzle/0004_sweet_gravity.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `push_subscriptions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`endpoint` text NOT NULL,
|
||||
`p256dh` text NOT NULL,
|
||||
`auth` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `push_subscriptions_endpoint_unique` ON `push_subscriptions` (`endpoint`);
|
||||
1
drizzle/0005_previous_junta.sql
Normal file
1
drizzle/0005_previous_junta.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` ADD `reminder_always` integer DEFAULT 0 NOT NULL;
|
||||
433
drizzle/meta/0004_snapshot.json
Normal file
433
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,433 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ad5a452a-05fe-4e4e-b2e7-b14058c0370c",
|
||||
"prevId": "ef0ed770-bbc5-44b8-8842-fb51ffcde604",
|
||||
"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": {}
|
||||
},
|
||||
"push_subscriptions": {
|
||||
"name": "push_subscriptions",
|
||||
"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
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"p256dh": {
|
||||
"name": "p256dh",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth": {
|
||||
"name": "auth",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"push_subscriptions_endpoint_unique": {
|
||||
"name": "push_subscriptions_endpoint_unique",
|
||||
"columns": [
|
||||
"endpoint"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"push_subscriptions_user_id_users_id_fk": {
|
||||
"name": "push_subscriptions_user_id_users_id_fk",
|
||||
"tableFrom": "push_subscriptions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"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_provider": {
|
||||
"name": "llm_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"llm_api_key": {
|
||||
"name": "llm_api_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"llm_model": {
|
||||
"name": "llm_model",
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
441
drizzle/meta/0005_snapshot.json
Normal file
441
drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,441 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "783b3d85-d1bd-470a-943b-469f6177c228",
|
||||
"prevId": "ad5a452a-05fe-4e4e-b2e7-b14058c0370c",
|
||||
"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": {}
|
||||
},
|
||||
"push_subscriptions": {
|
||||
"name": "push_subscriptions",
|
||||
"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
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"p256dh": {
|
||||
"name": "p256dh",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth": {
|
||||
"name": "auth",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"push_subscriptions_endpoint_unique": {
|
||||
"name": "push_subscriptions_endpoint_unique",
|
||||
"columns": [
|
||||
"endpoint"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"push_subscriptions_user_id_users_id_fk": {
|
||||
"name": "push_subscriptions_user_id_users_id_fk",
|
||||
"tableFrom": "push_subscriptions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"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_always": {
|
||||
"name": "reminder_always",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"reminder_time": {
|
||||
"name": "reminder_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"llm_provider": {
|
||||
"name": "llm_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"llm_api_key": {
|
||||
"name": "llm_api_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"llm_model": {
|
||||
"name": "llm_model",
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,20 @@
|
||||
"when": 1769257297672,
|
||||
"tag": "0003_blue_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1769308162051,
|
||||
"tag": "0004_sweet_gravity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1769310687405,
|
||||
"tag": "0005_previous_junta",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1142
package-lock.json
generated
1142
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,9 @@
|
||||
"next": "16.1.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"uuid": "^13.0.0"
|
||||
"sqlite3": "^5.1.7",
|
||||
"uuid": "^13.0.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -29,6 +31,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.3",
|
||||
|
||||
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
30
public/manifest.json
Normal file
30
public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Quiet Thanks",
|
||||
"short_name": "Quiet Thanks",
|
||||
"description": "A calm, private gratitude and mood log",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": ["lifestyle", "health"],
|
||||
"id": "quiet-thanks-pwa"
|
||||
}
|
||||
94
public/sw.js
Normal file
94
public/sw.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Service Worker for Quiet Thanks PWA
|
||||
const CACHE_NAME = 'quiet-thanks-v1';
|
||||
|
||||
// Install event - cache essential assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
event.waitUntil(clients.claim());
|
||||
});
|
||||
|
||||
// Push event - handle incoming push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push event received');
|
||||
|
||||
let data = {
|
||||
title: 'Quiet Thanks',
|
||||
body: 'Take a moment to reflect on what you\'re grateful for today.',
|
||||
icon: '/icons/icon.svg',
|
||||
badge: '/icons/icon.svg',
|
||||
tag: 'daily-reminder',
|
||||
};
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
const payload = event.data.json();
|
||||
data = { ...data, ...payload };
|
||||
} catch (e) {
|
||||
data.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: data.icon,
|
||||
badge: data.badge,
|
||||
tag: data.tag,
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: '/',
|
||||
},
|
||||
actions: [
|
||||
{ action: 'open', title: 'Open app' },
|
||||
{ action: 'dismiss', title: 'Dismiss' },
|
||||
],
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[SW] Notification clicked');
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'dismiss') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Focus existing window if available
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.registration.scope) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open new window
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Periodic sync for background reminders (when supported)
|
||||
self.addEventListener('periodicsync', (event) => {
|
||||
if (event.tag === 'daily-reminder') {
|
||||
event.waitUntil(checkAndShowReminder());
|
||||
}
|
||||
});
|
||||
|
||||
async function checkAndShowReminder() {
|
||||
// This would check the reminder time and show notification if appropriate
|
||||
// For now, we rely on the client-side scheduler
|
||||
console.log('[SW] Periodic sync triggered');
|
||||
}
|
||||
26
scripts/generate-icons.mjs
Normal file
26
scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import sharp from "sharp";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const publicDir = join(__dirname, "..", "public", "icons");
|
||||
|
||||
const svgPath = join(publicDir, "icon.svg");
|
||||
const svgContent = readFileSync(svgPath, "utf-8");
|
||||
|
||||
async function generateIcons() {
|
||||
const sizes = [192, 512];
|
||||
|
||||
for (const size of sizes) {
|
||||
const outputPath = join(publicDir, `icon-${size}.png`);
|
||||
await sharp(Buffer.from(svgContent))
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
console.log(`Generated ${outputPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
generateIcons().catch(console.error);
|
||||
92
src/app/api/notifications/send/route.ts
Normal file
92
src/app/api/notifications/send/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { sendPushNotification } from "@/lib/push";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
|
||||
// Format: HH:MM
|
||||
const timeString = `${currentHour.toString().padStart(2, "0")}:${currentMinute.toString().padStart(2, "0")}`;
|
||||
|
||||
console.log(`[Scheduler] Checking for reminders at ${timeString} (${now.toISOString()})`);
|
||||
|
||||
// Find users who have reminders enabled and time matches
|
||||
const users = await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(and(eq(schema.users.reminderEnabled, 1), eq(schema.users.reminderTime, timeString)));
|
||||
|
||||
console.log(`[Scheduler] Found ${users.length} users with reminder time ${timeString}`);
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const user of users) {
|
||||
console.log(`[Scheduler] Processing user ${user.id} (Always: ${user.reminderAlways})`);
|
||||
|
||||
// Check if user has already made an entry today, unless they want reminders always
|
||||
if (user.reminderAlways !== 1) {
|
||||
const today = now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
// Ensure we use the user's local date if possible, but for now assuming server time matches user expectation
|
||||
// since we enforced TZ=Australia/Perth
|
||||
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(schema.entries)
|
||||
.where(and(eq(schema.entries.userId, user.id), eq(schema.entries.date, today)))
|
||||
.limit(1);
|
||||
|
||||
if (entries.length > 0) {
|
||||
console.log(`[Scheduler] User ${user.id} already has an entry for ${today}, skipping`);
|
||||
continue; // Already journaled today
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscriptions
|
||||
const subscriptions = await db
|
||||
.select()
|
||||
.from(schema.pushSubscriptions)
|
||||
.where(eq(schema.pushSubscriptions.userId, user.id));
|
||||
|
||||
console.log(`[Scheduler] User ${user.id} has ${subscriptions.length} subscriptions`);
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
console.log(`[Scheduler] Sending push to ${sub.endpoint.substring(0, 20)}...`);
|
||||
const success = await sendPushNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
{
|
||||
title: "Time to reflect",
|
||||
body: "Take a moment to record what you're grateful for today.",
|
||||
url: "/new",
|
||||
}
|
||||
);
|
||||
|
||||
if (success) {
|
||||
sent++;
|
||||
} else {
|
||||
// Expired
|
||||
await db.delete(schema.pushSubscriptions).where(eq(schema.pushSubscriptions.id, sub.id));
|
||||
failed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Push error:", error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ sent, failed });
|
||||
} catch (error) {
|
||||
console.error("Scheduler error:", error);
|
||||
return NextResponse.json({ error: "Scheduler failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
71
src/app/api/notifications/subscribe/route.ts
Normal file
71
src/app/api/notifications/subscribe/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
|
||||
export async function GET() {
|
||||
if (!VAPID_PUBLIC_KEY) {
|
||||
return NextResponse.json({ error: "Push notifications not configured" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ publicKey: VAPID_PUBLIC_KEY });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { subscription } = await request.json();
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return NextResponse.json({ error: "Invalid subscription" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.pushSubscriptions)
|
||||
.where(eq(schema.pushSubscriptions.endpoint, subscription.endpoint))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(schema.pushSubscriptions).values({
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Subscription error:", error);
|
||||
return NextResponse.json({ error: "Failed to subscribe" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const endpoint = searchParams.get("endpoint");
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ error: "Endpoint required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(schema.pushSubscriptions)
|
||||
.where(eq(schema.pushSubscriptions.endpoint, endpoint));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export async function GET() {
|
||||
.select({
|
||||
reminderEnabled: schema.users.reminderEnabled,
|
||||
reminderTime: schema.users.reminderTime,
|
||||
reminderAlways: schema.users.reminderAlways,
|
||||
llmProvider: schema.users.llmProvider,
|
||||
llmApiKey: schema.users.llmApiKey,
|
||||
llmModel: schema.users.llmModel,
|
||||
@@ -30,6 +31,7 @@ export async function GET() {
|
||||
return NextResponse.json({
|
||||
reminderEnabled: users[0].reminderEnabled === 1,
|
||||
reminderTime: users[0].reminderTime || "20:00",
|
||||
reminderAlways: users[0].reminderAlways === 1,
|
||||
llmProvider: users[0].llmProvider || null,
|
||||
hasLlmKey: Boolean(users[0].llmApiKey),
|
||||
llmModel: users[0].llmModel || null,
|
||||
@@ -55,6 +57,10 @@ export async function PATCH(request: NextRequest) {
|
||||
updates.reminderEnabled = body.reminderEnabled ? 1 : 0;
|
||||
}
|
||||
|
||||
if (typeof body.reminderAlways === "boolean") {
|
||||
updates.reminderAlways = body.reminderAlways ? 1 : 0;
|
||||
}
|
||||
|
||||
if (typeof body.reminderTime === "string") {
|
||||
updates.reminderTime = body.reminderTime;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,20 @@ import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
import { ServiceWorkerProvider } from "@/components/ServiceWorkerProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: APP_NAME,
|
||||
description: "A calm, private gratitude and mood log",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/icons/icon.svg",
|
||||
apple: "/icons/icon.svg",
|
||||
apple: "/icons/icon-192.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: APP_NAME,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,12 +34,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="antialiased min-h-screen">
|
||||
<ServiceWorkerProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useServiceWorker } from "@/components/ServiceWorkerProvider";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -10,6 +11,7 @@ import Link from "next/link";
|
||||
interface UserSettings {
|
||||
reminderEnabled: boolean;
|
||||
reminderTime: string;
|
||||
reminderAlways: boolean;
|
||||
llmProvider: string | null;
|
||||
hasLlmKey: boolean;
|
||||
llmModel: string | null;
|
||||
@@ -29,11 +31,13 @@ const PROVIDERS = [
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { isSupported: swSupported, isRegistered: swRegistered, showNotification, subscribeToPush } = useServiceWorker();
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showLlmSetup, setShowLlmSetup] = useState(false);
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
// LLM setup state
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("");
|
||||
@@ -68,8 +72,20 @@ export default function SettingsPage() {
|
||||
if ("Notification" in window) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
|
||||
// Detect if running as PWA (standalone mode)
|
||||
const isStandalone = window.matchMedia("(display-mode: standalone)").matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
setIsPWA(isStandalone);
|
||||
}, []);
|
||||
|
||||
// Ensure push subscription is active if permissions are granted
|
||||
useEffect(() => {
|
||||
if (notificationPermission === "granted" && swRegistered) {
|
||||
subscribeToPush().catch(console.error);
|
||||
}
|
||||
}, [notificationPermission, swRegistered, subscribeToPush]);
|
||||
|
||||
const updateSetting = async (key: string, value: unknown) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
@@ -94,6 +110,14 @@ export default function SettingsPage() {
|
||||
setNotificationPermission(permission);
|
||||
if (permission === "granted") {
|
||||
updateSetting("reminderEnabled", true);
|
||||
const subscribed = await subscribeToPush();
|
||||
if (subscribed) {
|
||||
showNotification(APP_NAME, {
|
||||
body: "Notifications enabled! You will receive daily reminders.",
|
||||
icon: "/icons/icon.svg",
|
||||
tag: "welcome-notification",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -204,27 +228,35 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Reminder check interval
|
||||
// Reminder check interval - uses service worker for iOS compatibility
|
||||
useEffect(() => {
|
||||
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||
|
||||
const checkReminder = () => {
|
||||
// Track if we've shown the notification this minute
|
||||
let lastShownMinute = -1;
|
||||
|
||||
const checkReminder = async () => {
|
||||
const now = new Date();
|
||||
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
if (now.getHours() === hours && now.getMinutes() === minutes) {
|
||||
if (now.getHours() === hours && now.getMinutes() === minutes && currentMinute !== lastShownMinute) {
|
||||
lastShownMinute = currentMinute;
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification(APP_NAME, {
|
||||
await showNotification(APP_NAME, {
|
||||
body: "Take a moment to reflect on what you're grateful for today.",
|
||||
icon: "/icon.png",
|
||||
icon: "/icons/icon.svg",
|
||||
tag: "daily-reminder",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately and then every minute
|
||||
checkReminder();
|
||||
const interval = setInterval(checkReminder, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [settings?.reminderEnabled, settings?.reminderTime]);
|
||||
}, [settings?.reminderEnabled, settings?.reminderTime, showNotification]);
|
||||
|
||||
const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id;
|
||||
|
||||
@@ -292,7 +324,8 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
{settings.reminderEnabled && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-muted">Remind me at:</label>
|
||||
<input
|
||||
type="time"
|
||||
@@ -301,10 +334,51 @@ export default function SettingsPage() {
|
||||
className="px-3 py-1 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="reminderAlways"
|
||||
checked={settings.reminderAlways}
|
||||
onChange={(e) => updateSetting("reminderAlways", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
<label htmlFor="reminderAlways" className="text-sm text-muted cursor-pointer">
|
||||
Always remind me, even if I've already journaled today
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{settings.reminderEnabled && notificationPermission === "granted" && swRegistered && (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-green-500">
|
||||
<Check size={14} />
|
||||
<span>Notifications ready{isPWA ? " (PWA mode)" : ""}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => showNotification(APP_NAME, {
|
||||
body: "Test notification - reminders are working!",
|
||||
icon: "/icons/icon.svg",
|
||||
tag: "test-notification",
|
||||
})}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{settings.reminderEnabled && notificationPermission === "granted" && !swRegistered && (
|
||||
<p className="mt-2 text-sm text-amber-400">
|
||||
Service worker loading... Notifications may be limited.
|
||||
</p>
|
||||
)}
|
||||
{notificationPermission === "denied" && (
|
||||
<p className="mt-2 text-sm text-red-400">
|
||||
Notifications are blocked. Please enable them in your browser settings.
|
||||
Notifications are blocked. Please enable them in your browser/device settings.
|
||||
</p>
|
||||
)}
|
||||
{settings.reminderEnabled && notificationPermission === "default" && (
|
||||
<p className="mt-2 text-sm text-amber-400">
|
||||
Notification permission not yet granted. Toggle reminders off and on to request permission.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { WeeklyReflection } from "@/components/WeeklyReflection";
|
||||
import { FilterPanel } from "@/components/FilterPanel";
|
||||
import { EntryRow } from "@/components/EntryRow";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CalendarView } from "@/components/CalendarView";
|
||||
import { Loader2, Search, Calendar, List, X } from "lucide-react";
|
||||
import type { EntryWithTags, FilterState } from "@/lib/types";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
|
||||
type ViewMode = "list" | "calendar";
|
||||
|
||||
export default function TimelinePage() {
|
||||
const [entries, setEntries] = useState<EntryWithTags[]>([]);
|
||||
const [filteredEntries, setFilteredEntries] = useState<EntryWithTags[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
moods: [],
|
||||
tagId: null,
|
||||
@@ -35,18 +42,41 @@ export default function TimelinePage() {
|
||||
loadEntries();
|
||||
}, []);
|
||||
|
||||
// Apply filters client-side
|
||||
// Get all dates that have entries
|
||||
const entryDates = useMemo(() => {
|
||||
return new Set(entries.map((e) => e.date));
|
||||
}, [entries]);
|
||||
|
||||
// Apply filters and search client-side
|
||||
useEffect(() => {
|
||||
let result = entries;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(e) =>
|
||||
e.text.toLowerCase().includes(query) ||
|
||||
e.tags.some((t) => t.name.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (selectedDate) {
|
||||
result = result.filter((e) => e.date === selectedDate);
|
||||
}
|
||||
|
||||
// Mood filter
|
||||
if (filters.moods.length > 0) {
|
||||
result = result.filter((e) => e.mood && filters.moods.includes(e.mood));
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (filters.tagId) {
|
||||
result = result.filter((e) => e.tags.some((t) => t.id === filters.tagId));
|
||||
}
|
||||
|
||||
// Rough day filter
|
||||
if (filters.roughDay === true) {
|
||||
result = result.filter((e) => e.roughDay);
|
||||
} else if (filters.roughDay === false) {
|
||||
@@ -54,7 +84,20 @@ export default function TimelinePage() {
|
||||
}
|
||||
|
||||
setFilteredEntries(result);
|
||||
}, [entries, filters]);
|
||||
}, [entries, filters, searchQuery, selectedDate]);
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchQuery("");
|
||||
setSelectedDate(null);
|
||||
setFilters({ moods: [], tagId: null, roughDay: null });
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim() ||
|
||||
selectedDate ||
|
||||
filters.moods.length > 0 ||
|
||||
filters.tagId ||
|
||||
filters.roughDay !== null;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -70,20 +113,133 @@ export default function TimelinePage() {
|
||||
) : (
|
||||
<>
|
||||
<WeeklyReflection entries={entries} />
|
||||
|
||||
{/* Search and View Toggle */}
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search entries..."
|
||||
className="w-full pl-10 pr-10 py-2 bg-surface border border-border rounded-xl text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle and clear filters */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||
viewMode === "list"
|
||||
? "bg-accent text-white"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<List size={16} />
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("calendar")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||
viewMode === "calendar"
|
||||
? "bg-accent text-white"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar size={16} />
|
||||
Calendar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar View */}
|
||||
{viewMode === "calendar" && (
|
||||
<div className="mb-4">
|
||||
<CalendarView
|
||||
entryDates={entryDates}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={setSelectedDate}
|
||||
currentMonth={currentMonth}
|
||||
onChangeMonth={setCurrentMonth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filter indicator */}
|
||||
{selectedDate && (
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
<span className="text-muted">Showing entries for:</span>
|
||||
<span className="px-2 py-1 bg-accent/20 text-accent rounded">
|
||||
{new Date(selectedDate + "T12:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedDate(null)}
|
||||
className="text-muted hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterPanel filters={filters} onChange={setFilters} />
|
||||
|
||||
{/* Results count */}
|
||||
{hasActiveFilters && (
|
||||
<p className="mb-3 text-sm text-muted">
|
||||
{filteredEntries.length} {filteredEntries.length === 1 ? "entry" : "entries"} found
|
||||
</p>
|
||||
)}
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted">
|
||||
{entries.length === 0 ? (
|
||||
<p>No entries yet. Start your first check-in!</p>
|
||||
) : (
|
||||
<div>
|
||||
<p>No entries match your filters.</p>
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="mt-2 text-accent hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredEntries.map((entry) => (
|
||||
<EntryRow key={entry.id} entry={entry} />
|
||||
<EntryRow key={entry.id} entry={entry} searchQuery={searchQuery} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
155
src/components/CalendarView.tsx
Normal file
155
src/components/CalendarView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface CalendarViewProps {
|
||||
entryDates: Set<string>; // YYYY-MM-DD format
|
||||
selectedDate: string | null;
|
||||
onSelectDate: (date: string | null) => void;
|
||||
currentMonth: Date;
|
||||
onChangeMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
export function CalendarView({
|
||||
entryDates,
|
||||
selectedDate,
|
||||
onSelectDate,
|
||||
currentMonth,
|
||||
onChangeMonth,
|
||||
}: CalendarViewProps) {
|
||||
const { days, monthLabel } = useMemo(() => {
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
|
||||
// First day of month
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const startDayOfWeek = firstDay.getDay();
|
||||
|
||||
// Days in month
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
// Build calendar grid
|
||||
const days: { date: string; day: number; isCurrentMonth: boolean; hasEntry: boolean }[] = [];
|
||||
|
||||
// Previous month padding
|
||||
const prevMonth = new Date(year, month, 0);
|
||||
const prevMonthDays = prevMonth.getDate();
|
||||
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||
const day = prevMonthDays - i;
|
||||
const date = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) });
|
||||
}
|
||||
|
||||
// Current month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
days.push({ date, day, isCurrentMonth: true, hasEntry: entryDates.has(date) });
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const remaining = 42 - days.length; // 6 rows * 7 days
|
||||
for (let day = 1; day <= remaining; day++) {
|
||||
const nextMonth = new Date(year, month + 2, 0);
|
||||
const date = `${nextMonth.getFullYear()}-${String(month + 2).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) });
|
||||
}
|
||||
|
||||
const monthLabel = firstDay.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||
|
||||
return { days, monthLabel };
|
||||
}, [currentMonth, entryDates]);
|
||||
|
||||
const goToPrevMonth = () => {
|
||||
onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
onChangeMonth(new Date());
|
||||
onSelectDate(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={goToPrevMonth}
|
||||
className="p-1 text-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{monthLabel}</span>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={goToNextMonth}
|
||||
className="p-1 text-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
||||
<div key={day} className="text-center text-xs text-muted py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map(({ date, day, isCurrentMonth, hasEntry }) => {
|
||||
const isSelected = selectedDate === date;
|
||||
const isToday = date === new Date().toISOString().split("T")[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
onClick={() => onSelectDate(isSelected ? null : date)}
|
||||
className={`
|
||||
relative aspect-square flex items-center justify-center text-sm rounded-lg transition-colors
|
||||
${!isCurrentMonth ? "text-muted/30" : ""}
|
||||
${isSelected ? "bg-accent text-white" : "hover:bg-border"}
|
||||
${isToday && !isSelected ? "ring-1 ring-accent" : ""}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
{hasEntry && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-accent rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-muted">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-accent rounded-full" />
|
||||
<span>Has entry</span>
|
||||
</div>
|
||||
{selectedDate && (
|
||||
<button
|
||||
onClick={() => onSelectDate(null)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,45 +99,22 @@ export function EntryForm({ date, entryId, isNewEntry = false, onSaved }: EntryF
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save buttons */}
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
{isNewEntry ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSaveAndNew}
|
||||
onClick={isNewEntry ? handleSaveAndNew : 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} />
|
||||
) : (
|
||||
) : isNewEntry ? (
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<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
|
||||
{isNewEntry ? "Save & New" : "Save"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save status */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted ml-auto">
|
||||
|
||||
@@ -8,9 +8,27 @@ import type { EntryWithTags } from "@/lib/types";
|
||||
|
||||
interface EntryRowProps {
|
||||
entry: EntryWithTags;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function EntryRow({ entry }: EntryRowProps) {
|
||||
function highlightText(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-accent/30 text-foreground rounded px-0.5">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function EntryRow({ entry, searchQuery = "" }: EntryRowProps) {
|
||||
// Truncate text to one line preview
|
||||
const preview = entry.text.length > 80 ? entry.text.slice(0, 80) + "..." : entry.text;
|
||||
|
||||
@@ -31,10 +49,12 @@ export function EntryRow({ entry }: EntryRowProps) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-foreground truncate">{preview}</p>
|
||||
<p className="text-foreground truncate">
|
||||
{highlightText(preview, searchQuery)}
|
||||
</p>
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<TagChips tags={entry.tags} />
|
||||
<TagChips tags={entry.tags} highlightQuery={searchQuery} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
75
src/components/ReminderScheduler.tsx
Normal file
75
src/components/ReminderScheduler.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useServiceWorker } from "./ServiceWorkerProvider";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
|
||||
export function ReminderScheduler() {
|
||||
const { showNotification } = useServiceWorker();
|
||||
const { user } = useAuth();
|
||||
const lastShownMinuteRef = useRef<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
let settings: { reminderEnabled: boolean; reminderTime: string } | null = null;
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
settings = {
|
||||
reminderEnabled: data.reminderEnabled,
|
||||
reminderTime: data.reminderTime,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function checkReminder() {
|
||||
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||
|
||||
const now = new Date();
|
||||
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
const targetMinute = hours * 60 + minutes;
|
||||
|
||||
if (currentMinute === targetMinute && currentMinute !== lastShownMinuteRef.current) {
|
||||
lastShownMinuteRef.current = currentMinute;
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
console.log("[Reminder] Showing notification at", now.toLocaleTimeString());
|
||||
await showNotification(APP_NAME, {
|
||||
body: "Take a moment to reflect on what you're grateful for today.",
|
||||
icon: "/icons/icon-192.png",
|
||||
tag: "daily-reminder",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
fetchSettings();
|
||||
|
||||
// Refresh settings periodically (in case user changes them)
|
||||
const settingsInterval = setInterval(fetchSettings, 60000);
|
||||
|
||||
// Check reminder every 30 seconds for more reliable timing
|
||||
const reminderInterval = setInterval(checkReminder, 30000);
|
||||
|
||||
// Also check immediately after settings load
|
||||
const initialCheck = setTimeout(checkReminder, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(settingsInterval);
|
||||
clearInterval(reminderInterval);
|
||||
clearTimeout(initialCheck);
|
||||
};
|
||||
}, [user, showNotification]);
|
||||
|
||||
return null;
|
||||
}
|
||||
148
src/components/ServiceWorkerProvider.tsx
Normal file
148
src/components/ServiceWorkerProvider.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||
|
||||
interface ServiceWorkerContextValue {
|
||||
isSupported: boolean;
|
||||
isRegistered: boolean;
|
||||
registration: ServiceWorkerRegistration | null;
|
||||
showNotification: (title: string, options?: NotificationOptions) => Promise<void>;
|
||||
subscribeToPush: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ServiceWorkerContext = createContext<ServiceWorkerContextValue>({
|
||||
isSupported: false,
|
||||
isRegistered: false,
|
||||
registration: null,
|
||||
showNotification: async () => {},
|
||||
subscribeToPush: async () => false,
|
||||
});
|
||||
|
||||
export function useServiceWorker() {
|
||||
return useContext(ServiceWorkerContext);
|
||||
}
|
||||
|
||||
// Helper for VAPID key conversion
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
interface ServiceWorkerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ServiceWorkerProvider({ children }: ServiceWorkerProviderProps) {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isRegistered, setIsRegistered] = useState(false);
|
||||
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if service workers are supported
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
console.log("[SW Provider] Service workers not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSupported(true);
|
||||
|
||||
// Register the service worker
|
||||
async function registerServiceWorker() {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register("/sw.js", {
|
||||
scope: "/",
|
||||
});
|
||||
console.log("[SW Provider] Service worker registered:", reg.scope);
|
||||
setRegistration(reg);
|
||||
setIsRegistered(true);
|
||||
|
||||
// Check for updates
|
||||
reg.addEventListener("updatefound", () => {
|
||||
console.log("[SW Provider] Service worker update found");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[SW Provider] Service worker registration failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
registerServiceWorker();
|
||||
}, []);
|
||||
|
||||
const subscribeToPush = useCallback(async () => {
|
||||
try {
|
||||
// Wait for service worker to be ready if registration isn't set yet
|
||||
const reg = registration || await navigator.serviceWorker.ready;
|
||||
if (!reg || !reg.pushManager) {
|
||||
console.error("Push manager not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get public key
|
||||
const response = await fetch("/api/notifications/subscribe");
|
||||
if (!response.ok) return false;
|
||||
const { publicKey } = await response.json();
|
||||
|
||||
const subscription = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
|
||||
// Send to server
|
||||
const saveResponse = await fetch("/api/notifications/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subscription }),
|
||||
});
|
||||
|
||||
console.log("Push subscription saved:", saveResponse.ok);
|
||||
return saveResponse.ok;
|
||||
} catch (error) {
|
||||
console.error("Push subscription failed:", error);
|
||||
return false;
|
||||
}
|
||||
}, [registration]);
|
||||
|
||||
const showNotification = useCallback(
|
||||
async (title: string, options?: NotificationOptions) => {
|
||||
if (!registration) {
|
||||
console.log("[SW Provider] No registration, falling back to Notification API");
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification(title, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await registration.showNotification(title, options);
|
||||
console.log("[SW Provider] Notification shown via service worker");
|
||||
} catch (error) {
|
||||
console.error("[SW Provider] Failed to show notification:", error);
|
||||
// Fallback to regular Notification API
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
new Notification(title, options);
|
||||
}
|
||||
}
|
||||
},
|
||||
[registration]
|
||||
);
|
||||
|
||||
return (
|
||||
<ServiceWorkerContext.Provider
|
||||
value={{
|
||||
isSupported,
|
||||
isRegistered,
|
||||
registration,
|
||||
showNotification,
|
||||
subscribeToPush,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ServiceWorkerContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -120,9 +120,27 @@ export function TagInput({ selectedTags, onAddTag, onRemoveTag }: TagInputProps)
|
||||
interface TagChipsProps {
|
||||
tags: { id: string; name: string }[];
|
||||
maxDisplay?: number;
|
||||
highlightQuery?: string;
|
||||
}
|
||||
|
||||
export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) {
|
||||
function highlightTagText(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-accent/40 text-foreground rounded">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function TagChips({ tags, maxDisplay = 2, highlightQuery = "" }: TagChipsProps) {
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
const displayed = tags.slice(0, maxDisplay);
|
||||
@@ -135,7 +153,7 @@ export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) {
|
||||
key={tag.id}
|
||||
className="px-2 py-0.5 bg-surface text-muted text-xs rounded"
|
||||
>
|
||||
{tag.name}
|
||||
{highlightTagText(tag.name, highlightQuery)}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ export const users = sqliteTable("users", {
|
||||
name: text("name"),
|
||||
isAdmin: integer("is_admin").notNull().default(0), // 1 = admin
|
||||
reminderEnabled: integer("reminder_enabled").notNull().default(0),
|
||||
reminderAlways: integer("reminder_always").notNull().default(0), // 1 = always send, 0 = only if no entry
|
||||
reminderTime: text("reminder_time"), // HH:MM format
|
||||
llmProvider: text("llm_provider"), // anthropic, openai, openrouter
|
||||
llmApiKey: text("llm_api_key"), // API key for LLM
|
||||
@@ -58,6 +59,17 @@ export const entryTags = sqliteTable(
|
||||
(table) => [primaryKey({ columns: [table.entryId, table.tagId] })]
|
||||
);
|
||||
|
||||
export const pushSubscriptions = sqliteTable("push_subscriptions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
endpoint: text("endpoint").notNull().unique(),
|
||||
p256dh: text("p256dh").notNull(),
|
||||
auth: text("auth").notNull(),
|
||||
createdAt: integer("created_at").notNull(), // Unix ms
|
||||
});
|
||||
|
||||
// Types
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
39
src/lib/push.ts
Normal file
39
src/lib/push.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import webpush from "web-push";
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || "";
|
||||
const VAPID_EMAIL = process.env.VAPID_EMAIL || "mailto:admin@quietthanks.local";
|
||||
|
||||
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
|
||||
webpush.setVapidDetails(VAPID_EMAIL, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
subscription: { endpoint: string; p256dh: string; auth: string },
|
||||
payload: { title: string; body: string; url?: string }
|
||||
) {
|
||||
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
|
||||
console.warn("VAPID keys not configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
return false; // Expired
|
||||
}
|
||||
console.error("Push error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user