mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +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