mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-06-10 13:45:55 +08:00
Initial commit: Quiet Thanks gratitude app
A calm, private gratitude and mood log built with Next.js 16, TypeScript, Tailwind CSS, and SQLite/Drizzle ORM. Features: - Quick check-in with autosave (800ms debounce) - Optional mood selector (5 levels) with accessibility labels - Optional tags with tap-to-add from recent - Timeline with weekly reflection card - Filters by mood, tag, and rough day - Export to Markdown and JSON - Dark mode default - Delete with undo toast - Docker deployment ready Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
data/*.db
|
||||||
|
data/*.db-wal
|
||||||
|
data/*.db-shm
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# database
|
||||||
|
data/*.db
|
||||||
|
data/*.db-wal
|
||||||
|
data/*.db-shm
|
||||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for native modules (better-sqlite3)
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# Install dependencies (skip postinstall as source isn't there yet)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# Rebuild better-sqlite3 native bindings
|
||||||
|
RUN npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate database migrations and run them
|
||||||
|
RUN npx drizzle-kit generate || true
|
||||||
|
RUN npm run db:migrate || true
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DATABASE_PATH=/app/data/quietthanks.db
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
|
COPY --from=builder /app/src/lib/db/migrate.ts ./src/lib/db/migrate.ts
|
||||||
|
COPY --from=builder /app/src/lib/db/schema.ts ./src/lib/db/schema.ts
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
131
README.md
Normal file
131
README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Quiet Thanks
|
||||||
|
|
||||||
|
A calm, private gratitude and mood log. No streaks, no gamification—just a simple way to reflect on what you're grateful for.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Quick check-in**: One prompt, optional mood, optional tags. Entry capture takes 10-20 seconds.
|
||||||
|
- **Autosave**: No save button needed. Your entry saves automatically as you type.
|
||||||
|
- **Timeline**: View all entries in reverse chronological order with filters.
|
||||||
|
- **Weekly reflection**: See your entry count and top tags from the past 7 days.
|
||||||
|
- **Export**: Download all entries as Markdown or JSON.
|
||||||
|
- **Dark mode**: Calm, minimal interface with soft colors.
|
||||||
|
- **Self-hosted**: Your data stays on your server.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at `http://localhost:6124`.
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Generate and run database migrations:
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open `http://localhost:3000` in your browser.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generate database migrations after schema changes
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# Start dev server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# View database with Drizzle Studio
|
||||||
|
npm run db:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- **Location**: `./data/quietthanks.db` (SQLite)
|
||||||
|
- **Environment variable**: `DATABASE_PATH` to customize location
|
||||||
|
- **Migrations**: Stored in `./drizzle/`
|
||||||
|
|
||||||
|
The database is created automatically when you run migrations. In Docker, the `./data` directory is mounted as a volume to persist data.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
- **entries**: Main gratitude entries with date, text, optional mood (1-5), rough day flag, and timestamps
|
||||||
|
- **tags**: Normalized tag names
|
||||||
|
- **entry_tags**: Junction table linking entries to tags
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
Navigate to `/export` or use the Export tab to download your data:
|
||||||
|
|
||||||
|
- **Markdown**: Human-readable format, grouped by date, includes mood and tags
|
||||||
|
- **JSON**: Full data export with all fields and timestamps
|
||||||
|
|
||||||
|
Exports include all entries regardless of filters.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### App Name
|
||||||
|
|
||||||
|
Change the app name by editing `src/lib/constants.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const APP_NAME = "My Gratitude Log";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port
|
||||||
|
|
||||||
|
In `docker-compose.yml`, change the port mapping:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8080:3000" # Change 8080 to your desired port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 16 with App Router
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Styling**: Tailwind CSS 4
|
||||||
|
- **Database**: SQLite with Drizzle ORM
|
||||||
|
- **Icons**: Lucide React
|
||||||
|
|
||||||
|
## Future Extension Points
|
||||||
|
|
||||||
|
These features are not implemented but the architecture supports them:
|
||||||
|
|
||||||
|
- **Cloud sync**: Add authentication and a sync service to enable cross-device access
|
||||||
|
- **LLM summaries**: Integrate with an LLM API to generate monthly reflections
|
||||||
|
- **Notifications**: Add push notifications for daily reminders
|
||||||
|
- **Import**: Add an import endpoint to restore from JSON exports
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
quietthanks:
|
||||||
|
build: .
|
||||||
|
container_name: quietthanks
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6124:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATABASE_PATH=/app/data/quietthanks.db
|
||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/lib/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_PATH || "./data/quietthanks.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
25
drizzle/0000_busy_earthquake.sql
Normal file
25
drizzle/0000_busy_earthquake.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE `entries` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`date` text NOT NULL,
|
||||||
|
`text` text NOT NULL,
|
||||||
|
`mood` integer,
|
||||||
|
`rough_day` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `entry_tags` (
|
||||||
|
`entry_id` text NOT NULL,
|
||||||
|
`tag_id` text NOT NULL,
|
||||||
|
PRIMARY KEY(`entry_id`, `tag_id`),
|
||||||
|
FOREIGN KEY (`entry_id`) REFERENCES `entries`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `tags` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);
|
||||||
176
drizzle/meta/0000_snapshot.json
Normal file
176
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "5116dd80-0292-4345-a403-fd678d148880",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"entries": {
|
||||||
|
"name": "entries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"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": {},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"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": {
|
||||||
|
"tags_name_unique": {
|
||||||
|
"name": "tags_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769219562573,
|
||||||
|
"tag": "0000_busy_earthquake",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
8644
package-lock.json
generated
Normal file
8644
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "quietthanks",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/lib/db/migrate.ts",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"next": "16.1.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.3",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
5
public/icons/icon.svg
Normal file
5
public/icons/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" rx="20" fill="#0a0a0a"/>
|
||||||
|
<path d="M50 25 L60 45 L80 48 L65 63 L68 83 L50 73 L32 83 L35 63 L20 48 L40 45 Z"
|
||||||
|
fill="none" stroke="#6366f1" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 292 B |
155
src/app/api/entries/[id]/route.ts
Normal file
155
src/app/api/entries/[id]/route.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { UpdateEntryRequest, EntryWithTags } from "@/lib/types";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/entries/[id] - Get single entry
|
||||||
|
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(eq(schema.entries.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (entry.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Entry not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryTagRows = await db
|
||||||
|
.select({ tag: schema.tags })
|
||||||
|
.from(schema.entryTags)
|
||||||
|
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
|
||||||
|
.where(eq(schema.entryTags.entryId, id));
|
||||||
|
|
||||||
|
const entryWithTags: EntryWithTags = {
|
||||||
|
...entry[0],
|
||||||
|
tags: entryTagRows.map((row) => row.tag),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(entryWithTags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch entry:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch entry" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/entries/[id] - Update entry
|
||||||
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: UpdateEntryRequest = await request.json();
|
||||||
|
const { text, mood, roughDay, tagNames } = body;
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(eq(schema.entries.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Entry not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Update entry fields
|
||||||
|
const updates: Partial<typeof schema.entries.$inferInsert> = {
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
if (text !== undefined) updates.text = text;
|
||||||
|
if (mood !== undefined) updates.mood = mood;
|
||||||
|
if (roughDay !== undefined) updates.roughDay = roughDay ? 1 : 0;
|
||||||
|
|
||||||
|
await db.update(schema.entries).set(updates).where(eq(schema.entries.id, id));
|
||||||
|
|
||||||
|
// Update tags if provided
|
||||||
|
if (tagNames !== undefined) {
|
||||||
|
await db.delete(schema.entryTags).where(eq(schema.entryTags.entryId, id));
|
||||||
|
|
||||||
|
for (const name of tagNames) {
|
||||||
|
const normalizedName = name.toLowerCase().trim();
|
||||||
|
if (!normalizedName) continue;
|
||||||
|
|
||||||
|
let tag = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.tags)
|
||||||
|
.where(eq(schema.tags.name, normalizedName))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let tagId: string;
|
||||||
|
if (tag.length === 0) {
|
||||||
|
tagId = uuidv4();
|
||||||
|
await db.insert(schema.tags).values({
|
||||||
|
id: tagId,
|
||||||
|
name: normalizedName,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tagId = tag[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(schema.entryTags)
|
||||||
|
.values({ entryId: id, tagId })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated entry
|
||||||
|
const entry = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(eq(schema.entries.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const entryTagRows = await db
|
||||||
|
.select({ tag: schema.tags })
|
||||||
|
.from(schema.entryTags)
|
||||||
|
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
|
||||||
|
.where(eq(schema.entryTags.entryId, id));
|
||||||
|
|
||||||
|
const entryWithTags: EntryWithTags = {
|
||||||
|
...entry[0],
|
||||||
|
tags: entryTagRows.map((row) => row.tag),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(entryWithTags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update entry:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to update entry" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/entries/[id] - Delete entry
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(eq(schema.entries.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Entry not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete entry (cascades to entry_tags)
|
||||||
|
await db.delete(schema.entries).where(eq(schema.entries.id, id));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete entry:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to delete entry" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/app/api/entries/route.ts
Normal file
170
src/app/api/entries/route.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { eq, desc, and, inArray, gte } from "drizzle-orm";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import type { CreateEntryRequest, EntryWithTags } from "@/lib/types";
|
||||||
|
|
||||||
|
// GET /api/entries - List entries with optional filters
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const moods = searchParams.get("moods")?.split(",").map(Number).filter(Boolean);
|
||||||
|
const tagId = searchParams.get("tagId");
|
||||||
|
const roughDay = searchParams.get("roughDay");
|
||||||
|
const since = searchParams.get("since"); // YYYY-MM-DD
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build conditions
|
||||||
|
const conditions = [];
|
||||||
|
if (moods && moods.length > 0) {
|
||||||
|
conditions.push(inArray(schema.entries.mood, moods));
|
||||||
|
}
|
||||||
|
if (roughDay === "true") {
|
||||||
|
conditions.push(eq(schema.entries.roughDay, 1));
|
||||||
|
} else if (roughDay === "false") {
|
||||||
|
conditions.push(eq(schema.entries.roughDay, 0));
|
||||||
|
}
|
||||||
|
if (since) {
|
||||||
|
conditions.push(gte(schema.entries.date, since));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entries
|
||||||
|
const entries = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(schema.entries.date), desc(schema.entries.createdAt));
|
||||||
|
|
||||||
|
// Get tags for each entry
|
||||||
|
const entriesWithTags: EntryWithTags[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryTagRows = await db
|
||||||
|
.select({ tag: schema.tags })
|
||||||
|
.from(schema.entryTags)
|
||||||
|
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
|
||||||
|
.where(eq(schema.entryTags.entryId, entry.id));
|
||||||
|
|
||||||
|
const tags = entryTagRows.map((row) => row.tag);
|
||||||
|
|
||||||
|
// Filter by tagId if specified
|
||||||
|
if (tagId && !tags.some((t) => t.id === tagId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesWithTags.push({ ...entry, tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(entriesWithTags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch entries:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch entries" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/entries - Create or update today's entry
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body: CreateEntryRequest = await request.json();
|
||||||
|
const { 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
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(eq(schema.entries.date, date))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let entryId: string;
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing entry
|
||||||
|
entryId = existing[0].id;
|
||||||
|
await db
|
||||||
|
.update(schema.entries)
|
||||||
|
.set({
|
||||||
|
text,
|
||||||
|
mood: mood ?? null,
|
||||||
|
roughDay: roughDay ? 1 : 0,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(schema.entries.id, entryId));
|
||||||
|
} else {
|
||||||
|
// Create new entry
|
||||||
|
entryId = uuidv4();
|
||||||
|
await db.insert(schema.entries).values({
|
||||||
|
id: entryId,
|
||||||
|
date,
|
||||||
|
text,
|
||||||
|
mood: mood ?? null,
|
||||||
|
roughDay: roughDay ? 1 : 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tags
|
||||||
|
// Remove existing tag associations
|
||||||
|
await db.delete(schema.entryTags).where(eq(schema.entryTags.entryId, entryId));
|
||||||
|
|
||||||
|
// Add new tags
|
||||||
|
if (tagNames && tagNames.length > 0) {
|
||||||
|
for (const name of tagNames) {
|
||||||
|
const normalizedName = name.toLowerCase().trim();
|
||||||
|
if (!normalizedName) continue;
|
||||||
|
|
||||||
|
// Find or create tag
|
||||||
|
let tag = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.tags)
|
||||||
|
.where(eq(schema.tags.name, normalizedName))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let tagId: string;
|
||||||
|
if (tag.length === 0) {
|
||||||
|
tagId = uuidv4();
|
||||||
|
await db.insert(schema.tags).values({
|
||||||
|
id: tagId,
|
||||||
|
name: normalizedName,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tagId = tag[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create association
|
||||||
|
await db
|
||||||
|
.insert(schema.entryTags)
|
||||||
|
.values({ entryId, tagId })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and return the updated entry with tags
|
||||||
|
const entry = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.where(eq(schema.entries.id, entryId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const entryTagRows = await db
|
||||||
|
.select({ tag: schema.tags })
|
||||||
|
.from(schema.entryTags)
|
||||||
|
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
|
||||||
|
.where(eq(schema.entryTags.entryId, entryId));
|
||||||
|
|
||||||
|
const entryWithTags: EntryWithTags = {
|
||||||
|
...entry[0],
|
||||||
|
tags: entryTagRows.map((row) => row.tag),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(entryWithTags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create/update entry:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to save entry" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/app/api/export/route.ts
Normal file
96
src/app/api/export/route.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import type { EntryWithTags, ExportFormat } from "@/lib/types";
|
||||||
|
import { MOOD_LABELS, APP_NAME } from "@/lib/constants";
|
||||||
|
import { formatDateLong } from "@/lib/utils/date";
|
||||||
|
|
||||||
|
// POST /api/export - Export all entries
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const format: ExportFormat = body.format || "markdown";
|
||||||
|
|
||||||
|
// Fetch all entries with tags
|
||||||
|
const entries = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.entries)
|
||||||
|
.orderBy(desc(schema.entries.date));
|
||||||
|
|
||||||
|
const entriesWithTags: EntryWithTags[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryTagRows = await db
|
||||||
|
.select({ tag: schema.tags })
|
||||||
|
.from(schema.entryTags)
|
||||||
|
.innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id))
|
||||||
|
.where(eq(schema.entryTags.entryId, entry.id));
|
||||||
|
|
||||||
|
entriesWithTags.push({
|
||||||
|
...entry,
|
||||||
|
tags: entryTagRows.map((row) => row.tag),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "json") {
|
||||||
|
const jsonContent = JSON.stringify(
|
||||||
|
{
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
appName: APP_NAME,
|
||||||
|
entries: entriesWithTags,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
return new NextResponse(jsonContent, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Disposition": `attachment; filename="${APP_NAME.toLowerCase().replace(/\s+/g, "-")}-export-${new Date().toISOString().slice(0, 10)}.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown export
|
||||||
|
let markdown = `# ${APP_NAME} Export\n\n`;
|
||||||
|
markdown += `Exported on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}\n\n`;
|
||||||
|
markdown += `---\n\n`;
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
let currentDate = "";
|
||||||
|
for (const entry of entriesWithTags) {
|
||||||
|
if (entry.date !== currentDate) {
|
||||||
|
currentDate = entry.date;
|
||||||
|
markdown += `## ${formatDateLong(entry.date)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown += entry.text + "\n\n";
|
||||||
|
|
||||||
|
const meta: string[] = [];
|
||||||
|
if (entry.mood) {
|
||||||
|
meta.push(`Mood: ${MOOD_LABELS[entry.mood]} (${entry.mood}/5)`);
|
||||||
|
}
|
||||||
|
if (entry.roughDay) {
|
||||||
|
meta.push("Rough day");
|
||||||
|
}
|
||||||
|
if (entry.tags.length > 0) {
|
||||||
|
meta.push(`Tags: ${entry.tags.map((t) => t.name).join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.length > 0) {
|
||||||
|
markdown += `*${meta.join(" | ")}*\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown += "---\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(markdown, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/markdown; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${APP_NAME.toLowerCase().replace(/\s+/g, "-")}-export-${new Date().toISOString().slice(0, 10)}.md"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to export" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/api/tags/route.ts
Normal file
42
src/app/api/tags/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { desc, like, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
// GET /api/tags - Get tags (recent or search)
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const search = searchParams.get("search");
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "10", 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (search) {
|
||||||
|
// Search tags by name
|
||||||
|
const tags = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.tags)
|
||||||
|
.where(like(schema.tags.name, `%${search.toLowerCase()}%`))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return NextResponse.json(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get most recently used tags (by entry count)
|
||||||
|
const tagsWithCount = await db
|
||||||
|
.select({
|
||||||
|
id: schema.tags.id,
|
||||||
|
name: schema.tags.name,
|
||||||
|
createdAt: schema.tags.createdAt,
|
||||||
|
useCount: sql<number>`count(${schema.entryTags.entryId})`.as("use_count"),
|
||||||
|
})
|
||||||
|
.from(schema.tags)
|
||||||
|
.leftJoin(schema.entryTags, sql`${schema.tags.id} = ${schema.entryTags.tagId}`)
|
||||||
|
.groupBy(schema.tags.id)
|
||||||
|
.orderBy(desc(sql`use_count`), desc(schema.tags.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return NextResponse.json(tagsWithCount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch tags:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch tags" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/app/entry/[id]/page.tsx
Normal file
138
src/app/entry/[id]/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use, useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { EntryForm } from "@/components/EntryForm";
|
||||||
|
import { ToastContainer } from "@/components/Toast";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { EntryWithTags } from "@/lib/types";
|
||||||
|
import { formatDateLong } from "@/lib/utils/date";
|
||||||
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface EntryPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntryPage({ params }: EntryPageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toasts, showToast, dismissToast } = useToast();
|
||||||
|
const [entry, setEntry] = useState<EntryWithTags | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadEntry() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/entries/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
setEntry(await res.json());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load entry:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadEntry();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/entries/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
// Store for undo
|
||||||
|
const deletedEntry = entry;
|
||||||
|
|
||||||
|
showToast("Entry deleted", {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: async () => {
|
||||||
|
// Re-create the entry
|
||||||
|
await fetch("/api/entries", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
date: deletedEntry.date,
|
||||||
|
text: deletedEntry.text,
|
||||||
|
mood: deletedEntry.mood,
|
||||||
|
roughDay: Boolean(deletedEntry.roughDay),
|
||||||
|
tagNames: deletedEntry.tags.map((t) => t.name),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
router.push("/timeline");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/timeline");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete entry:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [entry, id, router, showToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/timeline"
|
||||||
|
className="p-2 -ml-2 text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-light">{APP_NAME}</h1>
|
||||||
|
{entry && (
|
||||||
|
<p className="text-sm text-muted">{formatDateLong(entry.date)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="p-2 text-muted hover:text-red-400 transition-colors"
|
||||||
|
aria-label="Delete entry"
|
||||||
|
>
|
||||||
|
<Trash2 size={20} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<EntryForm entryId={id} />
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{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 entry?</h2>
|
||||||
|
<p className="text-sm text-muted mb-6">
|
||||||
|
This action can be undone for a few seconds after deletion.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4 py-2 text-sm text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/app/export/page.tsx
Normal file
102
src/app/export/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Download, FileJson, FileText, Loader2 } from "lucide-react";
|
||||||
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export default function ExportPage() {
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async (format: "markdown" | "json") => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/export", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ format }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
const contentDisposition = res.headers.get("Content-Disposition");
|
||||||
|
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||||
|
const filename =
|
||||||
|
filenameMatch?.[1] ||
|
||||||
|
`quietthanks-export.${format === "json" ? "json" : "md"}`;
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export:", error);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
||||||
|
<p className="text-sm text-muted">Export</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted">
|
||||||
|
Download all your entries for backup or use elsewhere.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport("markdown")}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="flex items-center gap-4 p-4 bg-surface border border-border rounded-xl hover:border-muted transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="p-3 bg-background rounded-lg">
|
||||||
|
<FileText size={24} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">Markdown</h3>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Readable format, grouped by date
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isExporting ? (
|
||||||
|
<Loader2 className="animate-spin text-muted" size={20} />
|
||||||
|
) : (
|
||||||
|
<Download size={20} className="text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport("json")}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="flex items-center gap-4 p-4 bg-surface border border-border rounded-xl hover:border-muted transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="p-3 bg-background rounded-lg">
|
||||||
|
<FileJson size={24} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">JSON</h3>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Full data with timestamps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isExporting ? (
|
||||||
|
<Loader2 className="animate-spin text-muted" size={20} />
|
||||||
|
) : (
|
||||||
|
<Download size={20} className="text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/globals.css
Normal file
95
src/app/globals.css
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #e5e5e5;
|
||||||
|
--muted: #737373;
|
||||||
|
--border: #262626;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--surface: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area for mobile */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast animation */
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom checkbox */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 9px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
40
src/app/layout.tsx
Normal file
40
src/app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Navigation } from "@/components/Navigation";
|
||||||
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: APP_NAME,
|
||||||
|
description: "A calm, private gratitude and mood log",
|
||||||
|
icons: {
|
||||||
|
icon: "/icons/icon.svg",
|
||||||
|
apple: "/icons/icon.svg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
themeColor: "#0a0a0a",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
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 pb-20">
|
||||||
|
<main className="max-w-lg mx-auto px-4 py-6">{children}</main>
|
||||||
|
<Navigation />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/page.tsx
Normal file
30
src/app/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { EntryForm } from "@/components/EntryForm";
|
||||||
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
import { formatDate, getLocalDate } from "@/lib/utils/date";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const today = getLocalDate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-light mb-1">{APP_NAME}</h1>
|
||||||
|
<p className="text-sm text-muted">{formatDate(today)}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<EntryForm />
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-border">
|
||||||
|
<Link
|
||||||
|
href="/timeline"
|
||||||
|
className="flex items-center justify-between text-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>View timeline</span>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/app/settings/page.tsx
Normal file
73
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
||||||
|
<p className="text-sm text-muted">Settings</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Notifications - 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">Daily reminder</h3>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Get a gentle nudge to check in
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-border text-center text-sm text-muted">
|
||||||
|
<p>{APP_NAME} v0.1.0</p>
|
||||||
|
<p className="mt-1">A calm, private gratitude log.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/timeline/page.tsx
Normal file
93
src/app/timeline/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { WeeklyReflection } from "@/components/WeeklyReflection";
|
||||||
|
import { FilterPanel } from "@/components/FilterPanel";
|
||||||
|
import { EntryRow } from "@/components/EntryRow";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type { EntryWithTags, FilterState } from "@/lib/types";
|
||||||
|
import { APP_NAME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export default function TimelinePage() {
|
||||||
|
const [entries, setEntries] = useState<EntryWithTags[]>([]);
|
||||||
|
const [filteredEntries, setFilteredEntries] = useState<EntryWithTags[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
moods: [],
|
||||||
|
tagId: null,
|
||||||
|
roughDay: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadEntries() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/entries");
|
||||||
|
if (res.ok) {
|
||||||
|
setEntries(await res.json());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load entries:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadEntries();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply filters client-side
|
||||||
|
useEffect(() => {
|
||||||
|
let result = entries;
|
||||||
|
|
||||||
|
if (filters.moods.length > 0) {
|
||||||
|
result = result.filter((e) => e.mood && filters.moods.includes(e.mood));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tagId) {
|
||||||
|
result = result.filter((e) => e.tags.some((t) => t.id === filters.tagId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.roughDay === true) {
|
||||||
|
result = result.filter((e) => e.roughDay);
|
||||||
|
} else if (filters.roughDay === false) {
|
||||||
|
result = result.filter((e) => !e.roughDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredEntries(result);
|
||||||
|
}, [entries, filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-light">{APP_NAME}</h1>
|
||||||
|
<p className="text-sm text-muted">Timeline</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="animate-spin text-muted" size={24} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WeeklyReflection entries={entries} />
|
||||||
|
<FilterPanel filters={filters} onChange={setFilters} />
|
||||||
|
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p>No entries yet. Start your first check-in!</p>
|
||||||
|
) : (
|
||||||
|
<p>No entries match your filters.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredEntries.map((entry) => (
|
||||||
|
<EntryRow key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/EntryForm.tsx
Normal file
101
src/components/EntryForm.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEntry } from "@/hooks/useEntry";
|
||||||
|
import { MoodSelector } from "./MoodSelector";
|
||||||
|
import { TagInput } from "./TagInput";
|
||||||
|
import { Check, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface EntryFormProps {
|
||||||
|
date?: string;
|
||||||
|
entryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryForm({ date, entryId }: EntryFormProps) {
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
mood,
|
||||||
|
roughDay,
|
||||||
|
tagNames,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
lastSaved,
|
||||||
|
updateText,
|
||||||
|
updateMood,
|
||||||
|
updateRoughDay,
|
||||||
|
addTag,
|
||||||
|
removeTag,
|
||||||
|
} = useEntry({ date, entryId });
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="animate-spin text-muted" size={24} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Main prompt */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="gratitude-text" className="block text-lg mb-3 text-muted">
|
||||||
|
What are you grateful for today?
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="gratitude-text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => updateText(e.target.value)}
|
||||||
|
placeholder="Take a moment to reflect..."
|
||||||
|
rows={5}
|
||||||
|
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-muted/50 focus:outline-none focus:border-muted resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mood */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted mb-2">How are you feeling?</label>
|
||||||
|
<MoodSelector value={mood} onChange={updateMood} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rough day toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={roughDay}
|
||||||
|
onChange={(e) => updateRoughDay(e.target.checked)}
|
||||||
|
className="w-5 h-5 rounded border-border bg-surface accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted">Mark as a rough day</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted mb-2">Tags (optional)</label>
|
||||||
|
<TagInput
|
||||||
|
selectedTags={tagNames}
|
||||||
|
onAddTag={addTag}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save status */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted">
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={14} />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : lastSaved ? (
|
||||||
|
<>
|
||||||
|
<Check size={14} className="text-green-500" />
|
||||||
|
Saved
|
||||||
|
</>
|
||||||
|
) : text.trim() ? (
|
||||||
|
<span className="text-muted/50">Auto-saves as you type</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/EntryRow.tsx
Normal file
49
src/components/EntryRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { formatDate, isToday } from "@/lib/utils/date";
|
||||||
|
import { MoodIcon } from "./MoodSelector";
|
||||||
|
import { TagChips } from "./TagInput";
|
||||||
|
import type { EntryWithTags } from "@/lib/types";
|
||||||
|
|
||||||
|
interface EntryRowProps {
|
||||||
|
entry: EntryWithTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryRow({ entry }: EntryRowProps) {
|
||||||
|
// Truncate text to one line preview
|
||||||
|
const preview = entry.text.length > 80 ? entry.text.slice(0, 80) + "..." : entry.text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/entry/${entry.id}`}
|
||||||
|
className="block p-4 bg-surface border border-border rounded-xl hover:border-muted transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{isToday(entry.date) ? "Today" : formatDate(entry.date)}
|
||||||
|
</span>
|
||||||
|
{entry.roughDay ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-red-500/20 text-red-400 rounded">
|
||||||
|
rough day
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-foreground truncate">{preview}</p>
|
||||||
|
{entry.tags.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<TagChips tags={entry.tags} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.mood && (
|
||||||
|
<div className="text-xl">
|
||||||
|
<MoodIcon mood={entry.mood} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/components/FilterPanel.tsx
Normal file
172
src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Filter, X } from "lucide-react";
|
||||||
|
import type { Tag } from "@/lib/db/schema";
|
||||||
|
import type { FilterState } from "@/lib/types";
|
||||||
|
import { MOOD_LABELS } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
filters: FilterState;
|
||||||
|
onChange: (filters: FilterState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOOD_OPTIONS = [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
export function FilterPanel({ filters, onChange }: FilterPanelProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadTags() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tags?limit=20");
|
||||||
|
if (res.ok) {
|
||||||
|
setTags(await res.json());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasFilters =
|
||||||
|
filters.moods.length > 0 || filters.tagId !== null || filters.roughDay !== null;
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
onChange({ moods: [], tagId: null, roughDay: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMood = (mood: number) => {
|
||||||
|
const newMoods = filters.moods.includes(mood)
|
||||||
|
? filters.moods.filter((m) => m !== mood)
|
||||||
|
: [...filters.moods, mood];
|
||||||
|
onChange({ ...filters, moods: newMoods });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
isOpen || hasFilters
|
||||||
|
? "bg-accent/20 text-accent"
|
||||||
|
: "bg-surface text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter size={16} />
|
||||||
|
Filters
|
||||||
|
{hasFilters && (
|
||||||
|
<span className="bg-accent text-white text-xs px-1.5 rounded-full">
|
||||||
|
{(filters.moods.length > 0 ? 1 : 0) +
|
||||||
|
(filters.tagId ? 1 : 0) +
|
||||||
|
(filters.roughDay !== null ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-sm text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="mt-3 p-4 bg-surface border border-border rounded-xl space-y-4">
|
||||||
|
{/* Mood filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted mb-2">Mood</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{MOOD_OPTIONS.map((mood) => (
|
||||||
|
<button
|
||||||
|
key={mood}
|
||||||
|
onClick={() => toggleMood(mood)}
|
||||||
|
aria-label={MOOD_LABELS[mood]}
|
||||||
|
aria-pressed={filters.moods.includes(mood)}
|
||||||
|
className={`text-xl p-2 rounded-lg transition-colors ${
|
||||||
|
filters.moods.includes(mood)
|
||||||
|
? "bg-accent/20"
|
||||||
|
: "opacity-40 hover:opacity-70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{["😔", "😕", "😐", "🙂", "😊"][mood - 1]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag filter */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted mb-2">Tag</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
tagId: filters.tagId === tag.id ? null : tag.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
filters.tagId === tag.id
|
||||||
|
? "bg-accent/20 text-accent"
|
||||||
|
: "bg-background border border-border text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rough day filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted mb-2">
|
||||||
|
Rough day
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
roughDay: filters.roughDay === true ? null : true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
filters.roughDay === true
|
||||||
|
? "bg-accent/20 text-accent"
|
||||||
|
: "bg-background border border-border text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
roughDay: filters.roughDay === false ? null : false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
filters.roughDay === false
|
||||||
|
? "bg-accent/20 text-accent"
|
||||||
|
: "bg-background border border-border text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/MoodSelector.tsx
Normal file
45
src/components/MoodSelector.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MOOD_LABELS } from "@/lib/constants";
|
||||||
|
|
||||||
|
const MOOD_ICONS = ["😔", "😕", "😐", "🙂", "😊"];
|
||||||
|
|
||||||
|
interface MoodSelectorProps {
|
||||||
|
value: number | null;
|
||||||
|
onChange: (mood: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoodSelector({ value, onChange }: MoodSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{MOOD_ICONS.map((icon, index) => {
|
||||||
|
const moodValue = index + 1;
|
||||||
|
const isSelected = value === moodValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={moodValue}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(isSelected ? null : moodValue)}
|
||||||
|
aria-label={MOOD_LABELS[moodValue]}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={`text-2xl p-2 rounded-lg transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "bg-accent/20 scale-110"
|
||||||
|
: "opacity-40 hover:opacity-70 hover:bg-surface"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoodIcon({ mood }: { mood: number }) {
|
||||||
|
return (
|
||||||
|
<span aria-label={MOOD_LABELS[mood]} title={MOOD_LABELS[mood]}>
|
||||||
|
{MOOD_ICONS[mood - 1]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/Navigation.tsx
Normal file
38
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Home, List, Download, Settings } from "lucide-react";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/", icon: Home, label: "Check-in" },
|
||||||
|
{ href: "/timeline", icon: List, label: "Timeline" },
|
||||||
|
{ href: "/export", icon: Download, label: "Export" },
|
||||||
|
{ href: "/settings", icon: Settings, label: "Settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Navigation() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 bg-background border-t border-border safe-area-bottom">
|
||||||
|
<div className="flex justify-around max-w-lg mx-auto">
|
||||||
|
{NAV_ITEMS.map(({ href, icon: Icon, label }) => {
|
||||||
|
const isActive = pathname === href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`flex flex-col items-center gap-1 py-3 px-4 ${
|
||||||
|
isActive ? "text-accent" : "text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="text-xs">{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/TagInput.tsx
Normal file
146
src/components/TagInput.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { X, Plus } from "lucide-react";
|
||||||
|
import type { Tag } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
interface TagInputProps {
|
||||||
|
selectedTags: string[];
|
||||||
|
onAddTag: (name: string) => void;
|
||||||
|
onRemoveTag: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagInput({ selectedTags, onAddTag, onRemoveTag }: TagInputProps) {
|
||||||
|
const [recentTags, setRecentTags] = useState<Tag[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [showInput, setShowInput] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadRecentTags() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tags?limit=10");
|
||||||
|
if (res.ok) {
|
||||||
|
const tags = await res.json();
|
||||||
|
setRecentTags(tags);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadRecentTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const availableTags = recentTags.filter((t) => !selectedTags.includes(t.name));
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = inputValue.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
onAddTag(trimmed);
|
||||||
|
setInputValue("");
|
||||||
|
setShowInput(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Selected tags */}
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1 bg-accent/20 text-accent rounded-full text-sm"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemoveTag(tag)}
|
||||||
|
className="hover:bg-accent/30 rounded-full p-0.5"
|
||||||
|
aria-label={`Remove ${tag}`}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent tags to tap */}
|
||||||
|
{availableTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableTags.slice(0, 6).map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddTag(tag.name)}
|
||||||
|
className="px-3 py-1 bg-surface border border-border rounded-full text-sm text-muted hover:text-foreground hover:border-muted transition-colors"
|
||||||
|
>
|
||||||
|
+ {tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add custom tag */}
|
||||||
|
{showInput ? (
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
placeholder="Type a tag..."
|
||||||
|
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||||
|
autoFocus
|
||||||
|
onBlur={() => {
|
||||||
|
if (!inputValue.trim()) setShowInput(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-3 py-2 bg-accent text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowInput(true)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1 text-sm text-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add tag
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagChipsProps {
|
||||||
|
tags: { id: string; name: string }[];
|
||||||
|
maxDisplay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) {
|
||||||
|
if (tags.length === 0) return null;
|
||||||
|
|
||||||
|
const displayed = tags.slice(0, maxDisplay);
|
||||||
|
const remaining = tags.length - maxDisplay;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{displayed.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="px-2 py-0.5 bg-surface text-muted text-xs rounded"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<span className="px-2 py-0.5 text-muted text-xs">+{remaining}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/Toast.tsx
Normal file
41
src/components/Toast.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { Toast as ToastType } from "@/hooks/useToast";
|
||||||
|
|
||||||
|
interface ToastContainerProps {
|
||||||
|
toasts: ToastType[];
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 bg-surface border border-border rounded-lg shadow-lg animate-slide-up"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{toast.message}</span>
|
||||||
|
{toast.action && (
|
||||||
|
<button
|
||||||
|
onClick={toast.action.onClick}
|
||||||
|
className="text-sm text-accent font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDismiss(toast.id)}
|
||||||
|
className="text-muted hover:text-foreground p-1"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/components/WeeklyReflection.tsx
Normal file
65
src/components/WeeklyReflection.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { EntryWithTags } from "@/lib/types";
|
||||||
|
import { isWithinWeek } from "@/lib/utils/date";
|
||||||
|
|
||||||
|
interface WeeklyReflectionProps {
|
||||||
|
entries: EntryWithTags[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeeklyReflection({ entries }: WeeklyReflectionProps) {
|
||||||
|
// Filter to last 7 days
|
||||||
|
const weekEntries = entries.filter((e) => isWithinWeek(e.date));
|
||||||
|
|
||||||
|
if (weekEntries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count tag occurrences
|
||||||
|
const tagCounts = new Map<string, { name: string; count: number }>();
|
||||||
|
for (const entry of weekEntries) {
|
||||||
|
for (const tag of entry.tags) {
|
||||||
|
const existing = tagCounts.get(tag.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.count++;
|
||||||
|
} else {
|
||||||
|
tagCounts.set(tag.id, { name: tag.name, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top 3 tags
|
||||||
|
const topTags = Array.from(tagCounts.values())
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
// Generate summary
|
||||||
|
let summary = "";
|
||||||
|
if (topTags.length > 0) {
|
||||||
|
const tagNames = topTags.map((t) => capitalize(t.name));
|
||||||
|
if (tagNames.length === 1) {
|
||||||
|
summary = `You mentioned: ${tagNames[0]}.`;
|
||||||
|
} else if (tagNames.length === 2) {
|
||||||
|
summary = `You mentioned: ${tagNames[0]} and ${tagNames[1]}.`;
|
||||||
|
} else {
|
||||||
|
summary = `You mentioned: ${tagNames.slice(0, -1).join(", ")}, and ${tagNames[tagNames.length - 1]}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-border rounded-xl p-4 mb-6">
|
||||||
|
<h2 className="text-sm font-medium text-muted mb-2">This week</h2>
|
||||||
|
<div className="flex items-baseline gap-4 mb-2">
|
||||||
|
<span className="text-2xl font-light">{weekEntries.length}</span>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{weekEntries.length === 1 ? "entry" : "entries"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{summary && <p className="text-sm text-muted italic">{summary}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
32
src/hooks/useDebounce.ts
Normal file
32
src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
export function useDebounce<T extends (...args: Parameters<T>) => void>(
|
||||||
|
callback: T,
|
||||||
|
delay: number
|
||||||
|
): T {
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const debouncedFn = useCallback(
|
||||||
|
(...args: Parameters<T>) => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
callback(...args);
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
[callback, delay]
|
||||||
|
) as T;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return debouncedFn;
|
||||||
|
}
|
||||||
191
src/hooks/useEntry.ts
Normal file
191
src/hooks/useEntry.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useDebounce } from "./useDebounce";
|
||||||
|
import type { EntryWithTags, CreateEntryRequest } from "@/lib/types";
|
||||||
|
import { getLocalDate } from "@/lib/utils/date";
|
||||||
|
import { AUTOSAVE_DELAY } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface UseEntryOptions {
|
||||||
|
date?: string;
|
||||||
|
entryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntry({ date, entryId }: UseEntryOptions = {}) {
|
||||||
|
const targetDate = date || getLocalDate();
|
||||||
|
const [entry, setEntry] = useState<EntryWithTags | null>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [mood, setMood] = useState<number | null>(null);
|
||||||
|
const [roughDay, setRoughDay] = useState(false);
|
||||||
|
const [tagNames, setTagNames] = useState<string[]>([]);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const pendingSaveRef = useRef(false);
|
||||||
|
|
||||||
|
// Load entry
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadEntry() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
let data: EntryWithTags | null = null;
|
||||||
|
|
||||||
|
if (entryId) {
|
||||||
|
const res = await fetch(`/api/entries/${entryId}`);
|
||||||
|
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) {
|
||||||
|
setEntry(data);
|
||||||
|
setText(data.text);
|
||||||
|
setMood(data.mood);
|
||||||
|
setRoughDay(Boolean(data.roughDay));
|
||||||
|
setTagNames(data.tags.map((t) => t.name));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load entry:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEntry();
|
||||||
|
}, [targetDate, entryId]);
|
||||||
|
|
||||||
|
// Save function
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
pendingSaveRef.current = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: CreateEntryRequest = {
|
||||||
|
date: entry?.date || targetDate,
|
||||||
|
text: text.trim(),
|
||||||
|
mood,
|
||||||
|
roughDay,
|
||||||
|
tagNames,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/entries", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const saved: EntryWithTags = await res.json();
|
||||||
|
setEntry(saved);
|
||||||
|
setLastSaved(new Date());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save entry:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
|
||||||
|
|
||||||
|
// Debounced save
|
||||||
|
const debouncedSave = useDebounce(() => {
|
||||||
|
if (pendingSaveRef.current) {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
}, AUTOSAVE_DELAY);
|
||||||
|
|
||||||
|
// Mark pending and trigger debounced save
|
||||||
|
const markDirty = useCallback(() => {
|
||||||
|
pendingSaveRef.current = true;
|
||||||
|
debouncedSave();
|
||||||
|
}, [debouncedSave]);
|
||||||
|
|
||||||
|
// Update handlers that trigger autosave
|
||||||
|
const updateText = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setText(value);
|
||||||
|
markDirty();
|
||||||
|
},
|
||||||
|
[markDirty]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateMood = useCallback(
|
||||||
|
(value: number | null) => {
|
||||||
|
setMood(value);
|
||||||
|
markDirty();
|
||||||
|
},
|
||||||
|
[markDirty]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateRoughDay = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
setRoughDay(value);
|
||||||
|
markDirty();
|
||||||
|
},
|
||||||
|
[markDirty]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addTag = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
const normalized = name.toLowerCase().trim();
|
||||||
|
if (normalized && !tagNames.includes(normalized)) {
|
||||||
|
setTagNames((prev) => [...prev, normalized]);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tagNames, markDirty]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeTag = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
setTagNames((prev) => prev.filter((t) => t !== name));
|
||||||
|
markDirty();
|
||||||
|
},
|
||||||
|
[markDirty]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save on unmount if pending
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pendingSaveRef.current && text.trim()) {
|
||||||
|
// Fire and forget
|
||||||
|
fetch("/api/entries", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
date: entry?.date || targetDate,
|
||||||
|
text: text.trim(),
|
||||||
|
mood,
|
||||||
|
roughDay,
|
||||||
|
tagNames,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [text, mood, roughDay, tagNames, entry?.date, targetDate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
text,
|
||||||
|
mood,
|
||||||
|
roughDay,
|
||||||
|
tagNames,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
lastSaved,
|
||||||
|
updateText,
|
||||||
|
updateMood,
|
||||||
|
updateRoughDay,
|
||||||
|
addTag,
|
||||||
|
removeTag,
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/hooks/useToast.ts
Normal file
38
src/hooks/useToast.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(message: string, action?: Toast["action"], duration = 5000) => {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
const toast: Toast = { id, message, action };
|
||||||
|
|
||||||
|
setToasts((prev) => [...prev, toast]);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, showToast, dismissToast };
|
||||||
|
}
|
||||||
14
src/lib/constants.ts
Normal file
14
src/lib/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// App name - change this to customize your instance
|
||||||
|
export const APP_NAME = "Quiet Thanks";
|
||||||
|
|
||||||
|
// Mood labels for accessibility
|
||||||
|
export const MOOD_LABELS: Record<number, string> = {
|
||||||
|
1: "Very low",
|
||||||
|
2: "Low",
|
||||||
|
3: "Neutral",
|
||||||
|
4: "Good",
|
||||||
|
5: "Very good",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Autosave debounce delay in ms
|
||||||
|
export const AUTOSAVE_DELAY = 800;
|
||||||
14
src/lib/db/index.ts
Normal file
14
src/lib/db/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), "data", "quietthanks.db");
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
export { schema };
|
||||||
27
src/lib/db/migrate.ts
Normal file
27
src/lib/db/migrate.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), "data", "quietthanks.db");
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
const db = drizzle(sqlite);
|
||||||
|
|
||||||
|
console.log("Running migrations...");
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
console.log("Migrations complete!");
|
||||||
|
|
||||||
|
// Checkpoint WAL to ensure clean state for build
|
||||||
|
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||||
|
sqlite.close();
|
||||||
40
src/lib/db/schema.ts
Normal file
40
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const entries = sqliteTable("entries", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
date: text("date").notNull(), // YYYY-MM-DD local date
|
||||||
|
text: text("text").notNull(),
|
||||||
|
mood: integer("mood"), // 1-5, null if not set
|
||||||
|
roughDay: integer("rough_day").notNull().default(0),
|
||||||
|
createdAt: integer("created_at").notNull(), // Unix ms
|
||||||
|
updatedAt: integer("updated_at").notNull(), // Unix ms
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tags = sqliteTable("tags", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull().unique(), // Normalized lowercase
|
||||||
|
createdAt: integer("created_at").notNull(), // Unix ms
|
||||||
|
});
|
||||||
|
|
||||||
|
export const entryTags = sqliteTable(
|
||||||
|
"entry_tags",
|
||||||
|
{
|
||||||
|
entryId: text("entry_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => entries.id, { onDelete: "cascade" }),
|
||||||
|
tagId: text("tag_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tags.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.entryId, table.tagId] })]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type Entry = typeof entries.$inferSelect;
|
||||||
|
export type NewEntry = typeof entries.$inferInsert;
|
||||||
|
|
||||||
|
export type Tag = typeof tags.$inferSelect;
|
||||||
|
export type NewTag = typeof tags.$inferInsert;
|
||||||
|
|
||||||
|
export type EntryTag = typeof entryTags.$inferSelect;
|
||||||
|
export type NewEntryTag = typeof entryTags.$inferInsert;
|
||||||
39
src/lib/types.ts
Normal file
39
src/lib/types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Entry, Tag } from "./db/schema";
|
||||||
|
|
||||||
|
// Entry with tags for API responses
|
||||||
|
export interface EntryWithTags extends Entry {
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API request/response types
|
||||||
|
export interface CreateEntryRequest {
|
||||||
|
date: string;
|
||||||
|
text: string;
|
||||||
|
mood?: number | null;
|
||||||
|
roughDay?: boolean;
|
||||||
|
tagNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEntryRequest {
|
||||||
|
text?: string;
|
||||||
|
mood?: number | null;
|
||||||
|
roughDay?: boolean;
|
||||||
|
tagNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
export interface FilterState {
|
||||||
|
moods: number[];
|
||||||
|
tagId: string | null;
|
||||||
|
roughDay: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly reflection data
|
||||||
|
export interface WeeklyReflection {
|
||||||
|
entryCount: number;
|
||||||
|
topTags: Tag[];
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export format
|
||||||
|
export type ExportFormat = "markdown" | "json";
|
||||||
50
src/lib/utils/date.ts
Normal file
50
src/lib/utils/date.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Get today's date in YYYY-MM-DD format (local time)
|
||||||
|
export function getLocalDate(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
export function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + "T00:00:00");
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date with year
|
||||||
|
export function formatDateLong(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + "T00:00:00");
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date N days ago
|
||||||
|
export function getDateDaysAgo(days: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - days);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if date is today
|
||||||
|
export function isToday(dateStr: string): boolean {
|
||||||
|
return dateStr === getLocalDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if date is within last 7 days
|
||||||
|
export function isWithinWeek(dateStr: string): boolean {
|
||||||
|
const weekAgo = getDateDaysAgo(7);
|
||||||
|
return dateStr >= weekAgo;
|
||||||
|
}
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user