feat: rename keep-notes to memento-note, migrate to PostgreSQL, fix MCP bugs
- Rename directory keep-notes -> memento-note with all code references - Prisma: SQLite -> PostgreSQL (both app and MCP server schemas) - Sync MCP schema with main app (add missing fields, relations, indexes) - Delete 17 SQLite migrations (clean slate for PostgreSQL) - Remove SQLite dependencies (@libsql/client, better-sqlite3, etc.) - Fix MCP server: hardcoded Windows DB paths -> DATABASE_URL env var - Fix MCP server: .dockerignore excluded index-sse.js (SSE mode broken) - MCP Dockerfile: node:20 -> node:22 - Docker Compose: add postgres service, remove SQLite volume - Generate favicon.ico, icon-192.png, icon-512.png, apple-icon.png - Update layout.tsx icons and manifest.json for PNG icons - Update all .env files for PostgreSQL - Rewrite README.md with updated sections - Remove mcp-server/node_modules and prisma/client-generated from git tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
53
memento-note/.dockerignore
Normal file
53
memento-note/.dockerignore
Normal file
@@ -0,0 +1,53 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
build
|
||||
dist
|
||||
|
||||
# Production
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Development files
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.eslintrc.json
|
||||
.prettierrc*
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Misc
|
||||
.turbo
|
||||
*.log
|
||||
|
||||
# BMAD output (not needed in container)
|
||||
_bmad-output
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
deploy.sh
|
||||
47
memento-note/.gitignore
vendored
Normal file
47
memento-note/.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/lib/generated/prisma
|
||||
|
||||
# generated
|
||||
/prisma/client-generated
|
||||
/_backup
|
||||
397
memento-note/DOCKER_DEPLOYMENT.md
Normal file
397
memento-note/DOCKER_DEPLOYMENT.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# 🐳 Docker Deployment Guide for Proxmox
|
||||
|
||||
Complete guide to deploy Memento on Proxmox using Docker Compose.
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
### On Your Proxmox Host:
|
||||
- Proxmox VE 7.x or 8.x
|
||||
- Docker and Docker Compose installed
|
||||
- At least 2GB RAM available (4GB+ recommended for AI features)
|
||||
- 10GB disk space available
|
||||
|
||||
### Optional for AI Features:
|
||||
- **For OpenAI**: Valid API key
|
||||
- **For Ollama (Local AI)**: 8GB+ RAM, 4+ CPU cores recommended
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Prepare Environment Files
|
||||
|
||||
Create a `.env` file in the `keep-notes` directory:
|
||||
|
||||
```bash
|
||||
cd /path/to/keep-notes
|
||||
cat > .env << 'EOF'
|
||||
# Required: Generate a random secret
|
||||
NEXTAUTH_SECRET=$(openssl rand -base64 32)
|
||||
NEXTAUTH_URL=http://your-domain.com:3000
|
||||
|
||||
# Optional: OpenAI API Key
|
||||
# OPENAI_API_KEY=sk-your-key-here
|
||||
|
||||
# Optional: Ollama Configuration (if using local AI)
|
||||
# OLLAMA_BASE_URL=http://ollama:11434
|
||||
# OLLAMA_MODEL=granite4:latest
|
||||
|
||||
# Optional: Custom Session Max Age (in seconds)
|
||||
NEXTAUTH_SESSION_MAX_AGE=604800
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Build and Start Containers
|
||||
|
||||
```bash
|
||||
# Build the Docker image
|
||||
docker compose build
|
||||
|
||||
# Start the application
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f keep-notes
|
||||
```
|
||||
|
||||
### 3. Access the Application
|
||||
|
||||
Open your browser and navigate to:
|
||||
- **http://YOUR_PROXMOX_IP:3000**
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Without Reverse Proxy (Basic)
|
||||
|
||||
Edit `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- NEXTAUTH_URL=http://your-ip:3000
|
||||
- NEXTAUTH_SECRET=your-random-secret
|
||||
ports:
|
||||
- "3000:3000"
|
||||
```
|
||||
|
||||
### With Nginx Reverse Proxy (Recommended)
|
||||
|
||||
#### 1. Create Nginx Configuration
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/conf.d/keep-notes.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name notes.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Max upload size for images
|
||||
client_max_body_size 10M;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Update docker-compose.yml
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NEXTAUTH_URL=https://notes.yourdomain.com
|
||||
```
|
||||
|
||||
#### 3. Restart Container
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### With SSL/HTTPS (Let's Encrypt)
|
||||
|
||||
```bash
|
||||
# Install certbot
|
||||
apt install certbot python3-certbot-nginx
|
||||
|
||||
# Get certificate
|
||||
certbot --nginx -d notes.yourdomain.com
|
||||
|
||||
# Auto-renewal (cron)
|
||||
echo "0 0,12 * * * root certbot renew --quiet" | tee /etc/cron.d/certbot-renew
|
||||
```
|
||||
|
||||
## 🤖 AI Features Setup
|
||||
|
||||
### Option 1: OpenAI (Cloud)
|
||||
|
||||
1. Get API key from https://platform.openai.com/api-keys
|
||||
2. Add to `.env`:
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-your-key-here
|
||||
```
|
||||
3. Restart: `docker compose restart`
|
||||
|
||||
### Option 2: Ollama (Local AI)
|
||||
|
||||
#### 1. Enable Ollama in docker-compose.yml
|
||||
|
||||
Uncomment the `ollama` service section in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: keep-ollama
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
networks:
|
||||
- keep-network
|
||||
```
|
||||
|
||||
Uncomment volume:
|
||||
```yaml
|
||||
volumes:
|
||||
ollama-data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
#### 2. Add Environment Variables
|
||||
|
||||
```yaml
|
||||
keep-notes:
|
||||
environment:
|
||||
- OLLAMA_BASE_URL=http://ollama:11434
|
||||
- OLLAMA_MODEL=granite4:latest
|
||||
```
|
||||
|
||||
#### 3. Start and Pull Model
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose exec -it ollama ollama pull granite4
|
||||
```
|
||||
|
||||
### Option 3: Custom OpenAI-compatible API
|
||||
|
||||
If you have a custom API (like LocalAI, LM Studio, etc.):
|
||||
|
||||
```bash
|
||||
# Add to .env or docker-compose.yml
|
||||
OPENAI_API_BASE_URL=http://your-api-host:port/v1
|
||||
OPENAI_API_KEY=any-key-here
|
||||
```
|
||||
|
||||
## 📊 Resource Recommendations
|
||||
|
||||
### Minimal Setup (Without AI)
|
||||
- **CPU**: 1 core
|
||||
- **RAM**: 512MB
|
||||
- **Disk**: 5GB
|
||||
|
||||
### Recommended Setup (With OpenAI)
|
||||
- **CPU**: 1-2 cores
|
||||
- **RAM**: 1-2GB
|
||||
- **Disk**: 10GB
|
||||
|
||||
### AI Setup (With Ollama)
|
||||
- **CPU**: 4+ cores
|
||||
- **RAM**: 8GB+
|
||||
- **Disk**: 20GB+
|
||||
|
||||
## 🗄️ Database Backup
|
||||
|
||||
### Backup SQLite Database
|
||||
|
||||
```bash
|
||||
# Create backup script
|
||||
cat > /path/to/backup-keep.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="/path/to/backups"
|
||||
CONTAINER_NAME="keep-notes"
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup database
|
||||
docker exec $CONTAINER_NAME \
|
||||
cp /app/prisma/dev.db /app/prisma/backup_$DATE.db
|
||||
|
||||
# Copy from container to host
|
||||
docker cp $CONTAINER_NAME:/app/prisma/backup_$DATE.db \
|
||||
$BACKUP_DIR/keep-notes_$DATE.db
|
||||
|
||||
# Keep last 7 days
|
||||
find $BACKUP_DIR -name "keep-notes_*.db" -mtime +7 -delete
|
||||
|
||||
echo "Backup completed: keep-notes_$DATE.db"
|
||||
EOF
|
||||
|
||||
chmod +x /path/to/backup-keep.sh
|
||||
|
||||
# Add to crontab (daily backup at 2 AM)
|
||||
crontab -e
|
||||
# Add: 0 2 * * * /path/to/backup-keep.sh
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```bash
|
||||
# Stop container
|
||||
docker compose down
|
||||
|
||||
# Restore database
|
||||
cp /path/to/backups/keep-notes_YYYYMMDD_HHMMSS.db \
|
||||
keep-notes/prisma/dev.db
|
||||
|
||||
# Start container
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🔄 Updating the Application
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild image
|
||||
docker compose build
|
||||
|
||||
# Restart with new image
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
|
||||
# Clean up old images
|
||||
docker image prune -a -f
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs keep-notes
|
||||
|
||||
# Check container status
|
||||
docker compose ps
|
||||
|
||||
# Enter container for debugging
|
||||
docker compose exec keep-notes sh
|
||||
```
|
||||
|
||||
### Database Errors
|
||||
|
||||
```bash
|
||||
# Fix database permissions
|
||||
docker compose exec keep-notes \
|
||||
chown -R nextjs:nodejs /app/prisma
|
||||
|
||||
# Regenerate Prisma client
|
||||
docker compose exec keep-notes \
|
||||
npx prisma generate
|
||||
|
||||
# Run migrations
|
||||
docker compose exec keep-notes \
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### AI Features Not Working
|
||||
|
||||
```bash
|
||||
# Check Ollama status
|
||||
docker compose logs ollama
|
||||
|
||||
# Test Ollama connection
|
||||
docker compose exec keep-notes \
|
||||
curl http://ollama:11434/api/tags
|
||||
|
||||
# Check environment variables
|
||||
docker compose exec keep-notes env | grep -E "OLLAMA|OPENAI"
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
|
||||
```bash
|
||||
# Check resource usage
|
||||
docker stats keep-notes
|
||||
|
||||
# Increase resources in docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4'
|
||||
memory: 4G
|
||||
```
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
1. **Change NEXTAUTH_SECRET**: Never use the default value
|
||||
2. **Use HTTPS**: Always use SSL in production
|
||||
3. **Limit Resources**: Prevent container from using all system resources
|
||||
4. **Regular Updates**: Keep Docker image and dependencies updated
|
||||
5. **Backups**: Set up automated database backups
|
||||
6. **Firewall**: Only expose necessary ports (3000 or reverse proxy port)
|
||||
|
||||
## 📱 Proxmox LXC Container Setup
|
||||
|
||||
### Create LXC Container (Recommended)
|
||||
|
||||
```bash
|
||||
# In Proxmox shell
|
||||
pveam available
|
||||
pveam update
|
||||
|
||||
# Create Ubuntu 22.04 container
|
||||
pct create 999 local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst \
|
||||
--hostname keep-notes \
|
||||
--storage local-lvm \
|
||||
--cores 2 \
|
||||
--memory 2048 \
|
||||
--swap 512 \
|
||||
--net0 name=eth0,bridge=vmbr0,ip=dhcp
|
||||
|
||||
# Start container
|
||||
pct start 999
|
||||
|
||||
# Enter container
|
||||
pct enter 999
|
||||
|
||||
# Install Docker inside LXC
|
||||
apt update && apt upgrade -y
|
||||
apt install -y curl git
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sh get-docker.sh
|
||||
usermod -aG docker ubuntu
|
||||
|
||||
# Enable nested containerization for LXC
|
||||
# Edit /etc/pve/lxc/999.conf on Proxmox host
|
||||
# Add: features: nesting=1,keyctl=1
|
||||
```
|
||||
|
||||
Then deploy Memento as described above.
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Next.js Deployment](https://nextjs.org/docs/deployment)
|
||||
- [Docker Compose Reference](https://docs.docker.com/compose/)
|
||||
- [Prisma Docker Guide](https://www.prisma.io/docs/guides/deployment/docker)
|
||||
- [Proxmox LXC Documentation](https://pve.proxmox.com/wiki/Linux_Container)
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Use Volumes**: Always use Docker volumes for persistent data
|
||||
2. **Health Checks**: Enable health checks for auto-restart
|
||||
3. **Log Rotation**: Prevent disk filling with logs
|
||||
4. **Monitoring**: Use Portainer or similar for easy management
|
||||
5. **Testing**: Test in staging environment before production
|
||||
|
||||
---
|
||||
|
||||
**Need Help?** Check the main README or open an issue on GitHub.
|
||||
64
memento-note/Dockerfile
Normal file
64
memento-note/Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
||||
# Multi-stage build for Next.js 16 with Webpack + Prisma
|
||||
# Using Debian 11 (bullseye) for native OpenSSL 1.1.x support
|
||||
|
||||
FROM node:22-bullseye-slim AS base
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL (1.1.x native in Debian 11)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Copy Prisma schema and generate client BEFORE Next.js build
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build Next.js with Webpack
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/package-lock.json ./package-lock.json
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Copy Next.js standalone output
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Copy Prisma schema, generated client, and Query Engine binaries
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
RUN chown -R nextjs:nodejs /app/prisma
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
323
memento-note/GLOBALS-CSS-CORRIGÉ-SLATE.css
Normal file
323
memento-note/GLOBALS-CSS-CORRIGÉ-SLATE.css
Normal file
@@ -0,0 +1,323 @@
|
||||
# 🔧 CORRECTION COMPLÈTE : Thème Slate
|
||||
|
||||
Ramez, voici le fichier CORRIGÉ complet à copier dans `keep-notes/app/globals.css`
|
||||
|
||||
---
|
||||
|
||||
## ✅ INSTRUCTIONS PRÉCISES
|
||||
|
||||
### Étape 1 : Remplacez le thème principal (:root)
|
||||
Cherchez `:root {` dans le fichier (ligne 100)
|
||||
Remplacez TOUTES les lignes 100-133 par ceci :
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* ============================================
|
||||
THEME SLATE (GRIS-BLEU) - PRINCIPAL ⭐
|
||||
============================================ */
|
||||
|
||||
/* Backgrounds */
|
||||
--background: oklch(0.985 0.003 230); /* Blanc grisâtre léger */
|
||||
--card: oklch(1 0 0); /* Blanc pur */
|
||||
--sidebar: oklch(0.97 0.004 230); /* Gris-bleu très pâle */
|
||||
--input: oklch(0.98 0.003 230); /* Gris-bleu pâle */
|
||||
|
||||
/* Textes */
|
||||
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
|
||||
--card-foreground: oklch(0.2 0.02 230);
|
||||
--popover-foreground: oklch(0.2 0.02 230);
|
||||
--foreground-secondary: oklch(0.45 0.015 230); /* Gris-bleu moyen */
|
||||
--foreground-muted: oklch(0.6 0.01 230); /* Gris-bleu clair */
|
||||
|
||||
/* Primary Actions - GRIS-BLEU, PAS BLEU SATURÉ */
|
||||
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
|
||||
/* Secondary */
|
||||
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
|
||||
--secondary-foreground: oklch(0.2 0.02 230);
|
||||
|
||||
/* Accents */
|
||||
--muted: oklch(0.92 0.005 230);
|
||||
--muted-foreground: oklch(0.6 0.01 230);
|
||||
--accent: oklch(0.94 0.005 230);
|
||||
--accent-foreground: oklch(0.2 0.02 230);
|
||||
|
||||
/* Functional */
|
||||
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
||||
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
|
||||
--input: oklch(0.98 0.003 230);
|
||||
--ring: oklch(0.7 0.005 230);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.97 0.004 230);
|
||||
--sidebar-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar-primary: oklch(0.45 0.08 230);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.005 230);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar-border: oklch(0.9 0.008 230);
|
||||
--sidebar-ring: oklch(0.7 0.005 230);
|
||||
|
||||
/* Popover */
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.2 0.02 230);
|
||||
|
||||
/* Charts (conservés) */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Étape 2 : Remplacez le thème dark par défaut (.dark)
|
||||
Cherchez `.dark {` dans le fichier (ligne 135)
|
||||
Remplacez TOUTES les lignes 135-167 par ceci :
|
||||
|
||||
```css
|
||||
.dark {
|
||||
/* ============================================
|
||||
THEME SLATE DARK MODE
|
||||
============================================ */
|
||||
|
||||
/* Backgrounds */
|
||||
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
|
||||
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
|
||||
--sidebar: oklch(0.12 0.005 230); /* Noir grisâtre */
|
||||
--input: oklch(0.2 0.006 230);
|
||||
|
||||
/* Textes */
|
||||
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
|
||||
--card-foreground: oklch(0.97 0.003 230);
|
||||
--popover-foreground: oklch(0.97 0.003 230);
|
||||
--foreground-secondary: oklch(0.75 0.008 230);
|
||||
--foreground-muted: oklch(0.55 0.01 230);
|
||||
--popover-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Primary Actions */
|
||||
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
|
||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
||||
|
||||
/* Secondary */
|
||||
--secondary: oklch(0.24 0.006 230);
|
||||
--secondary-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Accents */
|
||||
--muted: oklch(0.22 0.006 230);
|
||||
--muted-foreground: oklch(0.55 0.01 230);
|
||||
--accent: oklch(0.24 0.006 230);
|
||||
--accent-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Functional */
|
||||
--destructive: oklch(0.65 0.18 25);
|
||||
--border: oklch(0.28 0.01 230);
|
||||
--input: oklch(0.2 0.006 230);
|
||||
--ring: oklch(0.6 0.01 230);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.12 0.005 230);
|
||||
--sidebar-foreground: oklch(0.97 0.003 230);
|
||||
--sidebar-primary: oklch(0.55 0.08 230);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.006 230);
|
||||
--sidebar-accent-foreground: oklch(0.97 0.003 230);
|
||||
--sidebar-border: oklch(0.28 0.01 230);
|
||||
--sidebar-ring: oklch(0.6 0.01 230);
|
||||
|
||||
/* Popover */
|
||||
--popover: oklch(0.18 0.006 230);
|
||||
--popover-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Charts */
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Étape 3 : Corrigez le thème MIDNIGHT (remplacez lignes 169-188)
|
||||
Cherchez `[data-theme='midnight'] {` (ligne 169)
|
||||
Remplacez TOUTES les lignes 169-188 par ceci :
|
||||
|
||||
```css
|
||||
[data-theme='midnight'] {
|
||||
/* ============================================
|
||||
THEME MIDNIGHT - VERSION SOMBRE DE SLATE
|
||||
============================================ */
|
||||
|
||||
/* Light Mode */
|
||||
--background: oklch(0.94 0.005 250); /* Gris-bleu très pâle */
|
||||
--foreground: oklch(0.18 0.03 250); /* Gris-bleu très foncé */
|
||||
--card: oklch(0.97 0.006 250); /* Gris-bleu pâle */
|
||||
--card-foreground: oklch(0.18 0.03 250);
|
||||
--popover: oklch(0.97 0.006 250);
|
||||
--popover-foreground: oklch(0.18 0.03 250);
|
||||
--primary: oklch(0.5 0.12 250); /* Gris-bleu saturé */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
--secondary: oklch(0.2 0.01 250);
|
||||
--secondary-foreground: oklch(0.18 0.03 250);
|
||||
--muted: oklch(0.22 0.01 250);
|
||||
--muted-foreground: oklch(0.55 0.02 250);
|
||||
--accent: oklch(0.25 0.015 250);
|
||||
--accent-foreground: oklch(0.18 0.03 250);
|
||||
--destructive: oklch(0.6 0.22 25);
|
||||
--border: oklch(0.85 0.015 250);
|
||||
--input: oklch(0.25 0.01 250);
|
||||
--ring: oklch(0.65 0.015 250);
|
||||
--sidebar: oklch(0.9 0.01 250);
|
||||
--sidebar-foreground: oklch(0.18 0.03 250);
|
||||
--sidebar-primary: oklch(0.5 0.12 250);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.25 0.015 250);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.03 250);
|
||||
--sidebar-border: oklch(0.85 0.015 250);
|
||||
--sidebar-ring: oklch(0.65 0.015 250);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Étape 4 : Corrigez le thème BLUE (remplacez lignes 190-217)
|
||||
Cherchez `[data-theme='blue'] {` (ligne 190)
|
||||
Remplacez TOUTES les lignes 190-217 par ceci :
|
||||
|
||||
```css
|
||||
[data-theme='blue'] {
|
||||
/* ============================================
|
||||
THEME BLUE - VERSION SATURÉE DE SLATE
|
||||
============================================ */
|
||||
|
||||
/* Light Mode */
|
||||
--background: oklch(0.985 0.005 225); /* Blanc légèrement bleuté */
|
||||
--foreground: oklch(0.18 0.035 225); /* Gris-bleu foncé saturé */
|
||||
--card: oklch(0.98 0.01 225); /* Blanc légèrement bleuté */
|
||||
--card-foreground: oklch(0.18 0.035 225);
|
||||
--popover: oklch(0.98 0.01 225);
|
||||
--popover-foreground: oklch(0.18 0.035 225);
|
||||
--primary: oklch(0.5 0.15 225); /* Gris-bleu saturé vibrant */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
--secondary: oklch(0.93 0.008 225);
|
||||
--secondary-foreground: oklch(0.18 0.035 225);
|
||||
--muted: oklch(0.9 0.01 225);
|
||||
--muted-foreground: oklch(0.58 0.015 225);
|
||||
--accent: oklch(0.93 0.01 225);
|
||||
--accent-foreground: oklch(0.18 0.035 225);
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
--border: oklch(0.87 0.012 225);
|
||||
--input: oklch(0.95 0.01 225);
|
||||
--ring: oklch(0.65 0.015 225);
|
||||
--sidebar: oklch(0.965 0.008 225);
|
||||
--sidebar-foreground: oklch(0.18 0.035 225);
|
||||
--sidebar-primary: oklch(0.5 0.15 225);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.93 0.01 225);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.035 225);
|
||||
--sidebar-border: oklch(0.87 0.012 225);
|
||||
--sidebar-ring: oklch(0.65 0.015 225);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Étape 5 : Corrigez le thème SEPIA (remplacez lignes 219-238)
|
||||
Cherchez `[data-theme='sepia'] {` (ligne 219)
|
||||
Remplacez TOUTES les lignes 219-238 par ceci :
|
||||
|
||||
```css
|
||||
[data-theme='sepia'] {
|
||||
/* ============================================
|
||||
THEME SEPIA - VERSION CHALEUREUSE DE SLATE
|
||||
============================================ */
|
||||
|
||||
/* Light Mode */
|
||||
--background: oklch(0.985 0.004 45); /* Blanc légèrement doré */
|
||||
--foreground: oklch(0.2 0.015 45); /* Gris-brun foncé */
|
||||
--card: oklch(0.98 0.01 45); /* Blanc légèrement doré */
|
||||
--card-foreground: oklch(0.2 0.015 45);
|
||||
--popover: oklch(0.98 0.01 45);
|
||||
--popover-foreground: oklch(0.2 0.015 45);
|
||||
--primary: oklch(0.45 0.08 45); /* Gris-brun chaud */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
--secondary: oklch(0.94 0.008 45);
|
||||
--secondary-foreground: oklch(0.2 0.015 45);
|
||||
--muted: oklch(0.91 0.01 45);
|
||||
--muted-foreground: oklch(0.6 0.012 45);
|
||||
--accent: oklch(0.93 0.01 45);
|
||||
--accent-foreground: oklch(0.2 0.015 45);
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
--border: oklch(0.88 0.012 45);
|
||||
--input: oklch(0.97 0.008 45);
|
||||
--ring: oklch(0.68 0.01 45);
|
||||
--sidebar: oklch(0.96 0.01 45);
|
||||
--sidebar-foreground: oklch(0.2 0.015 45);
|
||||
--sidebar-primary: oklch(0.45 0.08 45);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.93 0.01 45);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.015 45);
|
||||
--sidebar-border: oklch(0.88 0.012 45);
|
||||
--sidebar-ring: oklch(0.68 0.01 45);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ APRÈS LES MODIFICATIONS
|
||||
|
||||
1. **Enregistrez** le fichier (`Ctrl+S`)
|
||||
2. **Allez dans vos settings** (Settings → Apparence)
|
||||
3. **Changez le thème** de 'blue' vers :
|
||||
- **'light'** pour le nouveau thème Slate principal
|
||||
- OU 'midnight' pour tester le thème nuit
|
||||
- OU 'blue' pour tester le thème bleu harmonisé
|
||||
4. **Rafraîchissez** la page (`F5` ou redémarrez le serveur)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Ce que vous devriez voir :
|
||||
|
||||
### Avec thème 'light' (nouveau Slate) :
|
||||
- Fond : blanc grisâtre
|
||||
- Texte : gris-bleu foncé
|
||||
- Boutons primary : **GRIS-BLEU DOX** (pas bleu saturé !)
|
||||
- Plus professionnel, moins fatigant
|
||||
|
||||
### Avec thème 'midnight' :
|
||||
- Fond : noir grisâtre
|
||||
- Texte : blanc grisâtre
|
||||
- Boutons : gris-bleu vibrant
|
||||
- Nuit profonde
|
||||
|
||||
### Avec thème 'blue' :
|
||||
- Fond : blanc légèrement bleuté
|
||||
- Texte : gris-bleu foncé saturé
|
||||
- Boutons : gris-bleu vibrant
|
||||
- Version énergique
|
||||
|
||||
### Avec thème 'sepia' :
|
||||
- Fond : blanc légèrement doré
|
||||
- Texte : gris-brun
|
||||
- Boutons : gris-brun
|
||||
- Version chaleureuse
|
||||
|
||||
---
|
||||
|
||||
## 🔙 ANNULER si pas satisfait :
|
||||
|
||||
Si les couleurs ne vous plaisent pas :
|
||||
```
|
||||
git checkout -- keep-notes/app/globals.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ramez, après avoir appliqué ces modifications, testez le thème 'light' pour voir le nouveau Slate !** 🎨🚀
|
||||
271
memento-note/GUIDE-TEST-COULEURS.md
Normal file
271
memento-note/GUIDE-TEST-COULEURS.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 🧪 GUIDE SIMPLE : Comment Tester le Thème Slate
|
||||
|
||||
Ramez, voici les étapes SIMPLES pour tester le nouveau thème Gris-Bleu (Slate).
|
||||
|
||||
---
|
||||
|
||||
## 📋 RÉSUMÉ
|
||||
|
||||
**Ce que j'ai fait :** Créé des fichiers de PROPOSITIONS uniquement
|
||||
|
||||
**Ce que vous devez faire :** Implémenter (copier-coller) le code dans votre application
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ÉTAPE 1 : Modifier le thème principal (5 minutes)
|
||||
|
||||
Ouvrez : `keep-notes/app/globals.css`
|
||||
|
||||
### ✏️ Remplacez les lignes 100-133 par ceci :
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* === THEME SLATE (GRIS-BLEU) MODERNE === */
|
||||
|
||||
/* Backgrounds */
|
||||
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
|
||||
--card: oklch(1 0 0); /* Blanc pur */
|
||||
--sidebar: oklch(0.97 0.004 230); /* Gris-bleu pâle */
|
||||
--input: oklch(0.98 0.003 230); /* Gris-bleu pâle */
|
||||
|
||||
/* Textes */
|
||||
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
|
||||
--card-foreground: oklch(0.2 0.02 230);
|
||||
--foreground-secondary: oklch(0.45 0.015 230); /* Gris-bleu moyen */
|
||||
--foreground-muted: oklch(0.6 0.01 230); /* Gris-bleu clair */
|
||||
--popover-foreground: oklch(0.2 0.02 230);
|
||||
|
||||
/* Primary Actions */
|
||||
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
|
||||
/* Secondary */
|
||||
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
|
||||
--secondary-foreground: oklch(0.2 0.02 230);
|
||||
|
||||
/* Accents */
|
||||
--muted: oklch(0.92 0.005 230);
|
||||
--muted-foreground: oklch(0.6 0.01 230);
|
||||
--accent: oklch(0.94 0.005 230);
|
||||
--accent-foreground: oklch(0.2 0.02 230);
|
||||
|
||||
/* Functional */
|
||||
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
||||
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
|
||||
--input: oklch(0.98 0.003 230);
|
||||
--ring: oklch(0.7 0.005 230);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar-primary: oklch(0.45 0.08 230);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.005 230);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar-border: oklch(0.9 0.008 230);
|
||||
--sidebar-ring: oklch(0.7 0.005 230);
|
||||
|
||||
/* Popover */
|
||||
--popover: oklch(1 0 0);
|
||||
|
||||
/* Charts (gardés) */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
```
|
||||
|
||||
### ✏️ Remplacez les lignes 135-167 par ceci :
|
||||
|
||||
```css
|
||||
.dark {
|
||||
/* === THEME SLATE DARK MODE === */
|
||||
|
||||
/* Backgrounds */
|
||||
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
|
||||
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
|
||||
--sidebar: oklch(0.12 0.005 230); /* Noir grisâtre */
|
||||
--input: oklch(0.2 0.006 230);
|
||||
|
||||
/* Textes */
|
||||
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
|
||||
--card-foreground: oklch(0.97 0.003 230);
|
||||
--foreground-secondary: oklch(0.75 0.008 230);
|
||||
--foreground-muted: oklch(0.55 0.01 230);
|
||||
--popover-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Primary Actions */
|
||||
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
|
||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
||||
|
||||
/* Secondary */
|
||||
--secondary: oklch(0.24 0.006 230);
|
||||
--secondary-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Accents */
|
||||
--muted: oklch(0.22 0.006 230);
|
||||
--muted-foreground: oklch(0.55 0.01 230);
|
||||
--accent: oklch(0.24 0.006 230);
|
||||
--accent-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Functional */
|
||||
--destructive: oklch(0.65 0.18 25);
|
||||
--border: oklch(0.28 0.01 230);
|
||||
--input: oklch(0.2 0.006 230);
|
||||
--ring: oklch(0.6 0.01 230);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-foreground: oklch(0.97 0.003 230);
|
||||
--sidebar-primary: oklch(0.55 0.08 230);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.006 230);
|
||||
--sidebar-accent-foreground: oklch(0.97 0.003 230);
|
||||
--sidebar-border: oklch(0.28 0.01 230);
|
||||
--sidebar-ring: oklch(0.6 0.01 230);
|
||||
|
||||
/* Popover */
|
||||
--popover: oklch(0.18 0.006 230);
|
||||
--popover-foreground: oklch(0.97 0.003 230);
|
||||
|
||||
/* Charts (gardés mais ajustés pour dark) */
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Enregistrez le fichier
|
||||
`Ctrl+S` ou `Cmd+S`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 ÉTAPE 2 : Vérifiez le résultat (immédiat !)
|
||||
|
||||
### Option A : Rafraîchir le navigateur
|
||||
Ouvrez votre application : `http://localhost:3001`
|
||||
Pressez `F5` ou `Cmd+R` pour rafraîchir
|
||||
|
||||
### Option B : Redémarrez le serveur (si nécessaire)
|
||||
Si les couleurs ne changent pas :
|
||||
1. Allez dans le terminal où tourne votre serveur
|
||||
2. Pressez `Ctrl+C` pour arrêter
|
||||
3. Relancez : `npm run dev`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ÉTAPE 3 : Testez le thème
|
||||
|
||||
### Ce que vous devriez voir :
|
||||
|
||||
**En mode light :**
|
||||
- Fond légèrement grisâtre (pas blanc pur)
|
||||
- Texte gris-bleu foncé (pas noir pur)
|
||||
- Boutons primary en gris-bleu doux (pas bleu saturé)
|
||||
- Bordures gris-bleu très clair
|
||||
|
||||
**En mode dark :**
|
||||
- Fond noir grisâtre (pas noir pur)
|
||||
- Texte blanc grisâtre (pas blanc pur)
|
||||
- Boutons primary en gris-bleu plus clair
|
||||
- Bordures gris-bleu foncé
|
||||
|
||||
### Testez les modes :
|
||||
- Basculez entre light et dark avec votre switcher
|
||||
- Observez les changements
|
||||
- Notez si vous aimez ou non
|
||||
|
||||
---
|
||||
|
||||
## ❓ RAPPEL : Pourquoi Slate ?
|
||||
|
||||
### ✅ Avantages que vous verrez :
|
||||
|
||||
1. **Plus professionnel**
|
||||
- Moins "agressif" que le bleu #356AC0 actuel
|
||||
- Plus sophistiqué et élégant
|
||||
|
||||
2. **Moins fatigant**
|
||||
- Teinte grise réduit la stimulation visuelle
|
||||
- Vos yeux reposeront plus
|
||||
|
||||
3. **Sans dégradés** 🚫
|
||||
- Couleurs plates et unies
|
||||
- Pas d'effets superflus
|
||||
- Propre et épuré
|
||||
|
||||
4. **Moderne**
|
||||
- Style Linear, Vercel, GitHub
|
||||
- Tendance 2025-2026
|
||||
|
||||
---
|
||||
|
||||
## 🔙 Annuler les changements (si vous n'aimez pas)
|
||||
|
||||
Si vous n'aimez pas le thème Slate :
|
||||
|
||||
1. Ouvrez le terminal Git
|
||||
2. Tapez : `git checkout -- keep-notes/app/globals.css`
|
||||
3. Rafraîchissez le navigateur
|
||||
4. Le thème actuel revient !
|
||||
|
||||
Ou utilisez l'annulation de votre IDE (Ctrl+Z / Cmd+Z)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Note sur les couleurs des notes
|
||||
|
||||
Les couleurs des **notes** (red, orange, yellow, etc.) ne changeront PAS avec ces modifications. C'est normal !
|
||||
|
||||
Si vous voulez aussi moderniser les couleurs des notes :
|
||||
- Faites-moi savoir
|
||||
- Je vous créerai un guide spécifique
|
||||
|
||||
---
|
||||
|
||||
## 🆘 PROBLÈMES ?
|
||||
|
||||
### Le thème ne s'applique pas :
|
||||
1. Vérifiez que vous avez bien **enregistré** le fichier (`Ctrl+S`)
|
||||
2. Videz le cache du navigateur (`Ctrl+Shift+R`)
|
||||
3. Redémarrez le serveur de dev
|
||||
|
||||
### Les couleurs sont identiques :
|
||||
1. Vérifiez que vous avez bien **remplacé** les bonnes lignes (100-133 et 135-167)
|
||||
2. Vérifiez qu'il n'y a pas d'erreurs dans la console du navigateur (`F12`)
|
||||
|
||||
### Vous voulez ajuster quelque chose :
|
||||
1. Dites-moi quoi (ex: "plus clair", "plus foncé", "autre teinte")
|
||||
2. Je vous ajusterai les valeurs OKLCH
|
||||
3. On re-teste !
|
||||
|
||||
---
|
||||
|
||||
## ✅ CHECKLIST AVANT DE TESTER
|
||||
|
||||
- [ ] J'ai ouvert `keep-notes/app/globals.css`
|
||||
- [ ] J'ai remplacé les lignes 100-133 par le nouveau code
|
||||
- [ ] J'ai remplacé les lignes 135-167 par le nouveau code
|
||||
- [ ] J'ai enregistré le fichier (`Ctrl+S`)
|
||||
- [ ] J'ai rafraîchi le navigateur (`F5` ou redémarré le serveur)
|
||||
- [ ] J'ai testé en mode light
|
||||
- [ ] J'ai testé en mode dark
|
||||
|
||||
---
|
||||
|
||||
## 💬 RAMEZ : Après avoir testé
|
||||
|
||||
Dites-moi :
|
||||
- ✅ "J'aime, c'est parfait !" → On peut l'adopter définitivement
|
||||
- 🔶 "C'est bien, mais..." → Dites-moi quoi changer
|
||||
- ❌ "Je n'aime pas du tout" → Testons une autre option (Monochrome, Indigo, ou Teal)
|
||||
|
||||
---
|
||||
|
||||
**Bon test Ramez !** 🧪
|
||||
|
||||
*Guide créé par Amelia - Developer Agent* 💻
|
||||
332
memento-note/PROPOSITION-COULEURS.md
Normal file
332
memento-note/PROPOSITION-COULEURS.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 🎨 Proposition d'Harmonie de Couleurs pour Memento
|
||||
|
||||
Ramez, voici mon analyse et proposition détaillée pour améliorer l'harmonie des couleurs de votre application.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analyse Actuelle
|
||||
|
||||
### Points Positifs ✅
|
||||
- **OKLCH** : Utilisation moderne d'un espace couleur perceptuel
|
||||
- **Système de thèmes** : Light, Dark, Midnight, Blue, Sepia disponibles
|
||||
- **Tailwind CSS** : Intégration fluide avec les utilitaires
|
||||
- **Couleurs de notes** : Palette variée (default, red, orange, yellow, green, teal, blue, purple, pink, gray)
|
||||
|
||||
### Points à Améliorer ⚠️
|
||||
1. **Cohérence de teinte** : Les couleurs utilisent des teintes différentes sans harmonie commune
|
||||
2. **Contraste texte** : Le texte par défaut `oklch(0.145 0 0)` est un peu sombre en mode light
|
||||
3. **Saturation** : Certains éléments manque de vibrance (primary actuel `oklch(0.205 0 0)` est gris)
|
||||
4. **Déclinaisons dark** : Les versions sombres des notes pourraient être plus cohérentes
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Proposition d'Amélioration
|
||||
|
||||
### 1. Thème Principal Unifié
|
||||
|
||||
**Approche : Harmonie de teinte (bleu 250°)**
|
||||
|
||||
Toutes les couleurs de l'interface partagent une teinte de base bleutée (250°) :
|
||||
|
||||
```css
|
||||
/* Fond plus clair et légèrement bleuté */
|
||||
--background: oklch(0.99 0.002 250);
|
||||
|
||||
/* Texte plus sombre pour meilleur contraste */
|
||||
--foreground: oklch(0.18 0.01 250);
|
||||
|
||||
/* Primary bleu Keep vibrant */
|
||||
--primary: oklch(0.55 0.2 250);
|
||||
--primary-hover: oklch(0.5 0.22 250);
|
||||
```
|
||||
|
||||
**Avantages :**
|
||||
- Cohérence visuelle immédiate
|
||||
- Réduction de la fatigue oculaire
|
||||
- Identité de marque plus forte
|
||||
|
||||
### 2. Palette de Notes Améliorée
|
||||
|
||||
**Nouvelles couleurs de notes :**
|
||||
|
||||
| Couleur | Light Mode | Dark Mode | Texte Light | Texte Dark |
|
||||
|---------|------------|-----------|--------------|-------------|
|
||||
| **default** | `bg-white` | `dark:bg-neutral-900` | `text-neutral-900` | `dark:text-neutral-100` |
|
||||
| **red** | `bg-red-50` | `dark:bg-red-950/40` | `text-red-950` | `dark:text-red-100` |
|
||||
| **orange** | `bg-orange-50` | `dark:bg-orange-950/40` | `text-orange-950` | `dark:text-orange-100` |
|
||||
| **yellow** | `bg-yellow-50` | `dark:bg-yellow-950/40` | `text-yellow-950` | `dark:text-yellow-100` |
|
||||
| **green** | `bg-emerald-50` | `dark:bg-emerald-950/40` | `text-emerald-950` | `dark:text-emerald-100` |
|
||||
| **teal** | `bg-teal-50` | `dark:bg-teal-950/40` | `text-teal-950` | `dark:text-teal-100` |
|
||||
| **blue** | `bg-blue-50` | `dark:bg-blue-950/40` | `text-blue-950` | `dark:text-blue-100` |
|
||||
| **indigo** | `bg-indigo-50` | `dark:bg-indigo-950/40` | `text-indigo-950` | `dark:text-indigo-100` |
|
||||
| **violet** | `bg-violet-50` | `dark:bg-violet-950/40` | `text-violet-950` | `dark:text-violet-100` |
|
||||
| **purple** | `bg-purple-50` | `dark:bg-purple-950/40` | `text-purple-950` | `dark:text-purple-100` |
|
||||
| **pink** | `bg-pink-50` | `dark:bg-pink-950/40` | `text-pink-950` | `dark:text-pink-100` |
|
||||
| **rose** | `bg-rose-50` | `dark:bg-rose-950/40` | `text-rose-950` | `dark:text-rose-100` |
|
||||
| **gray** | `bg-neutral-100` | `dark:bg-neutral-800` | `text-neutral-900` | `dark:text-neutral-100` |
|
||||
|
||||
**Nouvelles couleurs ajoutées :**
|
||||
- **indigo** : Entre bleu et violet, très moderne
|
||||
- **rose** : Alternative au pink, plus chaud
|
||||
- **emerald** : Remplace green avec une teinte plus riche
|
||||
|
||||
### 3. Accessibilité WCAG AA+
|
||||
|
||||
Tous les contrastes respectent ou dépassent 4.5:1 (WCAG AA)
|
||||
|
||||
**Exemples de contraste :**
|
||||
- Texte sur fond blanc : `15.5:1` (Excellent)
|
||||
- Texte sur note bleue claire : `7.2:1` (Excellent)
|
||||
- Texte sur note jaune : `6.8:1` (Excellent)
|
||||
- Primary sur fond : `4.8:1` (AA+)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Implementation Technique
|
||||
|
||||
### Fichiers à modifier
|
||||
|
||||
1. **`keep-notes/app/globals.css`**
|
||||
- Mettre à jour les variables CSS du thème
|
||||
- Ajouter la nouvelle palette OKLCH
|
||||
|
||||
2. **`keep-notes/lib/types.ts`**
|
||||
- Remplacer `NOTE_COLORS` par `RECOMMENDED_NOTE_COLORS`
|
||||
- Ajouter les nouvelles couleurs (indigo, rose)
|
||||
- Remplacer green par emerald
|
||||
|
||||
3. **Composants utilisant les couleurs :**
|
||||
- `note-card.tsx`
|
||||
- `note-input.tsx`
|
||||
- `note-editor.tsx`
|
||||
- `note-actions.tsx`
|
||||
- `notebooks-list.tsx`
|
||||
|
||||
### Exemple de migration
|
||||
|
||||
```typescript
|
||||
// AVANT (keep-notes/lib/types.ts)
|
||||
export const NOTE_COLORS = {
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
hover: 'hover:bg-blue-100 dark:hover:bg-blue-950/50',
|
||||
card: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-900/50'
|
||||
},
|
||||
// ...
|
||||
};
|
||||
|
||||
// APRÈS (recommandé)
|
||||
export const NOTE_COLORS = {
|
||||
blue: {
|
||||
bg: 'bg-blue-50',
|
||||
'bg-dark': 'dark:bg-blue-950/40',
|
||||
hover: 'hover:bg-blue-100',
|
||||
'hover-dark': 'dark:hover:bg-blue-950/60',
|
||||
text: 'text-blue-950',
|
||||
'text-dark': 'dark:text-blue-100',
|
||||
},
|
||||
// ... avec indigo, rose, emerald ajoutés
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Comparaison Avant/Après
|
||||
|
||||
### Avant
|
||||
```tsx
|
||||
<div className={`
|
||||
${colorClasses.bg}
|
||||
${colorClasses.card}
|
||||
${colorClasses.hover}
|
||||
`}>
|
||||
<p className="text-foreground">{content}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Problèmes :**
|
||||
- Le texte ne s'adapte pas toujours à la couleur de la note
|
||||
- Les bordures sont hardcoded dans chaque couleur
|
||||
- Manque de flexibilité
|
||||
|
||||
### Après
|
||||
```tsx
|
||||
<div className={`
|
||||
${noteColors.bg}
|
||||
dark:${noteColors['bg-dark']}
|
||||
${noteColors.border}
|
||||
dark:${noteColors['border-dark']}
|
||||
${noteColors.text}
|
||||
dark:${noteColors['text-dark']}
|
||||
hover:${noteColors.hover}
|
||||
dark:hover:${noteColors['hover-dark']}
|
||||
`}>
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Avantages :**
|
||||
- Texte automatiquement adapté à la couleur de fond
|
||||
- Gestion centralisée des bordures
|
||||
- Support dark mode par défaut
|
||||
- Extensible facilement
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visualisation des Palettes
|
||||
|
||||
### Thème Light Mode
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Background ████████ #FFFFFF │
|
||||
│ Card ████████ #FFFFFF │
|
||||
│ Sidebar ████████ #F5F6F8 │
|
||||
│ Primary ████████ #356AC0 │ ← Bleu Keep
|
||||
│ Text ████████ #2D3748 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Thème Dark Mode
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Background ████████ #1A202C │
|
||||
│ Card ████████ #2D3748 │
|
||||
│ Sidebar ████████ #171923 │
|
||||
│ Primary ████████ #4A7FD4 │
|
||||
│ Text ████████ #F7FAFC │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Palette de Notes (mode light)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Default ████████████ White │
|
||||
│ Red ████████████ #FEF2F2 (red-50) │
|
||||
│ Orange ████████████ #FFF7ED (orange-50) │
|
||||
│ Yellow ████████████ #FEFCE8 (yellow-50) │
|
||||
│ Green ████████████ #ECFDF5 (emerald-50) │
|
||||
│ Teal ████████████ #F0FDFA (teal-50) │
|
||||
│ Blue ████████████ #EFF6FF (blue-50) │
|
||||
│ Indigo ████████████ #EEF2FF (indigo-50) │
|
||||
│ Violet ████████████ #F5F3FF (violet-50) │
|
||||
│ Purple ████████████ #FAF5FF (purple-50) │
|
||||
│ Pink ████████████ #FDF2F8 (pink-50) │
|
||||
│ Rose ████████████ #FFF1F2 (rose-50) │
|
||||
│ Gray ████████████ #F5F5F4 (neutral-100) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Avantages de la Proposition
|
||||
|
||||
### 1. Cohérence Visuelle
|
||||
- Thème unifié avec teinte de base (bleu 250°)
|
||||
- Toutes les couleurs harmonisent ensemble
|
||||
- Identité de marque plus forte
|
||||
|
||||
### 2. Meilleure Accessibilité
|
||||
- Respect WCAG AA+ (contraste ≥ 4.5:1)
|
||||
- Texte adapté automatiquement à la couleur de fond
|
||||
- Support mode contraste élevé
|
||||
|
||||
### 3. Modernité
|
||||
- Utilisation d'OKLCH (espace couleur perceptuel)
|
||||
- Palette de 13 couleurs au lieu de 9
|
||||
- Couleurs actuelles et tendance (indigo, rose, emerald)
|
||||
|
||||
### 4. Maintenance Facilitée
|
||||
- Système centralisé et extensible
|
||||
- Documenté avec TypeScript
|
||||
- Facile à ajuster
|
||||
|
||||
### 5. Performance OKLCH
|
||||
- Perception humaine plus uniforme
|
||||
- Transition fluide entre light/dark
|
||||
- Moins de fatigue visuelle
|
||||
|
||||
---
|
||||
|
||||
## 📝 Plan d'Implémentation
|
||||
|
||||
### Phase 1 : Préparation (5 min)
|
||||
1. ✅ Créer le fichier `color-harmony-recommendation.ts`
|
||||
2. ✅ Documenter la proposition dans ce fichier
|
||||
|
||||
### Phase 2 : Migration (30 min)
|
||||
1. Mettre à jour `keep-notes/app/globals.css`
|
||||
2. Mettre à jour `keep-notes/lib/types.ts`
|
||||
3. Mettre à jour les composants concernés
|
||||
|
||||
### Phase 3 : Tests (15 min)
|
||||
1. Tester en mode light
|
||||
2. Tester en mode dark
|
||||
3. Tester chaque couleur de note
|
||||
4. Vérifier les contrastes WCAG
|
||||
|
||||
### Phase 4 : Ajustements (optionnel)
|
||||
1. Affiner selon vos préférences
|
||||
2. Ajouter d'autres couleurs si nécessaire
|
||||
3. Ajuster les saturations si trop/peu vibrant
|
||||
|
||||
---
|
||||
|
||||
## 💡 Recommandations Personnalisées
|
||||
|
||||
Ramez, voici mes recommandations spécifiques pour votre cas :
|
||||
|
||||
### Immédiat
|
||||
✅ **Adopter la teinte unifiée (bleu 250°)** pour le thème principal
|
||||
✅ **Remplacer green par emerald** pour une teinte plus riche
|
||||
✅ **Ajouter indigo et rose** pour plus de variété
|
||||
|
||||
### À terme
|
||||
🔶 Créer un "theme builder" pour que les utilisateurs puissent personnaliser
|
||||
🔶 Ajouter des présets de couleurs saisonnières (automne, hiver, printemps, été)
|
||||
🔶 Implémenter des animations de transition de couleur plus fluides
|
||||
|
||||
---
|
||||
|
||||
## 📦 Ressources Créées
|
||||
|
||||
J'ai créé les fichiers suivants pour vous aider :
|
||||
|
||||
1. **`keep-notes/lib/color-harmony-recommendation.ts`**
|
||||
- Code TypeScript complet avec toutes les couleurs
|
||||
- Exemples d'utilisation
|
||||
- Documentation inline
|
||||
- Types TypeScript
|
||||
|
||||
2. **`keep-notes/PROPOSITION-COULEURS.md`**
|
||||
- Ce document d'explication
|
||||
- Comparaisons avant/après
|
||||
- Plan d'implémentation
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Vos Options
|
||||
|
||||
**Option 1 : Adoption complète**
|
||||
- Mettre en place toute la proposition
|
||||
- Meilleure cohérence et accessibilité
|
||||
- ~45 min de travail
|
||||
|
||||
**Option 2 : Adoption progressive**
|
||||
- Commencer par le thème principal uniquement
|
||||
- Tester avec les utilisateurs
|
||||
- Étendre aux notes plus tard
|
||||
- ~20 min de travail initial
|
||||
|
||||
**Option 3 : Personnalisation**
|
||||
- Utiliser comme base et adapter selon vos goûts
|
||||
- Modifier les teintes/saturations
|
||||
- Garder la structure proposée
|
||||
|
||||
---
|
||||
|
||||
Ramez, cette proposition est prête à l'emploi ! 🚀
|
||||
|
||||
Voulez-vous que je procède à l'implémentation complète (Option 1), progressive (Option 2), ou préférez-vous l'adapter selon vos goûts personnels (Option 3) ?
|
||||
|
||||
---
|
||||
*Analyse et proposition créées par Amelia - Developer Agent* 💻
|
||||
242
memento-note/PROPOSITION-SLATE-MODERNE.md
Normal file
242
memento-note/PROPOSITION-SLATE-MODERNE.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 🎨 Proposition : Gris-Bleu (Slate) Moderne
|
||||
|
||||
Ramez, excellente idée ! Le **Gris-Bleu (Slate)** est le choix parfait pour une application moderne et professionnelle. C'est élégant, discret et ne fatigue absolument pas les yeux.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Pourquoi le Slate (Gris-Bleu) ?
|
||||
|
||||
### ✅ Avantages majeurs
|
||||
|
||||
1. **Ultra Moderne**
|
||||
- Utilisé par les meilleures applications : Linear, Vercel, GitHub, Raycast
|
||||
- Tendance 2025-2026 en design systems
|
||||
|
||||
2. **Professionnel et Sophistiqué**
|
||||
- Pas de couleur "agressive" ou "enfantin"
|
||||
- Transmet confiance et sérieux
|
||||
- Parfait pour une application de productivité
|
||||
|
||||
3. **Minimal Fatigue Oculaire**
|
||||
- Teinte grise réduite la stimulation visuelle
|
||||
- Contraste naturel et équilibré
|
||||
- Idéal pour une utilisation prolongée
|
||||
|
||||
4. **Pas de Dégradés** 🚫
|
||||
- Couleurs plates et unies (flat design)
|
||||
- Pas d'effets superflus
|
||||
- Propre et épuré
|
||||
|
||||
5. **Différent du Bleu Traditionnel**
|
||||
- Plus subtil et élégant
|
||||
- Ne ressemble pas aux apps "corporate"
|
||||
- Identité unique
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Palette Slate Complète
|
||||
|
||||
### Code Couleur OKLCH
|
||||
|
||||
```
|
||||
Teinte (Hue) : 230°
|
||||
↳ Entre le bleu pur (240°) et le cyan (180°)
|
||||
↳ Une touche subtile de bleu dans un gris neutre
|
||||
```
|
||||
|
||||
### Light Mode
|
||||
```css
|
||||
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
|
||||
--card: oklch(1 0 0); /* Blanc pur */
|
||||
--sidebar: oklch(0.97 0.004 230); /* Gris-bleu pâle */
|
||||
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
|
||||
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
|
||||
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
```css
|
||||
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
|
||||
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
|
||||
--sidebar: oklch(0.12 0.005 230); /* Noir grisâtre */
|
||||
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
|
||||
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
|
||||
--border: oklch(0.28 0.01 230); /* Gris-bleu foncé clair */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Visualisation
|
||||
|
||||
### Palette Light
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Background ████████ #F8F9FB │
|
||||
│ Card ████████ #FFFFFF │
|
||||
│ Sidebar ████████ #F3F4F6 │
|
||||
│ Primary ████████ #7A8A9A │ ← Slate doux
|
||||
│ Text ████████ #3B4252 │
|
||||
│ Border ████████ #E5E7EB │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Palette Dark
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Background ████████ #1F2937 │
|
||||
│ Card ████████ #2D3748 │
|
||||
│ Sidebar ████████ #1A202C │
|
||||
│ Primary ████████ #9CA3AF │ ← Slate clair
|
||||
│ Text ████████ #F7FAFC │
|
||||
│ Border ████████ #4A5568 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Comparaison avec Bleu Traditionnel
|
||||
```
|
||||
Bleu Keep traditionnel: ████████ #356AC0 ← Très saturé, agressif
|
||||
Slate moderne: ████████ #7A8A9A ← Élégant, apaisant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌈 Palette des Notes avec Slate
|
||||
|
||||
Les notes gardent leurs couleurs variées mais avec une cohérence Slate :
|
||||
|
||||
| Couleur | Light Mode | Dark Mode | Texte |
|
||||
|---------|------------|-----------|-------|
|
||||
| **default** | `bg-white` | `dark:bg-slate-900` | Slate foncé |
|
||||
| **red** | `bg-red-50` | `dark:bg-red-950/40` | Rouge foncé |
|
||||
| **orange** | `bg-orange-50` | `dark:bg-orange-950/40` | Orange foncé |
|
||||
| **yellow** | `bg-yellow-50` | `dark:bg-yellow-950/40` | Jaune foncé |
|
||||
| **emerald** | `bg-emerald-50` | `dark:bg-emerald-950/40` | Vert foncé |
|
||||
| **teal** | `bg-teal-50` | `dark:bg-teal-950/40` | Teal foncé |
|
||||
| **blue** | `bg-sky-50` | `dark:bg-sky-950/40` | Bleu ciel foncé |
|
||||
| **indigo** | `bg-indigo-50` | `dark:bg-indigo-950/40` | Indigo foncé |
|
||||
| **violet** | `bg-violet-50` | `dark:bg-violet-950/40` | Violet foncé |
|
||||
| **purple** | `bg-purple-50` | `dark:bg-purple-950/40` | Pourpre foncé |
|
||||
| **pink** | `bg-pink-50` | `dark:bg-pink-950/40` | Rose foncé |
|
||||
| **rose** | `bg-rose-50` | `dark:bg-rose-950/40` | Rose rougeâtre foncé |
|
||||
| **gray** | `bg-slate-100` | `dark:bg-slate-800` | Slate très foncé |
|
||||
|
||||
### Note : J'ai remplacé `blue-50` par `sky-50` pour éviter la confusion avec le thème Slate
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Autres Options Modernes
|
||||
|
||||
Si vous voulez explorer d'autres couleurs, voici 3 alternatives :
|
||||
|
||||
### Option 2 : Monochrome Gris ⚫
|
||||
- Ultra minimaliste
|
||||
- Style Apple, Linear, Stripe
|
||||
- Absolument aucune couleur sauf fonctionnelle
|
||||
- Très professionnel
|
||||
|
||||
### Option 3 : Violet Profond 💜
|
||||
- Élégant et unique
|
||||
- Style Discord, Notion, Figma
|
||||
- Entre bleu et violet
|
||||
- Moderne et vibrant sans être agressif
|
||||
|
||||
### Option 4 : Teal (Turquoise) 🌊
|
||||
- Moderne et rafraîchissant
|
||||
- Style Atlassian, Linear
|
||||
- Entre bleu et vert
|
||||
- Très apprécié dans le design actuel
|
||||
|
||||
---
|
||||
|
||||
## 📋 Comparaison des 4 Options
|
||||
|
||||
| Critère | Slate ⭐ | Monochrome | Indigo | Teal |
|
||||
|---------|----------|------------|---------|------|
|
||||
| **Modernité** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Professionnalisme** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Fatigue oculaire** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Unicité** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Tendance 2025** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Popularité** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
|
||||
**Gagnant : Slate (Gris-Bleu)** 🏆
|
||||
|
||||
---
|
||||
|
||||
## 💡 Recommandation
|
||||
|
||||
Ramez, je recommande fortement le **Slate (Gris-Bleu)** pour les raisons suivantes :
|
||||
|
||||
### 1. Correspond à votre demande
|
||||
✅ Moderne
|
||||
✅ Pas de dégradés
|
||||
✅ Gris-bleu comme suggéré
|
||||
✅ Différent du bleu traditionnel
|
||||
|
||||
### 2. Idéal pour votre application
|
||||
✅ Application de notes (besoin de calme et focus)
|
||||
✅ Utilisation quotidienne prolongée
|
||||
✅ Interface professionnelle
|
||||
✅ Fatigue oculaire minimale
|
||||
|
||||
### 3. Tendance et pérenne
|
||||
✅ Adopté par les meilleurs produits (Linear, Vercel)
|
||||
✅ Design system de référence en 2025
|
||||
✅ Ne sera pas "passé de mode" rapidement
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Implémentation
|
||||
|
||||
J'ai créé 2 fichiers pour vous :
|
||||
|
||||
### 1. `keep-notes/lib/modern-color-options.ts`
|
||||
Contient les 4 options complètes avec code OKLCH prêt à l'emploi :
|
||||
- Slate (Gris-Bleu) - **Recommandé**
|
||||
- Monochrome Gris
|
||||
- Violet Profond (Indigo)
|
||||
- Teal (Turquoise)
|
||||
|
||||
### 2. Ce document `PROPOSITION-SLATE-MODERNE.md`
|
||||
Détails complets de la proposition Slate
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Votre Choix
|
||||
|
||||
Ramez, voici vos options maintenant :
|
||||
|
||||
**Option A** : Adopter le Slate (Gris-Bleu) 🏆
|
||||
- Ma recommandation principale
|
||||
- Moderne, professionnel, apaisant
|
||||
- Correspond parfaitement à votre demande
|
||||
|
||||
**Option B** : Tester d'autres options
|
||||
- Voir les alternatives (Monochrome, Indigo, Teal)
|
||||
- Choisir selon vos goûts personnels
|
||||
|
||||
**Option C** : Personnaliser
|
||||
- Utiliser Slate comme base
|
||||
- Ajuster la saturation ou la teinte selon vos préférences
|
||||
|
||||
**Quelle option préférez-vous ?** 🎨
|
||||
|
||||
---
|
||||
|
||||
## 📊 Références Inspirantes
|
||||
|
||||
Voici des applications qui utilisent le Slate avec succès :
|
||||
|
||||
- **Linear.app** - Design moderne par excellence
|
||||
- **Vercel.com** - Professionalisme et élégance
|
||||
- **GitHub.com** - Interface propre et lisible
|
||||
- **Raycast.com** - Minimaliste et efficace
|
||||
- **Stripe.com** - Sophistiqué et trust-building
|
||||
|
||||
Toutes ces applications sont considérées comme des références en design moderne 2025 !
|
||||
|
||||
---
|
||||
|
||||
*Proposition créée par Amelia - Developer Agent* 💻
|
||||
|
||||
**Prêt à implémenter le Slate moderne ?** 🚀
|
||||
104
memento-note/README.md
Normal file
104
memento-note/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Memento - Google Keep Clone
|
||||
|
||||
A beautiful and feature-rich Google Keep clone built with modern web technologies.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 📝 **Create & Edit Notes**: Quick note creation with expandable input
|
||||
- ☑️ **Checklist Support**: Create todo lists with checkable items
|
||||
- 🎨 **Color Customization**: 10 beautiful color themes for organizing notes
|
||||
- 📌 **Pin Notes**: Keep important notes at the top
|
||||
- 📦 **Archive**: Archive notes you want to keep but don't need to see
|
||||
- 🏷️ **Labels**: Organize notes with custom labels
|
||||
- 🔍 **Real-time Search**: Instantly search through all your notes
|
||||
- 🌓 **Dark Mode**: Beautiful dark theme with system preference detection
|
||||
- 📱 **Fully Responsive**: Works perfectly on desktop, tablet, and mobile
|
||||
- ⚡ **Server Actions**: Lightning-fast CRUD operations with Next.js 16
|
||||
- 🎯 **Type-Safe**: Full TypeScript support throughout
|
||||
|
||||
## 🚀 Tech Stack
|
||||
|
||||
### Frontend
|
||||
- **Next.js 16** - React framework with App Router
|
||||
- **TypeScript** - Type safety and better DX
|
||||
- **Tailwind CSS 4** - Utility-first CSS framework
|
||||
- **shadcn/ui** - Beautiful, accessible UI components
|
||||
- **Lucide React** - Modern icon library
|
||||
|
||||
### Backend
|
||||
- **Next.js Server Actions** - Server-side mutations
|
||||
- **Prisma ORM** - Type-safe database client
|
||||
- **SQLite** - Lightweight database (easily switchable to PostgreSQL)
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd keep-notes
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up the database**
|
||||
```bash
|
||||
npx prisma generate
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
4. **Start the development server**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. **Open your browser**
|
||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
361
memento-note/THEMES-HARMONISES-SLATE.md
Normal file
361
memento-note/THEMES-HARMONISES-SLATE.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# 🎨 Thèmes Harmonisés avec Slate
|
||||
|
||||
Ramez, voici les thèmes **midnight**, **blue** et **sepia** harmonisés avec le thème **Slate** principal.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Tableau des Thèmes
|
||||
|
||||
| Thème | Teinte OKLCH | Caractère | Description |
|
||||
|--------|----------------|-------------|-------------|
|
||||
| **Slate** | 230° | Gris-bleu élégant | Thème principal ⭐ |
|
||||
| **Midnight** | 250° | Gris-bleu très sombre | Nuit profonde |
|
||||
| **Blue** | 225° | Gris-bleu saturé | Version vibrant |
|
||||
| **Sepia** | 45° | Gris-brun chaud | Vintage chaleureux |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Thème SLATE (Principal) - TEINTE 230°
|
||||
|
||||
### Light Mode
|
||||
```css
|
||||
[data-theme='slate'] {
|
||||
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
|
||||
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
|
||||
--card: oklch(1 0 0); /* Blanc pur */
|
||||
--card-foreground: oklch(0.2 0.02 230);
|
||||
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
|
||||
--secondary-foreground: oklch(0.2 0.02 230);
|
||||
--muted: oklch(0.92 0.005 230);
|
||||
--muted-foreground: oklch(0.6 0.01 230);
|
||||
--accent: oklch(0.94 0.005 230);
|
||||
--accent-foreground: oklch(0.2 0.02 230);
|
||||
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
||||
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
|
||||
--input: oklch(0.98 0.003 230);
|
||||
--ring: oklch(0.7 0.005 230);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar: oklch(0.97 0.004 230);
|
||||
--sidebar-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar-primary: oklch(0.45 0.08 230);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.005 230);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar-border: oklch(0.9 0.008 230);
|
||||
--sidebar-ring: oklch(0.7 0.005 230);
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
```css
|
||||
[data-theme='slate'].dark {
|
||||
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
|
||||
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
|
||||
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
|
||||
--card-foreground: oklch(0.97 0.003 230);
|
||||
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
|
||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
||||
--secondary: oklch(0.24 0.006 230);
|
||||
--secondary-foreground: oklch(0.97 0.003 230);
|
||||
--muted: oklch(0.22 0.006 230);
|
||||
--muted-foreground: oklch(0.55 0.01 230);
|
||||
--accent: oklch(0.24 0.006 230);
|
||||
--accent-foreground: oklch(0.97 0.003 230);
|
||||
--destructive: oklch(0.65 0.18 25);
|
||||
--border: oklch(0.28 0.01 230);
|
||||
--input: oklch(0.2 0.006 230);
|
||||
--ring: oklch(0.6 0.01 230);
|
||||
--popover: oklch(0.18 0.006 230);
|
||||
--popover-foreground: oklch(0.97 0.003 230);
|
||||
--sidebar: oklch(0.12 0.005 230);
|
||||
--sidebar-foreground: oklch(0.97 0.003 230);
|
||||
--sidebar-primary: oklch(0.55 0.08 230);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.006 230);
|
||||
--sidebar-accent-foreground: oklch(0.97 0.003 230);
|
||||
--sidebar-border: oklch(0.28 0.01 230);
|
||||
--sidebar-ring: oklch(0.6 0.01 230);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌙 Thème MIDNIGHT - TEINTE 250° (Valeurs harmonisées)
|
||||
|
||||
**Description : Version plus sombre et profonde de Slate, idéal pour nuit**
|
||||
|
||||
### Light Mode
|
||||
```css
|
||||
[data-theme='midnight'] {
|
||||
--background: oklch(0.94 0.005 250); /* Gris-bleu très pâle */
|
||||
--foreground: oklch(0.18 0.03 250); /* Gris-bleu très foncé */
|
||||
--card: oklch(0.97 0.006 250); /* Gris-bleu pâle */
|
||||
--card-foreground: oklch(0.18 0.03 250);
|
||||
--primary: oklch(0.5 0.12 250); /* Gris-bleu saturé */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
--secondary: oklch(0.2 0.01 250);
|
||||
--secondary-foreground: oklch(0.18 0.03 250);
|
||||
--muted: oklch(0.22 0.01 250);
|
||||
--muted-foreground: oklch(0.55 0.02 250);
|
||||
--accent: oklch(0.25 0.015 250);
|
||||
--accent-foreground: oklch(0.18 0.03 250);
|
||||
--destructive: oklch(0.6 0.22 25);
|
||||
--border: oklch(0.85 0.015 250);
|
||||
--input: oklch(0.25 0.01 250);
|
||||
--ring: oklch(0.65 0.015 250);
|
||||
--popover: oklch(0.97 0.006 250);
|
||||
--popover-foreground: oklch(0.18 0.03 250);
|
||||
--sidebar: oklch(0.9 0.01 250);
|
||||
--sidebar-foreground: oklch(0.18 0.03 250);
|
||||
--sidebar-primary: oklch(0.5 0.12 250);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.25 0.015 250);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.03 250);
|
||||
--sidebar-border: oklch(0.85 0.015 250);
|
||||
--sidebar-ring: oklch(0.65 0.015 250);
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode (midnight ajoute aussi class "dark")
|
||||
```css
|
||||
[data-theme='midnight'].dark {
|
||||
--background: oklch(0.1 0.01 250); /* Noir profond */
|
||||
--foreground: oklch(0.96 0.005 250); /* Blanc grisâtre */
|
||||
--card: oklch(0.15 0.015 250); /* Gris-bleu très foncé */
|
||||
--card-foreground: oklch(0.96 0.005 250);
|
||||
--primary: oklch(0.6 0.12 250); /* Gris-bleu vibrant */
|
||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
||||
--secondary: oklch(0.18 0.015 250);
|
||||
--secondary-foreground: oklch(0.96 0.005 250);
|
||||
--muted: oklch(0.2 0.015 250);
|
||||
--muted-foreground: oklch(0.5 0.02 250);
|
||||
--accent: oklch(0.22 0.02 250);
|
||||
--accent-foreground: oklch(0.96 0.005 250);
|
||||
--destructive: oklch(0.65 0.2 25);
|
||||
--border: oklch(0.3 0.02 250);
|
||||
--input: oklch(0.22 0.02 250);
|
||||
--ring: oklch(0.55 0.02 250);
|
||||
--popover: oklch(0.15 0.015 250);
|
||||
--popover-foreground: oklch(0.96 0.005 250);
|
||||
--sidebar: oklch(0.08 0.01 250);
|
||||
--sidebar-foreground: oklch(0.96 0.005 250);
|
||||
--sidebar-primary: oklch(0.6 0.12 250);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.02 250);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.005 250);
|
||||
--sidebar-border: oklch(0.3 0.02 250);
|
||||
--sidebar-ring: oklch(0.55 0.02 250);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💎 Thème BLUE - TEINTE 225° (Saturé)
|
||||
|
||||
**Description : Version plus vibrante et saturée de Slate, garde le côté bleu mais élégant**
|
||||
|
||||
### Light Mode
|
||||
```css
|
||||
[data-theme='blue'] {
|
||||
--background: oklch(0.985 0.005 225); /* Blanc légèrement bleuté */
|
||||
--foreground: oklch(0.18 0.035 225); /* Gris-bleu foncé saturé */
|
||||
--card: oklch(1 0 0); /* Blanc pur */
|
||||
--card-foreground: oklch(0.18 0.035 225);
|
||||
--primary: oklch(0.5 0.15 225); /* Bleu vibrant */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
--secondary: oklch(0.93 0.008 225);
|
||||
--secondary-foreground: oklch(0.18 0.035 225);
|
||||
--muted: oklch(0.9 0.01 225);
|
||||
--muted-foreground: oklch(0.58 0.015 225);
|
||||
--accent: oklch(0.93 0.01 225);
|
||||
--accent-foreground: oklch(0.18 0.035 225);
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
--border: oklch(0.87 0.012 225);
|
||||
--input: oklch(0.95 0.01 225);
|
||||
--ring: oklch(0.65 0.015 225);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.18 0.035 225);
|
||||
--sidebar: oklch(0.965 0.008 225);
|
||||
--sidebar-foreground: oklch(0.18 0.035 225);
|
||||
--sidebar-primary: oklch(0.5 0.15 225);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.93 0.01 225);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.035 225);
|
||||
--sidebar-border: oklch(0.87 0.012 225);
|
||||
--sidebar-ring: oklch(0.65 0.015 225);
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
```css
|
||||
[data-theme='blue'].dark {
|
||||
--background: oklch(0.13 0.008 225); /* Noir légèrement bleuté */
|
||||
--foreground: oklch(0.97 0.006 225); /* Blanc légèrement bleuté */
|
||||
--card: oklch(0.17 0.01 225); /* Gris-bleu foncé */
|
||||
--card-foreground: oklch(0.97 0.006 225);
|
||||
--primary: oklch(0.6 0.15 225); /* Bleu vibrant plus clair */
|
||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
||||
--secondary: oklch(0.22 0.015 225);
|
||||
--secondary-foreground: oklch(0.97 0.006 225);
|
||||
--muted: oklch(0.25 0.02 225);
|
||||
--muted-foreground: oklch(0.52 0.018 225);
|
||||
--accent: oklch(0.25 0.025 225);
|
||||
--accent-foreground: oklch(0.97 0.006 225);
|
||||
--destructive: oklch(0.65 0.22 25);
|
||||
--border: oklch(0.32 0.018 225);
|
||||
--input: oklch(0.25 0.02 225);
|
||||
--ring: oklch(0.55 0.02 225);
|
||||
--popover: oklch(0.17 0.01 225);
|
||||
--popover-foreground: oklch(0.97 0.006 225);
|
||||
--sidebar: oklch(0.1 0.01 225);
|
||||
--sidebar-foreground: oklch(0.97 0.006 225);
|
||||
--sidebar-primary: oklch(0.6 0.15 225);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.25 0.025 225);
|
||||
--sidebar-accent-foreground: oklch(0.97 0.006 225);
|
||||
--sidebar-border: oklch(0.32 0.018 225);
|
||||
--sidebar-ring: oklch(0.55 0.02 225);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 Thème SEPIA - TEINTE 45° (Chaleureux)
|
||||
|
||||
**Description : Version vintage chaleureuse avec une touche dorée/brun, garde le gris comme base**
|
||||
|
||||
### Light Mode
|
||||
```css
|
||||
[data-theme='sepia'] {
|
||||
--background: oklch(0.985 0.004 45); /* Blanc légèrement doré */
|
||||
--foreground: oklch(0.2 0.015 45); /* Gris-brun foncé */
|
||||
--card: oklch(1 0 0); /* Blanc pur */
|
||||
--card-foreground: oklch(0.2 0.015 45);
|
||||
--primary: oklch(0.45 0.08 45); /* Gris-brun chaud */
|
||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
||||
--secondary: oklch(0.94 0.008 45);
|
||||
--secondary-foreground: oklch(0.2 0.015 45);
|
||||
--muted: oklch(0.91 0.01 45);
|
||||
--muted-foreground: oklch(0.6 0.012 45);
|
||||
--accent: oklch(0.93 0.01 45);
|
||||
--accent-foreground: oklch(0.2 0.015 45);
|
||||
--destructive: oklch(0.6 0.2 25);
|
||||
--border: oklch(0.88 0.012 45);
|
||||
--input: oklch(0.97 0.008 45);
|
||||
--ring: oklch(0.68 0.01 45);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.2 0.015 45);
|
||||
--sidebar: oklch(0.96 0.01 45);
|
||||
--sidebar-foreground: oklch(0.2 0.015 45);
|
||||
--sidebar-primary: oklch(0.45 0.08 45);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.93 0.01 45);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.015 45);
|
||||
--sidebar-border: oklch(0.88 0.012 45);
|
||||
--sidebar-ring: oklch(0.68 0.01 45);
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
```css
|
||||
[data-theme='sepia'].dark {
|
||||
--background: oklch(0.15 0.008 45); /* Noir légèrement bruni */
|
||||
--foreground: oklch(0.97 0.005 45); /* Blanc légèrement bruni */
|
||||
--card: oklch(0.19 0.01 45); /* Gris-brun foncé */
|
||||
--card-foreground: oklch(0.97 0.005 45);
|
||||
--primary: oklch(0.55 0.08 45); /* Gris-brun plus clair */
|
||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
||||
--secondary: oklch(0.25 0.015 45);
|
||||
--secondary-foreground: oklch(0.97 0.005 45);
|
||||
--muted: oklch(0.23 0.02 45);
|
||||
--muted-foreground: oklch(0.55 0.012 45);
|
||||
--accent: oklch(0.27 0.018 45);
|
||||
--accent-foreground: oklch(0.97 0.005 45);
|
||||
--destructive: oklch(0.65 0.2 25);
|
||||
--border: oklch(0.3 0.018 45);
|
||||
--input: oklch(0.27 0.02 45);
|
||||
--ring: oklch(0.58 0.02 45);
|
||||
--popover: oklch(0.19 0.01 45);
|
||||
--popover-foreground: oklch(0.97 0.005 45);
|
||||
--sidebar: oklch(0.12 0.01 45);
|
||||
--sidebar-foreground: oklch(0.97 0.005 45);
|
||||
--sidebar-primary: oklch(0.55 0.08 45);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.27 0.018 45);
|
||||
--sidebar-accent-foreground: oklch(0.97 0.005 45);
|
||||
--sidebar-border: oklch(0.3 0.018 45);
|
||||
--sidebar-ring: oklch(0.58 0.02 45);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visualisation des Teintes
|
||||
|
||||
### Diagramme des teintes OKLCH :
|
||||
|
||||
```
|
||||
45° (Sepia) 225° (Blue)
|
||||
│ │
|
||||
│ 230° (Slate) │
|
||||
│ ← principal → │
|
||||
↓ ↓
|
||||
Gris-brun Gris-bleu
|
||||
chaleureux saturé
|
||||
|
||||
250° (Midnight)
|
||||
│
|
||||
↓
|
||||
Gris-bleu
|
||||
profond/sombre
|
||||
```
|
||||
|
||||
### Relation entre les thèmes :
|
||||
|
||||
```
|
||||
Slate (230°) ⭐ ← THÈME PRINCIPAL
|
||||
├── Midnight (250°) : Version + sombre
|
||||
├── Blue (225°) : Version + saturée
|
||||
└── Sepia (45°) : Version chaleureuse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Modifications à faire
|
||||
|
||||
Dans `keep-notes/app/globals.css` :
|
||||
|
||||
### 1. Remplacer les lignes 169-188 (midnight actuel)
|
||||
Par le nouveau code **MIDNIGHT** ci-dessus
|
||||
|
||||
### 2. Remplacer les lignes 190-217 (blue actuel)
|
||||
Par le nouveau code **BLUE** ci-dessus
|
||||
|
||||
### 3. Remplacer les lignes 219-238 (sepia actuel)
|
||||
Par le nouveau code **SEPIA** ci-dessus
|
||||
|
||||
---
|
||||
|
||||
## 💬 Choix du thème
|
||||
|
||||
Utilisez ce tableau pour choisir :
|
||||
|
||||
| Pour l'utilisation de jour | Utilisez |
|
||||
|-------------------------|------------|
|
||||
| Travail standard, productivité | **Slate** (230°) ⭐ |
|
||||
| Ambiance calme, liseuse | **Sepia** (45°) |
|
||||
| Ambiance énergique, créative | **Blue** (225°) |
|
||||
|
||||
| Pour l'utilisation de nuit | Utilisez |
|
||||
|-------------------------|------------|
|
||||
| Nuit profonde, coding | **Midnight** (250°) |
|
||||
| Nuit légère, confort | **Slate dark** (230°) |
|
||||
|
||||
---
|
||||
|
||||
**Tous les thèmes sont harmonisés !** 🎨
|
||||
|
||||
*Thèmes créés par Amelia - Developer Agent* 💻
|
||||
521
memento-note/VISUALISATION-COULEURS.html
Normal file
521
memento-note/VISUALISATION-COULEURS.html
Normal file
@@ -0,0 +1,521 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visualisation des Couleurs - Slate Moderne</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
padding: 40px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
color: #333;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 40px 0 20px 0;
|
||||
color: #444;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 60px;
|
||||
}
|
||||
|
||||
.theme-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
|
||||
gap: 40px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.theme-box {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.theme-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.color-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.color-box {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.comparison-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.comparison-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comparison-label {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comparison-swatch {
|
||||
height: 150px;
|
||||
border-radius: 12px;
|
||||
border: 3px solid #e0e0e0;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.comparison-code {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.note-colors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.note-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.note-content {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.recommendation {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.recommendation h2 {
|
||||
color: white;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.recommendation p {
|
||||
font-size: 18px;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.winner {
|
||||
border: 3px solid #667eea;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎨 Visualisation des Couleurs - Options Modernes</h1>
|
||||
|
||||
<div class="recommendation">
|
||||
<h2>🏆 RECOMMANDATION PRINCIPALE : SLATE (GRIS-BLEU)</h2>
|
||||
<p>Plus professionnel, moins fatigant, moderne - Sans dégradés !</p>
|
||||
</div>
|
||||
|
||||
<!-- COMPARAISON : AVANT / APRÈS -->
|
||||
<div class="section">
|
||||
<h2>📊 Comparaison : Bleu Actuel vs Slate Moderne</h2>
|
||||
<div class="comparison-section">
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-item">
|
||||
<div class="comparison-label">Bleu Keep Actuel</div>
|
||||
<div class="comparison-swatch" style="background: #356AC0;">
|
||||
#356AC0
|
||||
</div>
|
||||
<span class="comparison-code">Bleu saturé</span>
|
||||
</div>
|
||||
|
||||
<div class="comparison-item">
|
||||
<div class="comparison-label">Slate Moderne (NOUVEAU)</div>
|
||||
<div class="comparison-swatch" style="background: #7A8A9A;">
|
||||
#7A8A9A
|
||||
</div>
|
||||
<span class="comparison-code">Gris-bleu élégant</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- THEME SLATE - LIGHT MODE -->
|
||||
<div class="section">
|
||||
<h2>✨ Option 1 : Slate (Gris-Bleu) - Mode Light</h2>
|
||||
<div class="theme-comparison">
|
||||
<div class="theme-box winner">
|
||||
<div class="theme-header">
|
||||
<span class="theme-name">🏆 SLATE LIGHT</span>
|
||||
</div>
|
||||
<div class="color-swatches">
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #F8F9FB;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Background</div>
|
||||
<div class="color-value">#F8F9FB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #FFFFFF;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Card</div>
|
||||
<div class="color-value">#FFFFFF</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #F3F4F6;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Sidebar</div>
|
||||
<div class="color-value">#F3F4F6</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #3B4252;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Texte (foreground)</div>
|
||||
<div class="color-value">#3B4252</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #7A8A9A;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Primary (boutons)</div>
|
||||
<div class="color-value">#7A8A9A</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #E5E7EB;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Border</div>
|
||||
<div class="color-value">#E5E7EB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- THEME SLATE - DARK MODE -->
|
||||
<div class="section">
|
||||
<h2>🌙 Option 1 : Slate (Gris-Bleu) - Mode Dark</h2>
|
||||
<div class="theme-comparison">
|
||||
<div class="theme-box winner">
|
||||
<div class="theme-header">
|
||||
<span class="theme-name">🏆 SLATE DARK</span>
|
||||
</div>
|
||||
<div class="color-swatches">
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #1F2937;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Background</div>
|
||||
<div class="color-value">#1F2937</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #2D3748;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Card</div>
|
||||
<div class="color-value">#2D3748</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #1A202C;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Sidebar</div>
|
||||
<div class="color-value">#1A202C</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #F7FAFC;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Texte (foreground)</div>
|
||||
<div class="color-value">#F7FAFC</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #9CA3AF;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Primary (boutons)</div>
|
||||
<div class="color-value">#9CA3AF</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #4A5568;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Border</div>
|
||||
<div class="color-value">#4A5568</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COULEURS DES NOTES -->
|
||||
<div class="section">
|
||||
<h2>📝 Couleurs des Notes (Light Mode)</h2>
|
||||
<div class="note-colors">
|
||||
<div class="note-card" style="background: #FFFFFF; border: 1px solid #E5E7EB;">
|
||||
<div class="note-title">Default</div>
|
||||
<div class="note-content">Note blanche standard</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #FEF2F2; border: 1px solid #FEE2E2;">
|
||||
<div class="note-title" style="color: #7F1D1D;">Red</div>
|
||||
<div class="note-content" style="color: #991B1B;">Note rouge</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #FFF7ED; border: 1px solid #FFEDD5;">
|
||||
<div class="note-title" style="color: #9A3412;">Orange</div>
|
||||
<div class="note-content" style="color: #C2410C;">Note orange</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #FEFCE8; border: 1px solid #FEF9C3;">
|
||||
<div class="note-title" style="color: #854D0E;">Yellow</div>
|
||||
<div class="note-content" style="color: #A16207;">Note jaune</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #ECFDF5; border: 1px solid #D1FAE5;">
|
||||
<div class="note-title" style="color: #065F46;">Green (Emerald)</div>
|
||||
<div class="note-content" style="color: #047857;">Note verte</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #F0FDFA; border: 1px solid #CCFBF1;">
|
||||
<div class="note-title" style="color: #0F766E;">Teal</div>
|
||||
<div class="note-content" style="color: #115E59;">Note teal</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #EFF6FF; border: 1px solid #DBEAFE;">
|
||||
<div class="note-title" style="color: #1E40AF;">Blue (Sky)</div>
|
||||
<div class="note-content" style="color: #1D4ED8;">Note bleue</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #EEF2FF; border: 1px solid #E0E7FF;">
|
||||
<div class="note-title" style="color: #4338CA;">Indigo</div>
|
||||
<div class="note-content" style="color: #4338CA;">Note indigo</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #F5F3FF; border: 1px solid #EDE9FE;">
|
||||
<div class="note-title" style="color: #7C3AED;">Violet</div>
|
||||
<div class="note-content" style="color: #7C3AED;">Note violette</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #FAF5FF; border: 1px solid #F3E8FF;">
|
||||
<div class="note-title" style="color: #9333EA;">Purple</div>
|
||||
<div class="note-content" style="color: #9333EA;">Note pourpre</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #FDF2F8; border: 1px solid #FCE7F3;">
|
||||
<div class="note-title" style="color: #BE185D;">Pink</div>
|
||||
<div class="note-content" style="color: #BE185D;">Note rose</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #FFF1F2; border: 1px solid #FFE4E6;">
|
||||
<div class="note-title" style="color: #E11D48;">Rose</div>
|
||||
<div class="note-content" style="color: #E11D48;">Note rose rougeâtre</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card" style="background: #F5F5F4; border: 1px solid #E7E5E4;">
|
||||
<div class="note-title" style="color: #71717A;">Gray</div>
|
||||
<div class="note-content" style="color: #71717A;">Note grise</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AUTRES OPTIONS -->
|
||||
<div class="section">
|
||||
<h2>🔄 Autres Options de Thème</h2>
|
||||
<div class="theme-comparison">
|
||||
<div class="theme-box">
|
||||
<div class="theme-header">
|
||||
<span class="theme-name">⚫ MONOCHROME</span>
|
||||
</div>
|
||||
<div class="color-swatches">
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #FFFFFF;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Background</div>
|
||||
<div class="color-value">#FFFFFF</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #262626;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Primary</div>
|
||||
<div class="color-value">#262626</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-box">
|
||||
<div class="theme-header">
|
||||
<span class="theme-name">💜 INDIGO (Violet)</span>
|
||||
</div>
|
||||
<div class="color-swatches">
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #F9F9FB;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Background</div>
|
||||
<div class="color-value">#F9F9FB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #7C3AED;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Primary</div>
|
||||
<div class="color-value">#7C3AED</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-box">
|
||||
<div class="theme-header">
|
||||
<span class="theme-name">🌊 TEAL (Turquoise)</span>
|
||||
</div>
|
||||
<div class="color-swatches">
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #F9FAFB;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Background</div>
|
||||
<div class="color-value">#F9FAFB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="color-box" style="background: #0F766E;"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-label">Primary</div>
|
||||
<div class="color-value">#0F766E</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" style="text-align: center; margin-top: 60px;">
|
||||
<h2>💬 Votre Choix Ramez ?</h2>
|
||||
<p style="font-size: 18px; color: #666; margin-bottom: 30px;">
|
||||
Regardez les couleurs, choisissez ce que vous préférez, puis dites-moi :
|
||||
</p>
|
||||
<div style="display: flex; gap: 20px; justify-content: center; flex-wrap: wrap;">
|
||||
<div style="background: #10B981; color: white; padding: 15px 30px; border-radius: 8px; font-weight: bold; font-size: 16px;">
|
||||
✅ SLATE (mon choix)
|
||||
</div>
|
||||
<div style="background: #6366F1; color: white; padding: 15px 30px; border-radius: 8px; font-weight: bold; font-size: 16px;">
|
||||
💜 INDIGO
|
||||
</div>
|
||||
<div style="background: #0F766E; color: white; padding: 15px 30px; border-radius: 8px; font-weight: bold; font-size: 16px;">
|
||||
🌊 TEAL
|
||||
</div>
|
||||
<div style="background: #262626; color: white; padding: 15px 30px; border-radius: 8px; font-weight: bold; font-size: 16px;">
|
||||
⚫ MONOCHROME
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 40px; padding-bottom: 40px; color: #999; font-size: 14px;">
|
||||
<p>💡 Ouvrez ce fichier dans votre navigateur pour voir toutes les couleurs !</p>
|
||||
<p>Double-cliquez sur : keep-notes/VISUALISATION-COULEURS.html</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,459 @@
|
||||
# Résumé Complet de l'Implémentation Story 11-2 avec Méthode BMAD
|
||||
|
||||
Date: 2026-01-17
|
||||
Projet: Keep - Application de notes
|
||||
Story: 11-2 - Improve Settings UX
|
||||
|
||||
## 📋 Aperçu Général
|
||||
|
||||
La story 11-2 vise à améliorer l'UX des paramètres (Settings UX) en implémentant plusieurs fonctionnalités manquantes et en déployant les pages mises à jour.
|
||||
|
||||
## ✅ Ce Qui a Été Accompli
|
||||
|
||||
### 1. **Fonctionnalités Implémentées**
|
||||
|
||||
#### ✅ Serveur Actions Créés
|
||||
- **`updateEmailNotifications(enabled: boolean)`** - Met à jour les notifications par email dans la table User
|
||||
- **`updatePrivacyAnalytics(enabled: boolean)`** - Met à jour les analytics anonymes dans la table User
|
||||
- **`updateTheme(theme: string)`** - Met à jour le thème de l'utilisateur (light/dark/auto)
|
||||
- **`updateLanguage(language: string)`** - Met à jour la langue préférée
|
||||
- **`updateFontSize(fontSize: string)`** - Met à jour la taille de police
|
||||
- **`updateShowRecentNotes(showRecentNotes: boolean)`** - Met à jour l'affichage des notes récentes
|
||||
|
||||
**Fichier:** `keep-notes/app/actions/profile.ts`
|
||||
|
||||
#### ✅ Composant SettingsSearch Fonctionnel
|
||||
- Filtrage en temps réel par label ET description
|
||||
- Recherche insensible à la casse
|
||||
- Bouton de réinitialisation (X)
|
||||
- Support clavier (Escape pour effacer)
|
||||
- État vide quand aucun résultat
|
||||
- Accessibilité WCAG 2.1 AA
|
||||
|
||||
**Fichier:** `keep-notes/components/settings/SettingsSearch.tsx`
|
||||
|
||||
#### ✅ Mise à jour du Schéma de Base de Données
|
||||
- Ajout de `emailNotifications` (Boolean) à la table User
|
||||
- Ajout de `anonymousAnalytics` (Boolean) à la table User
|
||||
- Migration SQL créée mais NON appliquée
|
||||
|
||||
**Fichier:** `keep-notes/prisma/schema.prisma`
|
||||
**Migration:** `keep-notes/prisma/migrations/20260117000000_add_user_preferences_fields/migration.sql`
|
||||
|
||||
#### ✅ Pages de Paramètres Créées (mais NON déployées)
|
||||
- **`page-new.tsx`** pour General Settings avec notifications et privacy
|
||||
- **`profile-form-new.tsx`** pour Profile Settings avec toutes les fonctionnalités
|
||||
- **`page-new.tsx`** pour Appearance Settings avec persistance du thème
|
||||
|
||||
**Emplacements:**
|
||||
- `keep-notes/app/(main)/settings/general/page-new.tsx`
|
||||
- `keep-notes/app/(main)/settings/profile/profile-form-new.tsx`
|
||||
- `keep-notes/app/(main)/settings/appearance/page-new.tsx`
|
||||
|
||||
## 🚧 Étapes Restantes (Comment les Implémenter avec BMAD)
|
||||
|
||||
Voici les étapes restantes pour compléter l'implémentation avec la méthode BMAD :
|
||||
|
||||
### Étape 1: Régénérer le Client Prisma
|
||||
|
||||
**Problème:** Le build échoue avec "EPERM: operation not permitted" sur `query_engine-windows.dll.node`
|
||||
|
||||
**Solution BMAD:**
|
||||
1. Arrêter tous les processus Node.js/Next.js en cours
|
||||
2. Supprimer le dossier `prisma/client-generated`
|
||||
3. Régénérer le client Prisma
|
||||
|
||||
```bash
|
||||
# Dans le dossier keep-notes
|
||||
# Arrêter tous les processus (Ctrl+C dans les terminaux)
|
||||
|
||||
# Supprimer le dossier client-generated
|
||||
rm -rf prisma/client-generated
|
||||
# Ou sur Windows: rmdir /s /q prisma\client-generated
|
||||
|
||||
# Régénérer le client Prisma
|
||||
npx prisma generate
|
||||
|
||||
# Ou utiliser npm
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
### Étape 2: Appliquer la Migration de Base de Données
|
||||
|
||||
**Action:** Exécuter la migration SQL pour ajouter les nouveaux champs à la table User
|
||||
|
||||
```bash
|
||||
# Dans le dossier keep-notes
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Ou appliquer manuellement la migration
|
||||
# Ouvrir: prisma/migrations/20260117000000_add_user_preferences_fields/migration.sql
|
||||
# Exécuter le SQL sur la base de données
|
||||
```
|
||||
|
||||
**Contenu de la migration:**
|
||||
```sql
|
||||
ALTER TABLE "User" ADD COLUMN "emailNotifications" BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "User" ADD COLUMN "anonymousAnalytics" BOOLEAN NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
### Étape 3: Déployer les Pages de Paramètres (Méthode Safe Replace)
|
||||
|
||||
#### 3.1: Déployer General Settings Page
|
||||
|
||||
```bash
|
||||
# Dans keep-notes/app/(main)/settings/general
|
||||
# Backup du fichier actuel
|
||||
mv page.tsx page-old.tsx
|
||||
|
||||
# Copier le nouveau fichier
|
||||
cp page-new.tsx page.tsx
|
||||
|
||||
# Vérifier le build
|
||||
cd ../../../..
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Vérifications:**
|
||||
- [ ] La page se charge correctement à `/settings/general`
|
||||
- [ ] Les sections (Language, Notifications, Privacy) s'affichent
|
||||
- [ ] Le toggle "Email Notifications" fonctionne
|
||||
- [ ] Le toggle "Anonymous Analytics" fonctionne
|
||||
- [ ] Les paramètres se sauvegardent dans la base de données
|
||||
|
||||
#### 3.2: Déployer Profile Settings Form
|
||||
|
||||
```bash
|
||||
# Dans keep-notes/app/(main)/settings/profile
|
||||
# Backup du fichier actuel
|
||||
mv profile-form.tsx profile-form-old.tsx
|
||||
|
||||
# Copier le nouveau fichier
|
||||
cp profile-form-new.tsx profile-form.tsx
|
||||
|
||||
# Vérifier le build
|
||||
cd ../../../..
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Vérifications:**
|
||||
- [ ] Le formulaire de profil se charge correctement
|
||||
- [ ] La langue peut être changée
|
||||
- [ ] La taille de police peut être changée
|
||||
- [ ] Le toggle "Show Recent Notes" fonctionne
|
||||
- [ ] Les paramètres se sauvegardent correctement
|
||||
|
||||
#### 3.3: Déployer Appearance Settings Page
|
||||
|
||||
```bash
|
||||
# Dans keep-notes/app/(main)/settings/appearance
|
||||
# Backup du fichier actuel
|
||||
mv page.tsx page-old.tsx
|
||||
|
||||
# Créer le nouveau fichier avec persistance du thème
|
||||
# (Utiliser le contenu documenté dans cette section)
|
||||
|
||||
# Vérifier le build
|
||||
cd ../../../..
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Contenu à créer (page.tsx):**
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { updateTheme } from '@/app/actions/profile'
|
||||
|
||||
export default function AppearanceSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState('auto')
|
||||
|
||||
// Load theme from database/localStorage on mount
|
||||
useEffect(() => {
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
const result = await updateTheme(localStorage.getItem('theme') || 'auto')
|
||||
if (!result?.error) {
|
||||
setTheme(localStorage.getItem('theme') || 'auto')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadTheme()
|
||||
}, [])
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
localStorage.setItem('theme', value)
|
||||
applyTheme(value)
|
||||
|
||||
try {
|
||||
const result = await updateTheme(value)
|
||||
if (result?.error) {
|
||||
toast.error(t('appearance.themeUpdateFailed'))
|
||||
} else {
|
||||
toast.success(t('appearance.themeUpdateSuccess'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error?.message || t('appearance.themeUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const applyTheme = (themeValue: string) => {
|
||||
const root = document.documentElement
|
||||
if (themeValue === 'dark') {
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light')
|
||||
} else if (themeValue === 'light') {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
} else {
|
||||
// Auto: use system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (prefersDark) {
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light')
|
||||
} else {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<aside className="lg:col-span-1">
|
||||
<SettingsNav currentSection="appearance" />
|
||||
</aside>
|
||||
<main className="lg:col-span-3 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('appearance.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Theme"
|
||||
icon={<span className="text-2xl">🎨</span>}
|
||||
description={t('appearance.themeDescription')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label htmlFor="theme" className="text-sm font-medium">
|
||||
{t('appearance.colorScheme')}
|
||||
</label>
|
||||
<SettingSelect
|
||||
id="theme"
|
||||
value={theme}
|
||||
options={[
|
||||
{ value: 'light', label: t('appearance.light') },
|
||||
{ value: 'dark', label: t('appearance.dark') },
|
||||
{ value: 'auto', label: t('appearance.auto') },
|
||||
]}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('appearance.themeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Vérifications:**
|
||||
- [ ] La page se charge correctement à `/settings/appearance`
|
||||
- [ ] Le thème se charge depuis localStorage au démarrage
|
||||
- [ ] Le changement de thème fonctionne immédiatement
|
||||
- [ ] Le thème se sauvegarde dans la base de données
|
||||
- [ ] Le thème persiste après rechargement de la page
|
||||
- [ ] Le thème persiste après fermeture/ouverture du navigateur
|
||||
|
||||
### Étape 4: Mettre à Jour le Sprint Status
|
||||
|
||||
Une fois toutes les fonctionnalités implémentées et testées, mettre à jour le fichier de statut:
|
||||
|
||||
```bash
|
||||
# Ouvrir le fichier
|
||||
_bmad-output/implementation-artifacts/sprint-status.yaml
|
||||
|
||||
# Mettre à jour le statut de la story 11-2
|
||||
11-2-improve-settings-ux:
|
||||
status: done # Passer de "in-progress" à "done"
|
||||
completion: 100% # Passer de 60% à 100%
|
||||
```
|
||||
|
||||
## 📊 Structure BMAD pour Chaque Étape
|
||||
|
||||
Chaque étape devrait suivre le format BMAD:
|
||||
|
||||
### Format de Story BMAD
|
||||
|
||||
```yaml
|
||||
Story: X.Y - [Titre de la Story]
|
||||
Status: [backlog | ready-for-dev | in-progress | review | done]
|
||||
|
||||
## Story
|
||||
As a [type d'utilisateur],
|
||||
I want [ce que je veux],
|
||||
So that [pourquoi je veux cela].
|
||||
|
||||
## Acceptance Criteria
|
||||
1. [Given] Condition préalable
|
||||
2. [When] Action
|
||||
3. [Then] Résultat attendu
|
||||
|
||||
## Tasks / Subtasks
|
||||
- [ ] Tâche 1
|
||||
- [ ] Sous-tâche 1.1
|
||||
- [ ] Sous-tâche 1.2
|
||||
- [ ] Tâche 2
|
||||
- [ ] Sous-tâche 2.1
|
||||
- [ ] Sous-tâche 2.2
|
||||
|
||||
## Dev Notes
|
||||
### Implementation Context
|
||||
[Explication du contexte et fichiers impliqués]
|
||||
|
||||
### Deployment Strategy
|
||||
[Stratégie de déploiement]
|
||||
|
||||
### Testing Requirements
|
||||
[Conditions de test à vérifier]
|
||||
|
||||
### References
|
||||
[Liens vers les fichiers pertinents]
|
||||
```
|
||||
|
||||
## 📝 Résumé des Stories BMAD Créées
|
||||
|
||||
### ✅ Story 11.2.1: Deploy General Settings Page Update
|
||||
- **Statut:** backlog (prête à être exécutée)
|
||||
- **Objectif:** Déployer la page General Settings avec notifications et privacy
|
||||
- **Fichiers:** `keep-notes/app/(main)/settings/general/`
|
||||
|
||||
### ✅ Story 11.2.2: Implement Functional SettingsSearch Component
|
||||
- **Statut:** backlog (complétée mais NON déployée)
|
||||
- **Objectif:** Implémenter la recherche fonctionnelle dans SettingsSearch
|
||||
- **Fichiers:** `keep-notes/components/settings/SettingsSearch.tsx`
|
||||
|
||||
### ✅ Story 11.2.3: Deploy Appearance Settings Page Update
|
||||
- **Statut:** backlog (prête à être exécutée)
|
||||
- **Objectif:** Déployer la page Appearance Settings avec persistance du thème
|
||||
- **Fichiers:** `keep-notes/app/(main)/settings/appearance/`
|
||||
|
||||
### ✅ Story 11.2.4: Deploy Profile Settings Form Update (À créer)
|
||||
- **Statut:** backlog (à créer)
|
||||
- **Objectif:** Déployer le formulaire de profil avec toutes les fonctionnalités
|
||||
- **Fichiers:** `keep-notes/app/(main)/settings/profile/profile-form.tsx`
|
||||
|
||||
### ✅ Story 11.2.5: Update Story 11-2 to Done Status (À créer)
|
||||
- **Statut:** backlog (à créer)
|
||||
- **Objectif:** Mettre à jour le statut de la story 11-2 à "done"
|
||||
- **Fichiers:** `_bmad-output/implementation-artifacts/sprint-status.yaml`
|
||||
|
||||
## 🎯 Checklist Finale de Déploiement
|
||||
|
||||
Avant de marquer la story 11-2 comme "done", vérifier:
|
||||
|
||||
### Vérifications Techniques
|
||||
- [ ] Build réussi sans erreurs (`npm run build`)
|
||||
- [ ] Pas d'erreurs TypeScript
|
||||
- [ ] Pas d'erreurs de linting (`npm run lint`)
|
||||
- [ ] Client Prisma régénéré avec succès
|
||||
- [ ] Migration de base de données appliquée
|
||||
|
||||
### Vérifications Fonctionnelles
|
||||
- [ ] Page General Settings fonctionne
|
||||
- [ ] Toggle "Email Notifications" fonctionne et sauvegarde
|
||||
- [ ] Toggle "Anonymous Analytics" fonctionne et sauvegarde
|
||||
- [ ] Page Profile Settings fonctionne
|
||||
- [ ] Changement de langue fonctionne et sauvegarde
|
||||
- [ ] Changement de taille de police fonctionne et sauvegarde
|
||||
- [ ] Toggle "Show Recent Notes" fonctionne et sauvegarde
|
||||
- [ ] Page Appearance Settings fonctionne
|
||||
- [ ] Changement de thème fonctionne immédiatement
|
||||
- [ ] Thème se sauvegarde dans la base de données
|
||||
- [ ] Thème se charge depuis localStorage au démarrage
|
||||
- [ ] Thème persiste après rechargement de page
|
||||
- [ ] Thème persiste après fermeture/ouverture du navigateur
|
||||
|
||||
### Vérifications UI/UX
|
||||
- [ ] SettingsSearch filtre correctement les sections
|
||||
- [ ] Recherche fonctionne par label ET description
|
||||
- [ ] Recherche est insensible à la casse
|
||||
- [ ] Bouton de réinitialisation (X) fonctionne
|
||||
- [ ] Escape key efface la recherche
|
||||
- [ ] État vide s'affiche quand aucun résultat
|
||||
- [ ] Toast notifications s'affichent pour succès/erreur
|
||||
- [ ] Design responsive sur mobile/tablet/desktop
|
||||
|
||||
### Vérifications d'Accessibilité
|
||||
- [ ] Navigation au clavier fonctionne
|
||||
- [ ] Focus visible sur les éléments interactifs
|
||||
- [ ] ARIA labels présents
|
||||
- [ ] Contraste de couleurs conforme WCAG AA
|
||||
- [ ] Screen readers peuvent lire les labels
|
||||
|
||||
## 🚀 Comment Continuer
|
||||
|
||||
### Option 1: Suivre les Étapes Manuellement
|
||||
1. Résoudre le problème Prisma (Étape 1)
|
||||
2. Appliquer la migration (Étape 2)
|
||||
3. Déployer les pages une par une (Étape 3)
|
||||
4. Vérifier toutes les fonctionnalités (Checklist)
|
||||
5. Mettre à jour le statut (Étape 4)
|
||||
|
||||
### Option 2: Exécuter une Story BMAD
|
||||
Créer et exécuter les stories BMAD restantes en suivant le format:
|
||||
|
||||
```bash
|
||||
# Exemple pour créer une story
|
||||
_bmad/workflows/4-implementation/dev-story/workflow.yaml
|
||||
|
||||
# Spécifier:
|
||||
# - La story à exécuter (ex: 11-2-3-deploy-appearance-settings-page)
|
||||
# - L'étape (ex: deployment)
|
||||
# - Les fichiers à modifier
|
||||
```
|
||||
|
||||
## 📚 Références
|
||||
|
||||
**Fichiers Implémentés:**
|
||||
- `keep-notes/app/actions/profile.ts` - Server actions
|
||||
- `keep-notes/components/settings/SettingsSearch.tsx` - Composant de recherche
|
||||
- `keep-notes/app/(main)/settings/general/page-new.tsx` - Page General (non déployée)
|
||||
- `keep-notes/app/(main)/settings/profile/profile-form-new.tsx` - Profile form (non déployé)
|
||||
- `keep-notes/app/(main)/settings/appearance/page.tsx` - Page Appearance (à créer)
|
||||
- `keep-notes/prisma/schema.prisma` - Schéma de base de données
|
||||
- `keep-notes/prisma/migrations/20260117000000_add_user_preferences_fields/migration.sql` - Migration
|
||||
|
||||
**Documentation BMAD:**
|
||||
- `_bmad-output/implementation-artifacts/11-2-improve-settings-ux.md` - Story principale
|
||||
- `_bmad-output/implementation-artifacts/11-2-1-deploy-general-settings-page.md` - Story déployement General
|
||||
- `_bmad-output/implementation-artifacts/11-2-2-implement-functional-settingssearch.md` - Story SettingsSearch
|
||||
- `_bmad-output/implementation-artifacts/11-2-3-deploy-appearance-settings-page.md` - Story déployement Appearance
|
||||
|
||||
## 🎉 Conclusion
|
||||
|
||||
L'implémentation de la story 11-2 est à **80% complète**. Les fonctionnalités principales ont été créées et testées localement. Les étapes restantes sont principalement des tâches de déploiement et de vérification.
|
||||
|
||||
**Prochaines Actions Prioritaires:**
|
||||
1. Résoudre le problème Prisma (permissions)
|
||||
2. Régénérer le client Prisma
|
||||
3. Appliquer la migration de base de données
|
||||
4. Déployer les pages de paramètres
|
||||
5. Vérifier toutes les fonctionnalités
|
||||
6. Mettre à jour le statut à "done"
|
||||
|
||||
La méthode BMAD a été appliquée pour documenter chaque étape avec des critères d'acceptation, des notes d'implémentation et des exigences de tests. Chaque story peut être exécutée indépendamment en suivant le format standard BMAD.
|
||||
79
memento-note/app/(auth)/forgot-password/page.tsx
Normal file
79
memento-note/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { forgotPassword } from '@/app/actions/auth-reset'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useLanguage()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isDone, setIsSubmittingDone] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const result = await forgotPassword(formData.get('email') as string)
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
setIsSubmittingDone(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
return (
|
||||
<main className="flex items-center justify-center md:h-screen p-4">
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('auth.checkYourEmail')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('auth.resetEmailSent')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Link href="/login" className="w-full">
|
||||
<Button variant="outline" className="w-full">{t('auth.returnToLogin')}</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex items-center justify-center md:h-screen p-4">
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('auth.forgotPasswordTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('auth.forgotPasswordDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</label>
|
||||
<Input id="email" name="email" type="email" required placeholder="name@example.com" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('auth.sending') : t('auth.sendResetLink')}
|
||||
</Button>
|
||||
<Link href="/login" className="text-sm text-center underline">
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
19
memento-note/app/(auth)/layout.tsx
Normal file
19
memento-note/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<LanguageProvider initialLanguage="fr">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
17
memento-note/app/(auth)/login/page.tsx
Normal file
17
memento-note/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { LoginForm } from '@/components/login-form';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const config = await getSystemConfig();
|
||||
|
||||
// Default to true unless explicitly disabled in DB or Env
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
return (
|
||||
<main className="flex items-center justify-center md:h-screen">
|
||||
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
|
||||
<LoginForm allowRegister={allowRegister} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
20
memento-note/app/(auth)/register/page.tsx
Normal file
20
memento-note/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { RegisterForm } from '@/components/register-form';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const config = await getSystemConfig();
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
if (!allowRegister) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex items-center justify-center md:h-screen">
|
||||
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
98
memento-note/app/(auth)/reset-password/page.tsx
Normal file
98
memento-note/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useState, Suspense } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { resetPassword } from '@/app/actions/auth-reset'
|
||||
import { toast } from 'sonner'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!token) return
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const password = formData.get('password') as string
|
||||
const confirm = formData.get('confirmPassword') as string
|
||||
|
||||
if (password !== confirm) {
|
||||
toast.error(t('resetPassword.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
const result = await resetPassword(token, password)
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
toast.success(t('resetPassword.success'))
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('resetPassword.invalidLinkTitle')}</CardTitle>
|
||||
<CardDescription>{t('resetPassword.invalidLinkDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Link href="/forgot-password" title="Try again" className="w-full">
|
||||
<Button variant="outline" className="w-full">{t('resetPassword.requestNewLink')}</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('resetPassword.title')}</CardTitle>
|
||||
<CardDescription>{t('resetPassword.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password">{t('resetPassword.newPassword')}</label>
|
||||
<Input id="password" name="password" type="password" required minLength={6} autoFocus />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="confirmPassword">{t('resetPassword.confirmNewPassword')}</label>
|
||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('resetPassword.resetting') : t('resetPassword.resetPassword')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<main className="flex items-center justify-center md:h-screen p-4">
|
||||
<Suspense fallback={<div>{t('resetPassword.loading')}</div>}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
259
memento-note/app/(main)/admin/ai-test/ai-tester.tsx
Normal file
259
memento-note/app/(main)/admin/ai-test/ai-tester.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface TestResult {
|
||||
success: boolean
|
||||
provider?: string
|
||||
model?: string
|
||||
responseTime?: number
|
||||
tags?: Array<{ tag: string; confidence: number }>
|
||||
embeddingLength?: number
|
||||
firstValues?: number[]
|
||||
error?: string
|
||||
details?: any
|
||||
}
|
||||
|
||||
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [result, setResult] = useState<TestResult | null>(null)
|
||||
const [config, setConfig] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/ai/config')
|
||||
const data = await response.json()
|
||||
setConfig(data)
|
||||
|
||||
if (data.previousTest) {
|
||||
setResult(data.previousTest[type] || null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
setIsLoading(true)
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ai/test-${type}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
const data = await response.json()
|
||||
|
||||
setResult({
|
||||
...data,
|
||||
responseTime: endTime - startTime
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
`✅ ${type === 'tags' ? 'Tags' : 'Embeddings'} Test Successful!`,
|
||||
{
|
||||
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.error(
|
||||
`❌ ${type === 'tags' ? 'Tags' : 'Embeddings'} Test Failed`,
|
||||
{
|
||||
description: data.error || 'Unknown error'
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
const endTime = Date.now()
|
||||
const errorResult = {
|
||||
success: false,
|
||||
error: error.message || 'Network error',
|
||||
responseTime: endTime - startTime
|
||||
}
|
||||
setResult(errorResult)
|
||||
toast.error(t('admin.aiTest.testError', { error: error.message }))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getProviderInfo = () => {
|
||||
if (!config) return { provider: 'Loading...', model: 'Loading...' }
|
||||
|
||||
if (type === 'tags') {
|
||||
return {
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
provider: config.AI_PROVIDER_EMBEDDING || 'ollama',
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const providerInfo = getProviderInfo()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Provider Info */}
|
||||
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{providerInfo.provider.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{providerInfo.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Button */}
|
||||
<Button
|
||||
onClick={runTest}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
variant={result?.success ? 'default' : result?.success === false ? 'destructive' : 'default'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('admin.aiTest.testing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{t('admin.aiTest.runTest')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<Card className={result.success ? 'border-green-200 dark:border-green-900' : 'border-red-200 dark:border-red-900'}>
|
||||
<CardContent className="pt-6">
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{result.success ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Response Time */}
|
||||
{result.responseTime && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags Results */}
|
||||
{type === 'tags' && result.success && result.tags && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.tags.map((tag, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
>
|
||||
{tag.tag}
|
||||
<span className="ml-1 text-xs opacity-70">
|
||||
({Math.round(tag.confidence * 100)}%)
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embeddings Results */}
|
||||
{type === 'embeddings' && result.success && result.embeddingLength && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
|
||||
</div>
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<div className="text-2xl font-bold text-center">
|
||||
{result.embeddingLength}
|
||||
</div>
|
||||
<div className="text-xs text-center text-muted-foreground mt-1">
|
||||
{t('admin.aiTest.vectorDimensions')}
|
||||
</div>
|
||||
</div>
|
||||
{result.firstValues && result.firstValues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
|
||||
<div className="p-2 bg-muted rounded font-mono text-xs">
|
||||
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Details */}
|
||||
{!result.success && result.error && (
|
||||
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
|
||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
|
||||
{result.details && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs overflow-auto p-2 bg-red-100 dark:bg-red-900/30 rounded">
|
||||
{JSON.stringify(result.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="text-center py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t('admin.aiTest.testing')} {type === 'tags' ? 'tags generation' : 'embeddings'}...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
memento-note/app/(main)/admin/ai-test/page.tsx
Normal file
106
memento-note/app/(main)/admin/ai-test/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, TestTube } from 'lucide-react'
|
||||
import { AI_TESTER } from './ai-tester'
|
||||
|
||||
export default async function AITestPage() {
|
||||
const session = await auth()
|
||||
|
||||
if ((session?.user as any)?.role !== 'ADMIN') {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/settings">
|
||||
<Button variant="outline" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<TestTube className="h-8 w-8" />
|
||||
AI Provider Testing
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Test your AI providers for tag generation and semantic search embeddings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Tags Provider Test */}
|
||||
<Card className="border-primary/20 dark:border-primary/30">
|
||||
<CardHeader className="bg-primary/5 dark:bg-primary/10">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🏷️</span>
|
||||
Tags Generation Test
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Test the AI provider responsible for automatic tag suggestions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<AI_TESTER type="tags" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Embeddings Provider Test */}
|
||||
<Card className="border-green-200 dark:border-green-900">
|
||||
<CardHeader className="bg-green-50/50 dark:bg-green-950/20">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🔍</span>
|
||||
Embeddings Test
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Test the AI provider responsible for semantic search embeddings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<AI_TESTER type="embeddings" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>ℹ️ How Testing Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">🏷️ Tags Generation Test:</h4>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Sends a sample note to the AI provider</li>
|
||||
<li>Requests 3-5 relevant tags based on the content</li>
|
||||
<li>Displays the generated tags with confidence scores</li>
|
||||
<li>Measures response time</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">🔍 Embeddings Test:</h4>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Sends a sample text to the embedding provider</li>
|
||||
<li>Generates a vector representation (list of numbers)</li>
|
||||
<li>Displays embedding dimensions and sample values</li>
|
||||
<li>Verifies the vector is valid and properly formatted</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-amber-50 dark:bg-amber-950/20 p-4 rounded-lg border border-amber-200 dark:border-amber-900">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-100">💡 Tip:</p>
|
||||
<p className="text-amber-800 dark:text-amber-200 mt-1">
|
||||
You can use different providers for tags and embeddings! For example, use Ollama (free) for tags
|
||||
and OpenAI (best quality) for embeddings to optimize costs and performance.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
memento-note/app/(main)/admin/ai/page.tsx
Normal file
147
memento-note/app/(main)/admin/ai/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { AdminMetrics } from '@/components/admin-metrics'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export default async function AdminAIPage() {
|
||||
const config = await getSystemConfig()
|
||||
|
||||
// Determine provider status based on config
|
||||
const openaiKey = config.OPENAI_API_KEY
|
||||
const ollamaUrl = config.OLLAMA_BASE_URL || config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL_EMBEDDING
|
||||
|
||||
const providers = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
status: openaiKey ? 'Connected' : 'Not Configured',
|
||||
requests: 'N/A' // We don't track request counts yet
|
||||
},
|
||||
{
|
||||
name: 'Ollama',
|
||||
status: ollamaUrl ? 'Available' : 'Not Configured',
|
||||
requests: 'N/A'
|
||||
},
|
||||
]
|
||||
|
||||
// Mock AI metrics - in a real app, these would come from analytics
|
||||
// TODO: Implement real analytics tracking
|
||||
const aiMetrics = [
|
||||
{
|
||||
title: 'Total Requests',
|
||||
value: '—',
|
||||
trend: { value: 0, isPositive: true },
|
||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Success Rate',
|
||||
value: '100%',
|
||||
trend: { value: 0, isPositive: true },
|
||||
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Avg Response Time',
|
||||
value: '—',
|
||||
trend: { value: 0, isPositive: true },
|
||||
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
||||
},
|
||||
{
|
||||
title: 'Active Features',
|
||||
value: '6',
|
||||
icon: <Settings className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
AI Management
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Monitor and configure AI features
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/settings">
|
||||
<Button variant="outline">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Configure
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AdminMetrics metrics={aiMetrics} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Active AI Features
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
'Title Suggestions',
|
||||
'Semantic Search',
|
||||
'Paragraph Reformulation',
|
||||
'Memory Echo',
|
||||
'Language Detection',
|
||||
'Auto Labeling',
|
||||
].map((feature) => (
|
||||
<div
|
||||
key={feature}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{feature}
|
||||
</span>
|
||||
<span className="px-2 py-1 text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
AI Provider Status
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{provider.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{provider.requests} requests
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${provider.status === 'Connected' || provider.status === 'Available'
|
||||
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
|
||||
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{provider.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Recent AI Requests
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Recent AI requests will be displayed here. (Coming Soon)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
memento-note/app/(main)/admin/create-user-dialog.tsx
Normal file
80
memento-note/app/(main)/admin/create-user-dialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { createUser } from '@/app/actions/admin'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function CreateUserDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" /> {t('admin.users.addUser')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.users.createUser')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('admin.users.createUserDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
action={async (formData) => {
|
||||
const result = await createUser(formData)
|
||||
if (result?.error) {
|
||||
toast.error(t('admin.users.createFailed'))
|
||||
} else {
|
||||
toast.success(t('admin.users.createSuccess'))
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
className="grid gap-4 py-4"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="name">{t('admin.users.name')}</label>
|
||||
<Input id="name" name="name" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="email">{t('admin.users.email')}</label>
|
||||
<Input id="email" name="email" type="email" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="password">{t('admin.users.password')}</label>
|
||||
<Input id="password" name="password" type="password" required minLength={6} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="role">{t('admin.users.role')}</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{t('admin.users.createUser')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
23
memento-note/app/(main)/admin/layout.tsx
Normal file
23
memento-note/app/(main)/admin/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||
import { AdminContentArea } from '@/components/admin-content-area'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
|
||||
if ((session?.user as any)?.role !== 'ADMIN') {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-zinc-950">
|
||||
<AdminSidebar />
|
||||
<AdminContentArea>{children}</AdminContentArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
memento-note/app/(main)/admin/page.tsx
Normal file
59
memento-note/app/(main)/admin/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getUsers } from '@/app/actions/admin'
|
||||
import { AdminMetrics } from '@/components/admin-metrics'
|
||||
import { Users, Activity, Database, Zap } from 'lucide-react'
|
||||
|
||||
export default async function AdminPage() {
|
||||
const users = await getUsers()
|
||||
|
||||
// Mock metrics data - in a real app, these would come from analytics
|
||||
const metrics = [
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: users.length,
|
||||
trend: { value: 12, isPositive: true },
|
||||
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
|
||||
},
|
||||
{
|
||||
title: 'Active Sessions',
|
||||
value: '24',
|
||||
trend: { value: 8, isPositive: true },
|
||||
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
|
||||
},
|
||||
{
|
||||
title: 'Total Notes',
|
||||
value: '1,234',
|
||||
trend: { value: 24, isPositive: true },
|
||||
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
|
||||
},
|
||||
{
|
||||
title: 'AI Requests',
|
||||
value: '856',
|
||||
trend: { value: 5, isPositive: false },
|
||||
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Overview of your application metrics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminMetrics metrics={metrics} />
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Recent activity will be displayed here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
618
memento-note/app/(main)/admin/settings/admin-settings-form.tsx
Normal file
618
memento-note/app/(main)/admin/settings/admin-settings-form.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type AIProvider = 'ollama' | 'openai' | 'custom'
|
||||
|
||||
interface AvailableModels {
|
||||
tags: string[]
|
||||
embeddings: string[]
|
||||
}
|
||||
|
||||
const MODELS_2026 = {
|
||||
// Removed hardcoded Ollama models in favor of dynamic fetching
|
||||
openai: {
|
||||
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
|
||||
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
|
||||
},
|
||||
custom: {
|
||||
tags: ['gpt-4o-mini', 'gpt-4o', 'claude-3-haiku', 'claude-3-sonnet', 'llama-3.1-8b'],
|
||||
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||
const { t } = useLanguage()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
// Local state for Checkbox
|
||||
const [allowRegister, setAllowRegister] = useState(config.ALLOW_REGISTRATION !== 'false')
|
||||
const [smtpSecure, setSmtpSecure] = useState(config.SMTP_SECURE === 'true')
|
||||
const [smtpIgnoreCert, setSmtpIgnoreCert] = useState(config.SMTP_IGNORE_CERT === 'true')
|
||||
|
||||
// AI Provider state - separated for tags and embeddings
|
||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
|
||||
// Selected Models State (Controlled Inputs)
|
||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
|
||||
|
||||
// Dynamic Models State
|
||||
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
|
||||
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
|
||||
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
|
||||
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
|
||||
|
||||
// Sync state with config
|
||||
useEffect(() => {
|
||||
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
|
||||
setSmtpSecure(config.SMTP_SECURE === 'true')
|
||||
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
|
||||
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
setSelectedTagsModel(config.AI_MODEL_TAGS || '')
|
||||
setSelectedEmbeddingModel(config.AI_MODEL_EMBEDDING || '')
|
||||
}, [config])
|
||||
|
||||
// Fetch Ollama models
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingTagsModels(true)
|
||||
else setIsLoadingEmbeddingsModels(true)
|
||||
|
||||
try {
|
||||
const result = await getOllamaModels(url)
|
||||
|
||||
if (result.success) {
|
||||
if (type === 'tags') setOllamaTagsModels(result.models)
|
||||
else setOllamaEmbeddingsModels(result.models)
|
||||
} else {
|
||||
toast.error(`Failed to fetch Ollama models: ${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('Failed to fetch Ollama models')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingTagsModels(false)
|
||||
else setIsLoadingEmbeddingsModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial fetch for Ollama models if provider is selected
|
||||
useEffect(() => {
|
||||
if (tagsProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('tags', url)
|
||||
}
|
||||
}, [tagsProvider, config.OLLAMA_BASE_URL_TAGS, config.OLLAMA_BASE_URL, fetchOllamaModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddingsProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('embeddings', url)
|
||||
}
|
||||
}, [embeddingsProvider, config.OLLAMA_BASE_URL_EMBEDDING, config.OLLAMA_BASE_URL, fetchOllamaModels])
|
||||
|
||||
const handleSaveSecurity = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
ALLOW_REGISTRATION: allowRegister ? 'true' : 'false',
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('admin.security.updateFailed'))
|
||||
} else {
|
||||
toast.success(t('admin.security.updateSuccess'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAI = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data: Record<string, string> = {}
|
||||
|
||||
try {
|
||||
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
|
||||
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
|
||||
data.AI_PROVIDER_TAGS = tagsProv
|
||||
|
||||
const tagsModel = formData.get('AI_MODEL_TAGS') as string
|
||||
if (tagsModel) data.AI_MODEL_TAGS = tagsModel
|
||||
|
||||
if (tagsProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
|
||||
} else if (tagsProv === 'openai') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||
} else if (tagsProv === 'custom') {
|
||||
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_TAGS') as string
|
||||
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_TAGS') as string
|
||||
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
|
||||
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
|
||||
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
|
||||
data.AI_PROVIDER_EMBEDDING = embedProv
|
||||
|
||||
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
|
||||
if (embedModel) data.AI_MODEL_EMBEDDING = embedModel
|
||||
|
||||
if (embedProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
|
||||
} else if (embedProv === 'openai') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||
} else if (embedProv === 'custom') {
|
||||
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_EMBEDDING') as string
|
||||
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as string
|
||||
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
|
||||
} else {
|
||||
toast.success(t('admin.ai.updateSuccess'))
|
||||
setTagsProvider(tagsProv)
|
||||
setEmbeddingsProvider(embedProv)
|
||||
|
||||
// Refresh models after save if Ollama is selected
|
||||
if (tagsProv === 'ollama') {
|
||||
const url = data.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('tags', url)
|
||||
}
|
||||
if (embedProv === 'ollama') {
|
||||
const url = data.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('embeddings', url)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setIsSaving(false)
|
||||
toast.error(t('general.error') + ': ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSMTP = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
SMTP_HOST: formData.get('SMTP_HOST') as string,
|
||||
SMTP_PORT: formData.get('SMTP_PORT') as string,
|
||||
SMTP_USER: formData.get('SMTP_USER') as string,
|
||||
SMTP_PASS: formData.get('SMTP_PASS') as string,
|
||||
SMTP_FROM: formData.get('SMTP_FROM') as string,
|
||||
SMTP_IGNORE_CERT: smtpIgnoreCert ? 'true' : 'false',
|
||||
SMTP_SECURE: smtpSecure ? 'true' : 'false',
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('admin.smtp.updateFailed'))
|
||||
} else {
|
||||
toast.success(t('admin.smtp.updateSuccess'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestEmail = async () => {
|
||||
setIsTesting(true)
|
||||
try {
|
||||
const result: any = await testSMTP()
|
||||
if (result.success) {
|
||||
toast.success(t('admin.smtp.testSuccess'))
|
||||
} else {
|
||||
toast.error(t('admin.smtp.testFailed', { error: result.error }))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(t('general.error') + ': ' + e.message)
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.security.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.security.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveSecurity}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ALLOW_REGISTRATION"
|
||||
checked={allowRegister}
|
||||
onCheckedChange={(c) => setAllowRegister(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="ALLOW_REGISTRATION"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('admin.security.allowPublicRegistration')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.security.allowPublicRegistrationDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.ai.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.ai.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveAI}>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_TAGS"
|
||||
name="AI_PROVIDER_TAGS"
|
||||
value={tagsProvider}
|
||||
onChange={(e) => setTagsProvider(e.target.value as AIProvider)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{tagsProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="OLLAMA_BASE_URL_TAGS"
|
||||
name="OLLAMA_BASE_URL_TAGS"
|
||||
defaultValue={config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('OLLAMA_BASE_URL_TAGS') as HTMLInputElement
|
||||
fetchOllamaModels('tags', input.value)
|
||||
}}
|
||||
disabled={isLoadingTagsModels}
|
||||
title="Refresh Models"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_OLLAMA"
|
||||
name="AI_MODEL_TAGS_OLLAMA"
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{ollamaTagsModels.length > 0 ? (
|
||||
ollamaTagsModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingTagsModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tagsProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_OPENAI"
|
||||
name="AI_MODEL_TAGS_OPENAI"
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.openai.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tagsProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_CUSTOM"
|
||||
name="AI_MODEL_TAGS_CUSTOM"
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.custom.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_EMBEDDING">
|
||||
{t('admin.ai.provider')}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Current: {embeddingsProvider})
|
||||
</span>
|
||||
</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_EMBEDDING"
|
||||
name="AI_PROVIDER_EMBEDDING"
|
||||
value={embeddingsProvider}
|
||||
onChange={(e) => setEmbeddingsProvider(e.target.value as AIProvider)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{embeddingsProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="OLLAMA_BASE_URL_EMBEDDING"
|
||||
name="OLLAMA_BASE_URL_EMBEDDING"
|
||||
defaultValue={config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('OLLAMA_BASE_URL_EMBEDDING') as HTMLInputElement
|
||||
fetchOllamaModels('embeddings', input.value)
|
||||
}}
|
||||
disabled={isLoadingEmbeddingsModels}
|
||||
title="Refresh Models"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
name="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{ollamaEmbeddingsModels.length > 0 ? (
|
||||
ollamaEmbeddingsModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingEmbeddingsModels ? 'Fetching models...' : t('admin.ai.selectEmbeddingModel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{embeddingsProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_OPENAI"
|
||||
name="AI_MODEL_EMBEDDING_OPENAI"
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.openai.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{embeddingsProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
name="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.custom.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
<Link href="/admin/ai-test">
|
||||
<Button type="button" variant="outline" className="gap-2">
|
||||
<TestTube className="h-4 w-4" />
|
||||
{t('admin.ai.openTestPanel')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.smtp.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.smtp.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveSMTP}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
|
||||
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
|
||||
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
|
||||
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
|
||||
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
|
||||
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_SECURE"
|
||||
checked={smtpSecure}
|
||||
onCheckedChange={(c) => setSmtpSecure(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_SECURE"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('admin.smtp.forceSSL')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_IGNORE_CERT"
|
||||
checked={smtpIgnoreCert}
|
||||
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_IGNORE_CERT"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
||||
>
|
||||
{t('admin.smtp.ignoreCertErrors')}
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
memento-note/app/(main)/admin/settings/page.tsx
Normal file
23
memento-note/app/(main)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getSystemConfig } from '@/app/actions/admin-settings'
|
||||
import { AdminSettingsForm } from './admin-settings-form'
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const config = await getSystemConfig()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configure application-wide settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
|
||||
<AdminSettingsForm config={config} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
memento-note/app/(main)/admin/user-list.tsx
Normal file
83
memento-note/app/(main)/admin/user-list.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteUser, updateUserRole } from '@/app/actions/admin'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2, Shield, ShieldOff } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm(t('admin.users.confirmDelete'))) return
|
||||
try {
|
||||
await deleteUser(id)
|
||||
toast.success(t('admin.users.deleteSuccess'))
|
||||
} catch (e) {
|
||||
toast.error(t('admin.users.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = async (user: any) => {
|
||||
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
||||
try {
|
||||
await updateUserRole(user.id, newRole)
|
||||
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
|
||||
} catch (e) {
|
||||
toast.error(t('admin.users.roleUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto">
|
||||
<table className="w-full caption-bottom text-sm text-left">
|
||||
<thead className="[&_tr]:border-b">
|
||||
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
|
||||
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="[&_tr:last-child]:border-0">
|
||||
{initialUsers.map((user) => (
|
||||
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
|
||||
<td className="p-4 align-middle">{user.email}</td>
|
||||
<td className="p-4 align-middle">
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
||||
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
||||
<td className="p-4 align-middle text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRoleToggle(user)}
|
||||
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
|
||||
>
|
||||
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
memento-note/app/(main)/admin/users/page.tsx
Normal file
29
memento-note/app/(main)/admin/users/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getUsers } from '@/app/actions/admin'
|
||||
import { CreateUserDialog } from '../create-user-dialog'
|
||||
import { UserList } from '../user-list'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const users = await getUsers()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Users
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage application users and permissions
|
||||
</p>
|
||||
</div>
|
||||
<CreateUserDialog />
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<UserList initialUsers={users} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
memento-note/app/(main)/archive/page.tsx
Normal file
16
memento-note/app/(main)/archive/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getArchivedNotes } from '@/app/actions/notes'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
import { ArchiveHeader } from '@/components/archive-header'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ArchivePage() {
|
||||
const notes = await getArchivedNotes()
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<ArchiveHeader />
|
||||
<MasonryGrid notes={notes} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
34
memento-note/app/(main)/layout.tsx
Normal file
34
memento-note/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { HeaderWrapper } from "@/components/header-wrapper";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
import { ProvidersWrapper } from "@/components/providers-wrapper";
|
||||
import { auth } from "@/auth";
|
||||
import { detectUserLanguage } from "@/lib/i18n/detect-user-language";
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await auth();
|
||||
const initialLanguage = await detectUserLanguage();
|
||||
|
||||
return (
|
||||
<ProvidersWrapper initialLanguage={initialLanguage}>
|
||||
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white overflow-hidden h-screen flex flex-col">
|
||||
{/* Top Navigation - Style Keep */}
|
||||
<HeaderWrapper user={session?.user} />
|
||||
|
||||
{/* Main Layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar Navigation - Style Keep */}
|
||||
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-r border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ProvidersWrapper>
|
||||
);
|
||||
}
|
||||
464
memento-note/app/(main)/page.tsx
Normal file
464
memento-note/app/(main)/page.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAllNotes, getPinnedNotes, getRecentNotes, searchNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { NoteInput } from '@/components/note-input'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { NoteEditor } from '@/components/note-editor'
|
||||
import { BatchOrganizationDialog } from '@/components/batch-organization-dialog'
|
||||
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
|
||||
import { FavoritesSection } from '@/components/favorites-section'
|
||||
import { RecentNotesSection } from '@/components/recent-notes-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LabelFilter } from '@/components/label-filter'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
// Force re-render when search params change (for filtering)
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
|
||||
const [recentNotes, setRecentNotes] = useState<Note[]>([])
|
||||
const [showRecentNotes, setShowRecentNotes] = useState(false)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const { labels } = useLabels()
|
||||
|
||||
// Auto label suggestion (IA4)
|
||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||
|
||||
// Open auto label dialog when suggestion is available
|
||||
useEffect(() => {
|
||||
if (shouldSuggestLabels && suggestNotebookId) {
|
||||
setAutoLabelOpen(true)
|
||||
}
|
||||
}, [shouldSuggestLabels, suggestNotebookId])
|
||||
|
||||
// Check if viewing Notes générales (no notebook filter)
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const isInbox = !notebookFilter
|
||||
|
||||
// Callback for NoteInput to trigger notebook suggestion and update UI
|
||||
const handleNoteCreated = useCallback((note: Note) => {
|
||||
|
||||
|
||||
// Update UI immediately by adding the note to the list if it matches current filters
|
||||
setNotes((prevNotes) => {
|
||||
// Check if note matches current filters
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
|
||||
// Check notebook filter
|
||||
if (notebookFilter && note.notebookId !== notebookFilter) {
|
||||
return prevNotes // Note doesn't match notebook filter
|
||||
}
|
||||
if (!notebookFilter && note.notebookId) {
|
||||
return prevNotes // Viewing inbox but note has notebook
|
||||
}
|
||||
|
||||
// Check label filter
|
||||
if (labelFilter.length > 0) {
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelFilter.includes(label))) {
|
||||
return prevNotes // Note doesn't match label filter
|
||||
}
|
||||
}
|
||||
|
||||
// Check color filter
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) {
|
||||
return prevNotes // Note doesn't match color filter
|
||||
}
|
||||
}
|
||||
|
||||
// Check search filter (simple check - if searching, let refresh handle it)
|
||||
if (search) {
|
||||
// If searching, refresh to get proper search results
|
||||
router.refresh()
|
||||
return prevNotes
|
||||
}
|
||||
|
||||
// Note matches all filters - add it optimistically to the beginning of the list
|
||||
// (newest notes first based on order: isPinned desc, order asc, updatedAt desc)
|
||||
const isPinned = note.isPinned || false
|
||||
const pinnedNotes = prevNotes.filter(n => n.isPinned)
|
||||
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
|
||||
|
||||
if (isPinned) {
|
||||
// Add to beginning of pinned notes
|
||||
return [note, ...pinnedNotes, ...unpinnedNotes]
|
||||
} else {
|
||||
// Add to beginning of unpinned notes
|
||||
return [...pinnedNotes, note, ...unpinnedNotes]
|
||||
}
|
||||
})
|
||||
|
||||
// Only suggest if note has no notebook and has 20+ words
|
||||
if (!note.notebookId) {
|
||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
|
||||
if (wordCount >= 20) {
|
||||
|
||||
setNotebookSuggestion({
|
||||
noteId: note.id,
|
||||
content: note.content || ''
|
||||
})
|
||||
} else {
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
|
||||
// Refresh in background to ensure data consistency (non-blocking)
|
||||
router.refresh()
|
||||
}, [searchParams, labels, router])
|
||||
|
||||
const handleOpenNote = (noteId: string) => {
|
||||
const note = notes.find(n => n.id === noteId)
|
||||
if (note) {
|
||||
setEditingNote({ note, readOnly: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Enable reminder notifications
|
||||
useReminderCheck(notes)
|
||||
|
||||
// Load user settings to check if recent notes should be shown
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await getAISettings()
|
||||
setShowRecentNotes(settings.showRecentNotes === true)
|
||||
} catch (error) {
|
||||
setShowRecentNotes(false)
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
}, [refreshKey])
|
||||
|
||||
useEffect(() => {
|
||||
const loadNotes = async () => {
|
||||
setIsLoading(true)
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
const semanticMode = searchParams.get('semantic') === 'true'
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
|
||||
let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes()
|
||||
|
||||
// Filter by selected notebook
|
||||
if (notebookFilter) {
|
||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter)
|
||||
} else {
|
||||
// If no notebook selected, only show notes without notebook (Notes générales)
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
||||
}
|
||||
|
||||
// Filter by selected labels
|
||||
if (labelFilter.length > 0) {
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by color (filter notes that have labels with this color)
|
||||
// Note: We use a ref-like pattern to avoid including labels in dependencies
|
||||
// This prevents dialog closing when adding new labels
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Load pinned notes separately (shown in favorites section)
|
||||
const pinned = await getPinnedNotes(notebookFilter || undefined)
|
||||
|
||||
setPinnedNotes(pinned)
|
||||
|
||||
// Load recent notes only if enabled in settings
|
||||
if (showRecentNotes) {
|
||||
const recent = await getRecentNotes(3)
|
||||
// Filter recent notes by current filters
|
||||
if (notebookFilter) {
|
||||
setRecentNotes(recent.filter((note: any) => note.notebookId === notebookFilter))
|
||||
} else {
|
||||
setRecentNotes(recent.filter((note: any) => !note.notebookId))
|
||||
}
|
||||
} else {
|
||||
setRecentNotes([])
|
||||
}
|
||||
|
||||
setNotes(allNotes)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
loadNotes()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, refreshKey, showRecentNotes]) // Intentionally omit 'labels' and 'semantic' to prevent reload when adding tags or from router.push
|
||||
// Get notebooks context to display header
|
||||
const { notebooks } = useNotebooks()
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
const [showNoteInput, setShowNoteInput] = useState(false)
|
||||
|
||||
// Get icon component for header
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
return ICON_MAP[iconName] || Folder
|
||||
}
|
||||
|
||||
// Handle Note Created to close the input if desired, or keep open
|
||||
const handleNoteCreatedWrapper = (note: any) => {
|
||||
handleNoteCreated(note)
|
||||
setShowNoteInput(false)
|
||||
}
|
||||
|
||||
// Helper for Breadcrumbs
|
||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<span>{t('nav.notebooks')}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="font-medium text-primary">{notebookName}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="w-full px-8 py-6 flex flex-col h-full">
|
||||
{/* Notebook Specific Header */}
|
||||
{currentNotebook ? (
|
||||
<div className="flex flex-col gap-6 mb-8 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs notebookName={currentNotebook.name} />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Title Section */}
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
|
||||
return (
|
||||
<Icon
|
||||
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
|
||||
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Default Header for Home/Inbox */
|
||||
<div className="flex flex-col gap-6 mb-8 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
{/* Breadcrumbs Placeholder or just spacing */}
|
||||
<div className="h-5 mb-1"></div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Title Section */}
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
|
||||
{/* AI Organization Button - Moved to Header */}
|
||||
{isInbox && !isLoading && notes.length >= 2 && (
|
||||
<Button
|
||||
onClick={() => setBatchOrganizationOpen(true)}
|
||||
variant="outline"
|
||||
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
||||
title={t('batch.organizeWithAI')}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.newNote')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Input - Conditionally Visible or Always Visible on Home */}
|
||||
{/* Note Input - Conditionally Rendered */}
|
||||
{showNoteInput && (
|
||||
<div className="mb-8 animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<NoteInput
|
||||
onNoteCreated={handleNoteCreatedWrapper}
|
||||
forceExpanded={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Favorites Section - Pinned Notes */}
|
||||
<FavoritesSection
|
||||
pinnedNotes={pinnedNotes}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
/>
|
||||
|
||||
{/* Recent Notes Section - Only shown if enabled in settings */}
|
||||
{showRecentNotes && (
|
||||
<RecentNotesSection
|
||||
recentNotes={recentNotes.filter(note => !note.isPinned)}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Notes Grid - Unpinned Notes Only */}
|
||||
{notes.filter(note => !note.isPinned).length > 0 && (
|
||||
<div data-testid="notes-grid">
|
||||
<MasonryGrid
|
||||
notes={notes.filter(note => !note.isPinned)}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state when no notes */}
|
||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{t('notes.emptyState')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Memory Echo - Proactive note connections */}
|
||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
||||
|
||||
{/* Notebook Suggestion - IA1 */}
|
||||
{notebookSuggestion && (
|
||||
<NotebookSuggestionToast
|
||||
noteId={notebookSuggestion.noteId}
|
||||
noteContent={notebookSuggestion.content}
|
||||
onDismiss={() => setNotebookSuggestion(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch Organization Dialog - IA3 */}
|
||||
<BatchOrganizationDialog
|
||||
open={batchOrganizationOpen}
|
||||
onOpenChange={setBatchOrganizationOpen}
|
||||
onNotesMoved={() => {
|
||||
// Refresh notes to see updated notebook assignments
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Auto Label Suggestion Dialog - IA4 */}
|
||||
<AutoLabelSuggestionDialog
|
||||
open={autoLabelOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAutoLabelOpen(open)
|
||||
if (!open) dismissLabelSuggestion()
|
||||
}}
|
||||
notebookId={suggestNotebookId}
|
||||
onLabelsCreated={() => {
|
||||
// Refresh to see new labels
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Note Editor Modal */}
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
136
memento-note/app/(main)/settings/about/page.tsx
Normal file
136
memento-note/app/(main)/settings/about/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { SettingsSection } from '@/components/settings'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function AboutSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const version = '1.0.0'
|
||||
const buildDate = '2026-01-17'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('about.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('about.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('about.appName')}
|
||||
icon={<span className="text-2xl">📝</span>}
|
||||
description={t('about.appDescription')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{t('about.version')}</span>
|
||||
<Badge variant="secondary">{version}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{t('about.buildDate')}</span>
|
||||
<Badge variant="outline">{buildDate}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{t('about.platform')}</span>
|
||||
<Badge variant="outline">{t('about.platformWeb')}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('about.features.title')}
|
||||
icon={<span className="text-2xl">✨</span>}
|
||||
description={t('about.features.description')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.titleSuggestions')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.semanticSearch')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.paragraphReformulation')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.memoryEcho')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.notebookOrganization')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.dragDrop')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.labelSystem')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-500">✓</span>
|
||||
<span>{t('about.features.multipleProviders')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('about.technology.title')}
|
||||
icon={<span className="text-2xl">⚙️</span>}
|
||||
description={t('about.technology.description')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2 text-sm">
|
||||
<div><strong>{t('about.technology.frontend')}:</strong> Next.js 16, React 19, TypeScript</div>
|
||||
<div><strong>{t('about.technology.backend')}:</strong> Next.js API Routes, Server Actions</div>
|
||||
<div><strong>{t('about.technology.database')}:</strong> SQLite (Prisma ORM)</div>
|
||||
<div><strong>{t('about.technology.authentication')}:</strong> NextAuth 5</div>
|
||||
<div><strong>{t('about.technology.ai')}:</strong> Vercel AI SDK, OpenAI, Ollama</div>
|
||||
<div><strong>{t('about.technology.ui')}:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
|
||||
<div><strong>{t('about.technology.testing')}:</strong> Playwright (E2E)</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('about.support.title')}
|
||||
icon={<span className="text-2xl">💬</span>}
|
||||
description={t('about.support.description')}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div>
|
||||
<p className="font-medium mb-2">{t('about.support.documentation')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Check the documentation for detailed guides and tutorials.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-2">{t('about.support.reportIssues')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Found a bug? Report it in the issue tracker.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-2">{t('about.support.feedback')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
We value your feedback! Share your thoughts and suggestions.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
memento-note/app/(main)/settings/ai/ai-settings-header.tsx
Normal file
16
memento-note/app/(main)/settings/ai/ai-settings-header.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function AISettingsHeader() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">{t('aiSettings.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
memento-note/app/(main)/settings/ai/page-new.tsx
Normal file
184
memento-note/app/(main)/settings/ai/page-new.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect, SettingInput } from '@/components/settings'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function AISettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
// Mock settings state - in real implementation, load from server
|
||||
const [settings, setSettings] = useState({
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily' as 'daily' | 'weekly' | 'custom',
|
||||
aiProvider: 'auto' as 'auto' | 'openai' | 'ollama',
|
||||
preferredLanguage: 'auto' as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
|
||||
demoMode: false
|
||||
})
|
||||
|
||||
const handleToggle = async (feature: string, value: boolean) => {
|
||||
setSettings(prev => ({ ...prev, [feature]: value }))
|
||||
try {
|
||||
await updateAISettings({ [feature]: value })
|
||||
} catch (error) {
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(settings) // Revert on error
|
||||
}
|
||||
}
|
||||
|
||||
const handleFrequencyChange = async (value: string) => {
|
||||
setSettings(prev => ({ ...prev, memoryEchoFrequency: value as any }))
|
||||
try {
|
||||
await updateAISettings({ memoryEchoFrequency: value as any })
|
||||
} catch (error) {
|
||||
toast.error(t('aiSettings.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderChange = async (value: string) => {
|
||||
setSettings(prev => ({ ...prev, aiProvider: value as any }))
|
||||
try {
|
||||
await updateAISettings({ aiProvider: value as any })
|
||||
} catch (error) {
|
||||
toast.error(t('aiSettings.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleApiKeyChange = async (value: string) => {
|
||||
setApiKey(value)
|
||||
// TODO: Implement API key persistence
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="lg:col-span-1">
|
||||
<SettingsNav />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="lg:col-span-3 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('aiSettings.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Provider */}
|
||||
<SettingsSection
|
||||
title={t('aiSettings.provider')}
|
||||
icon={<span className="text-2xl">🤖</span>}
|
||||
description={t('aiSettings.providerDesc')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('aiSettings.provider')}
|
||||
description={t('aiSettings.providerDesc')}
|
||||
value={settings.aiProvider}
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('aiSettings.providerAuto'),
|
||||
description: t('aiSettings.providerAutoDesc')
|
||||
},
|
||||
{
|
||||
value: 'ollama',
|
||||
label: t('aiSettings.providerOllama'),
|
||||
description: t('aiSettings.providerOllamaDesc')
|
||||
},
|
||||
{
|
||||
value: 'openai',
|
||||
label: t('aiSettings.providerOpenAI'),
|
||||
description: t('aiSettings.providerOpenAIDesc')
|
||||
},
|
||||
]}
|
||||
onChange={handleProviderChange}
|
||||
/>
|
||||
|
||||
{settings.aiProvider === 'openai' && (
|
||||
<SettingInput
|
||||
label={t('admin.ai.apiKey')}
|
||||
description={t('admin.ai.openAIKeyDescription')}
|
||||
value={apiKey}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
onChange={handleApiKeyChange}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Feature Toggles */}
|
||||
<SettingsSection
|
||||
title={t('aiSettings.features')}
|
||||
icon={<span className="text-2xl">✨</span>}
|
||||
description={t('aiSettings.description')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('titleSuggestions.available').replace('💡 ', '')}
|
||||
description={t('aiSettings.titleSuggestionsDesc')}
|
||||
checked={settings.titleSuggestions}
|
||||
onChange={(checked) => handleToggle('titleSuggestions', checked)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label={t('semanticSearch.exactMatch')}
|
||||
description={t('semanticSearch.searching')}
|
||||
checked={settings.semanticSearch}
|
||||
onChange={(checked) => handleToggle('semanticSearch', checked)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label={t('paragraphRefactor.title')}
|
||||
description={t('aiSettings.paragraphRefactorDesc')}
|
||||
checked={settings.paragraphRefactor}
|
||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label={t('memoryEcho.title')}
|
||||
description={t('memoryEcho.dailyInsight')}
|
||||
checked={settings.memoryEcho}
|
||||
onChange={(checked) => handleToggle('memoryEcho', checked)}
|
||||
/>
|
||||
|
||||
{settings.memoryEcho && (
|
||||
<SettingSelect
|
||||
label={t('aiSettings.frequency')}
|
||||
description={t('aiSettings.frequencyDesc')}
|
||||
value={settings.memoryEchoFrequency}
|
||||
options={[
|
||||
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
|
||||
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Demo Mode */}
|
||||
<SettingsSection
|
||||
title={t('demoMode.title')}
|
||||
icon={<span className="text-2xl">🎭</span>}
|
||||
description={t('demoMode.description')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('demoMode.title')}
|
||||
description={t('demoMode.description')}
|
||||
checked={settings.demoMode}
|
||||
onChange={(checked) => handleToggle('demoMode', checked)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
memento-note/app/(main)/settings/ai/page.tsx
Normal file
22
memento-note/app/(main)/settings/ai/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AISettingsPanel } from '@/components/ai/ai-settings-panel'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { AISettingsHeader } from './ai-settings-header'
|
||||
|
||||
export default async function AISettingsPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
const settings = await getAISettings()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AISettingsHeader />
|
||||
<AISettingsPanel initialSettings={settings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
memento-note/app/(main)/settings/appearance/appearance-form.tsx
Normal file
107
memento-note/app/(main)/settings/appearance/appearance-form.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { SettingsSection, SettingSelect } from '@/components/settings'
|
||||
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AppearanceSettingsFormProps {
|
||||
initialTheme: string
|
||||
initialFontSize: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize }: AppearanceSettingsFormProps) {
|
||||
const router = useRouter()
|
||||
const [theme, setTheme] = useState(initialTheme)
|
||||
const [fontSize, setFontSize] = useState(initialFontSize)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
localStorage.setItem('theme-preference', value)
|
||||
|
||||
// Instant visual update
|
||||
const root = document.documentElement
|
||||
root.removeAttribute('data-theme')
|
||||
root.classList.remove('dark')
|
||||
|
||||
if (value === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
||||
} else if (value === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else if (value === 'light') {
|
||||
root.setAttribute('data-theme', 'light')
|
||||
} else {
|
||||
root.setAttribute('data-theme', value)
|
||||
if (['midnight', 'blue', 'sepia'].includes(value)) root.classList.add('dark')
|
||||
}
|
||||
|
||||
// Save to DB (no need for router.refresh - localStorage handles immediate visuals)
|
||||
await updateUser({ theme: value as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue' })
|
||||
}
|
||||
|
||||
const handleFontSizeChange = async (value: string) => {
|
||||
setFontSize(value)
|
||||
|
||||
// Instant visual update
|
||||
const fontSizeMap: Record<string, string> = {
|
||||
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
|
||||
}
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
|
||||
|
||||
await updateAI({ fontSize: value as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('appearance.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.theme')}
|
||||
icon={<span className="text-2xl">🎨</span>}
|
||||
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.theme')}
|
||||
description={t('settings.selectLanguage')}
|
||||
value={theme}
|
||||
options={[
|
||||
{ value: 'slate', label: t('settings.themeLight') },
|
||||
{ value: 'dark', label: t('settings.themeDark') },
|
||||
{ value: 'sepia', label: 'Sepia' },
|
||||
{ value: 'midnight', label: 'Midnight' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'auto', label: t('settings.themeSystem') },
|
||||
]}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('profile.fontSize')}
|
||||
icon={<span className="text-2xl">📝</span>}
|
||||
description={t('profile.fontSizeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('profile.fontSize')}
|
||||
description={t('profile.selectFontSize')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
memento-note/app/(main)/settings/appearance/page.tsx
Normal file
113
memento-note/app/(main)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
|
||||
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function AppearanceSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState('auto')
|
||||
const [fontSize, setFontSize] = useState('medium')
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const [aiSettings, userSettings] = await Promise.all([
|
||||
getAISettings(),
|
||||
getUserSettings()
|
||||
])
|
||||
if (aiSettings.fontSize) setFontSize(aiSettings.fontSize)
|
||||
if (userSettings.theme) setTheme(userSettings.theme)
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error)
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
localStorage.setItem('theme-preference', value)
|
||||
|
||||
// Instant visual update
|
||||
const root = document.documentElement
|
||||
root.removeAttribute('data-theme')
|
||||
root.classList.remove('dark')
|
||||
|
||||
if (value === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
||||
} else if (value === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.setAttribute('data-theme', value)
|
||||
if (['midnight'].includes(value)) root.classList.add('dark')
|
||||
}
|
||||
|
||||
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
|
||||
}
|
||||
|
||||
const handleFontSizeChange = async (value: string) => {
|
||||
setFontSize(value)
|
||||
|
||||
// Instant visual update
|
||||
const fontSizeMap: Record<string, string> = {
|
||||
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
|
||||
}
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
|
||||
|
||||
await updateAISettings({ fontSize: value as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('appearance.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.theme')}
|
||||
icon={<span className="text-2xl">🎨</span>}
|
||||
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.theme')}
|
||||
description={t('settings.selectLanguage')}
|
||||
value={theme}
|
||||
options={[
|
||||
{ value: 'light', label: t('settings.themeLight') },
|
||||
{ value: 'dark', label: t('settings.themeDark') },
|
||||
{ value: 'sepia', label: 'Sepia' },
|
||||
{ value: 'midnight', label: 'Midnight' },
|
||||
{ value: 'auto', label: t('settings.themeSystem') },
|
||||
]}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('profile.fontSize')}
|
||||
icon={<span className="text-2xl">📝</span>}
|
||||
description={t('profile.fontSizeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('profile.fontSize')}
|
||||
description={t('profile.selectFontSize')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
memento-note/app/(main)/settings/data/page.tsx
Normal file
189
memento-note/app/(main)/settings/data/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SettingsSection } from '@/components/settings'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Download, Upload, Trash2, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function DataSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const response = await fetch('/api/notes/export')
|
||||
if (response.ok) {
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `memento-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast.success(t('dataManagement.export.success'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
toast.error(t('dataManagement.export.failed'))
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch('/api/notes/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
toast.success(t('dataManagement.import.success', { count: result.count }))
|
||||
window.location.reload()
|
||||
} else {
|
||||
throw new Error('Import failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
toast.error(t('dataManagement.import.failed'))
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (!confirm(t('dataManagement.delete.confirm'))) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
|
||||
if (response.ok) {
|
||||
toast.success(t('dataManagement.delete.success'))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error)
|
||||
toast.error(t('dataManagement.delete.failed'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('dataManagement.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('dataManagement.toolsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={`💾 ${t('dataManagement.export.title')}`}
|
||||
icon={<span className="text-2xl">💾</span>}
|
||||
description={t('dataManagement.export.description')}
|
||||
>
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div>
|
||||
<p className="font-medium">{t('dataManagement.export.title')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('dataManagement.export.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isExporting ? t('dataManagement.exporting') : t('dataManagement.export.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={`📥 ${t('dataManagement.import.title')}`}
|
||||
icon={<span className="text-2xl">📥</span>}
|
||||
description={t('dataManagement.import.description')}
|
||||
>
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div>
|
||||
<p className="font-medium">{t('dataManagement.import.title')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('dataManagement.import.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
disabled={isImporting}
|
||||
className="hidden"
|
||||
id="import-file"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => document.getElementById('import-file')?.click()}
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isImporting ? t('dataManagement.importing') : t('dataManagement.import.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={`⚠️ ${t('dataManagement.dangerZone')}`}
|
||||
icon={<span className="text-2xl">⚠️</span>}
|
||||
description={t('dataManagement.dangerZoneDescription')}
|
||||
>
|
||||
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
|
||||
<div>
|
||||
<p className="font-medium text-red-600 dark:text-red-400">{t('dataManagement.delete.title')}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('dataManagement.delete.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAll}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isDeleting ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
memento-note/app/(main)/settings/general/page.tsx
Normal file
140
memento-note/app/(main)/settings/general/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||
const [language, setLanguage] = useState('auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(false)
|
||||
const [anonymousAnalytics, setAnonymousAnalytics] = useState(false)
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const settings = await getAISettings()
|
||||
if (settings.preferredLanguage) setLanguage(settings.preferredLanguage)
|
||||
if (settings.emailNotifications !== undefined) setEmailNotifications(settings.emailNotifications)
|
||||
if (settings.desktopNotifications !== undefined) setDesktopNotifications(settings.desktopNotifications)
|
||||
if (settings.anonymousAnalytics !== undefined) setAnonymousAnalytics(settings.anonymousAnalytics)
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error)
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
|
||||
// 1. Update database settings
|
||||
await updateAISettings({ preferredLanguage: value as any })
|
||||
|
||||
// 2. Update local storage and application state
|
||||
if (value === 'auto') {
|
||||
localStorage.removeItem('user-language')
|
||||
toast.success("Language set to Auto")
|
||||
} else {
|
||||
localStorage.setItem('user-language', value)
|
||||
setContextLanguage(value as any)
|
||||
toast.success(t('profile.languageUpdateSuccess') || "Language updated")
|
||||
}
|
||||
|
||||
// 3. Force reload to ensure all components update (server components, metadata, etc.)
|
||||
setTimeout(() => window.location.reload(), 500)
|
||||
}
|
||||
|
||||
const handleEmailNotificationsChange = async (enabled: boolean) => {
|
||||
setEmailNotifications(enabled)
|
||||
await updateAISettings({ emailNotifications: enabled })
|
||||
}
|
||||
|
||||
const handleDesktopNotificationsChange = async (enabled: boolean) => {
|
||||
setDesktopNotifications(enabled)
|
||||
await updateAISettings({ desktopNotifications: enabled })
|
||||
}
|
||||
|
||||
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
|
||||
setAnonymousAnalytics(enabled)
|
||||
await updateAISettings({ anonymousAnalytics: enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('generalSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.language')}
|
||||
icon={<span className="text-2xl">🌍</span>}
|
||||
description={t('profile.languagePreferencesDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.language')}
|
||||
description={t('settings.selectLanguage')}
|
||||
value={language}
|
||||
options={[
|
||||
{ value: 'auto', label: t('profile.autoDetect') },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'fa', label: 'فارسی' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'hi', label: 'हिन्दी' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
]}
|
||||
onChange={handleLanguageChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.notifications')}
|
||||
icon={<span className="text-2xl">🔔</span>}
|
||||
description={t('settings.notifications')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('settings.notifications')}
|
||||
description={t('settings.notifications')}
|
||||
checked={emailNotifications}
|
||||
onChange={handleEmailNotificationsChange}
|
||||
/>
|
||||
<SettingToggle
|
||||
label={t('settings.notifications')}
|
||||
description={t('settings.notifications')}
|
||||
checked={desktopNotifications}
|
||||
onChange={handleDesktopNotificationsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.privacy')}
|
||||
icon={<span className="text-2xl">🔒</span>}
|
||||
description={t('settings.privacy')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('settings.privacy')}
|
||||
description={t('settings.privacy')}
|
||||
checked={anonymousAnalytics}
|
||||
onChange={handleAnonymousAnalyticsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
memento-note/app/(main)/settings/layout.tsx
Normal file
25
memento-note/app/(main)/settings/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { SettingsNav } from '@/components/settings'
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="lg:col-span-1">
|
||||
<SettingsNav />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="lg:col-span-3 space-y-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
memento-note/app/(main)/settings/page.tsx
Normal file
213
memento-note/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SettingsNav, SettingsSection } from '@/components/settings'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react'
|
||||
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cleanupLoading, setCleanupLoading] = useState(false)
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [config, setConfig] = useState<any>(null)
|
||||
|
||||
const checkConnection = async () => {
|
||||
setLoading(true)
|
||||
setStatus('idle')
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch('/api/ai/test')
|
||||
const data = await res.json()
|
||||
|
||||
setConfig({
|
||||
provider: data.provider,
|
||||
status: res.ok ? 'connected' : 'disconnected'
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('success')
|
||||
setResult(data)
|
||||
} else {
|
||||
setStatus('error')
|
||||
setResult(data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
setStatus('error')
|
||||
setResult({ message: error.message, stack: error.stack })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncLoading(true)
|
||||
try {
|
||||
const result = await syncAllEmbeddings()
|
||||
if (result.success) {
|
||||
toast.success(`Indexing complete: ${result.count} notes processed`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Error during indexing")
|
||||
} finally {
|
||||
setSyncLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanup = async () => {
|
||||
setCleanupLoading(true)
|
||||
try {
|
||||
const result = await cleanupAllOrphans()
|
||||
if (result.success) {
|
||||
toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Error during cleanup")
|
||||
} finally {
|
||||
setCleanupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link href="/settings/ai">
|
||||
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
|
||||
<BrainCircuit className="h-6 w-6 text-purple-500 mb-2" />
|
||||
<h3 className="font-semibold">{t('aiSettings.title')}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/settings/profile">
|
||||
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
|
||||
<RefreshCw className="h-6 w-6 text-primary mb-2" />
|
||||
<h3 className="font-semibold">{t('profile.title')}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* AI Diagnostics */}
|
||||
<SettingsSection
|
||||
title={t('diagnostics.title')}
|
||||
icon={<span className="text-2xl">🔍</span>}
|
||||
description={t('diagnostics.description')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="p-4 rounded-lg bg-secondary/50">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">{t('diagnostics.configuredProvider')}</p>
|
||||
<p className="text-lg font-mono">{config?.provider || '...'}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-secondary/50">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">{t('diagnostics.apiStatus')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
|
||||
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
|
||||
<span className={`text-sm font-medium ${status === 'success' ? 'text-green-600 dark:text-green-400' :
|
||||
status === 'error' ? 'text-red-600 dark:text-red-400' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{status === 'success' ? t('diagnostics.operational') :
|
||||
status === 'error' ? t('diagnostics.errorStatus') :
|
||||
t('diagnostics.checking')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="text-sm font-medium">{t('diagnostics.testDetails')}</h3>
|
||||
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error'
|
||||
? 'bg-red-50 text-red-900 border border-red-200 dark:bg-red-950 dark:text-red-100 dark:border-red-900'
|
||||
: 'bg-slate-950 text-slate-50'
|
||||
}`}>
|
||||
<pre>{JSON.stringify(result, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||
<p className="font-bold">{t('diagnostics.troubleshootingTitle')}</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>{t('diagnostics.tip1')}</li>
|
||||
<li>{t('diagnostics.tip2')}</li>
|
||||
<li>{t('diagnostics.tip3')}</li>
|
||||
<li>{t('diagnostics.tip4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
{t('general.testConnection')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Maintenance */}
|
||||
<SettingsSection
|
||||
title={t('settings.maintenance')}
|
||||
icon={<span className="text-2xl">🔧</span>}
|
||||
description={t('settings.maintenanceDescription')}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
{t('settings.cleanTags')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('settings.cleanTagsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
|
||||
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
|
||||
{t('general.clean')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
{t('settings.semanticIndexing')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('settings.semanticIndexingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
|
||||
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
|
||||
{t('general.indexAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ArrowRight, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function AISettingsLinkCard() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
{t('nav.aiSettings')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('nav.configureAI')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/settings/ai">
|
||||
<Button variant="outline" className="w-full justify-between group">
|
||||
<span>{t('nav.manageAISettings')}</span>
|
||||
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
155
memento-note/app/(main)/settings/profile/page-new.tsx
Normal file
155
memento-note/app/(main)/settings/profile/page-new.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingToggle, SettingInput, SettingSelect } from '@/components/settings'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function ProfileSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Mock user data - in real implementation, load from server
|
||||
const [user, setUser] = useState({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
|
||||
const [language, setLanguage] = useState('auto')
|
||||
const [showRecentNotes, setShowRecentNotes] = useState(false)
|
||||
|
||||
const handleNameChange = async (value: string) => {
|
||||
setUser(prev => ({ ...prev, name: value }))
|
||||
// TODO: Implement profile update
|
||||
|
||||
}
|
||||
|
||||
const handleEmailChange = async (value: string) => {
|
||||
setUser(prev => ({ ...prev, email: value }))
|
||||
// TODO: Implement email update
|
||||
|
||||
}
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
try {
|
||||
await updateAISettings({ preferredLanguage: value as any })
|
||||
} catch (error) {
|
||||
console.error('Error updating language:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecentNotesChange = async (enabled: boolean) => {
|
||||
setShowRecentNotes(enabled)
|
||||
try {
|
||||
await updateAISettings({ showRecentNotes: enabled })
|
||||
} catch (error) {
|
||||
console.error('Error updating recent notes setting:', error)
|
||||
toast.error(t('aiSettings.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="lg:col-span-1">
|
||||
<SettingsNav />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="lg:col-span-3 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('profile.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('profile.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Information */}
|
||||
<SettingsSection
|
||||
title={t('profile.accountSettings')}
|
||||
icon={<span className="text-2xl">👤</span>}
|
||||
description={t('profile.description')}
|
||||
>
|
||||
<SettingInput
|
||||
label={t('profile.displayName')}
|
||||
description={t('profile.displayName')}
|
||||
value={user.name}
|
||||
onChange={handleNameChange}
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
/>
|
||||
|
||||
<SettingInput
|
||||
label={t('profile.email')}
|
||||
description={t('profile.email')}
|
||||
value={user.email}
|
||||
type="email"
|
||||
onChange={handleEmailChange}
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Preferences */}
|
||||
<SettingsSection
|
||||
title={t('settings.language')}
|
||||
icon={<span className="text-2xl">⚙️</span>}
|
||||
description={t('profile.languagePreferencesDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('profile.preferredLanguage')}
|
||||
description={t('profile.languageDescription')}
|
||||
value={language}
|
||||
options={[
|
||||
{ value: 'auto', label: t('profile.autoDetect') },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'fa', label: 'فارسی' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'hi', label: 'हिन्दी' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
]}
|
||||
onChange={handleLanguageChange}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label={t('profile.showRecentNotes')}
|
||||
description={t('profile.showRecentNotesDescription')}
|
||||
checked={showRecentNotes}
|
||||
onChange={handleRecentNotesChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* AI Settings Link */}
|
||||
<div className="p-6 border rounded-lg bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950 dark:to-pink-950">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-4xl">✨</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-1">{t('aiSettings.title')}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('aiSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.href = '/settings/ai'}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
{t('nav.configureAI')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
memento-note/app/(main)/settings/profile/page.tsx
Normal file
52
memento-note/app/(main)/settings/profile/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ProfileForm } from './profile-form'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { ProfilePageHeader } from '@/components/profile-page-header'
|
||||
import { AISettingsLinkCard } from './ai-settings-link-card'
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { name: true, email: true, role: true }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get user AI settings
|
||||
let userAISettings = { preferredLanguage: 'auto', showRecentNotes: false }
|
||||
try {
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
if (aiSettings) {
|
||||
userAISettings = {
|
||||
preferredLanguage: aiSettings.preferredLanguage || 'auto',
|
||||
showRecentNotes: aiSettings.showRecentNotes ?? false
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI settings:', error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ProfilePageHeader />
|
||||
<ProfileForm user={user} userAISettings={userAISettings} />
|
||||
|
||||
{/* AI Settings Link */}
|
||||
<AISettingsLinkCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
memento-note/app/(main)/settings/profile/profile-form.tsx
Normal file
179
memento-note/app/(main)/settings/profile/profile-form.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
|
||||
|
||||
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
|
||||
const router = useRouter()
|
||||
|
||||
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
|
||||
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
|
||||
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
|
||||
const [isUpdatingRecentNotes, setIsUpdatingRecentNotes] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const FONT_SIZES = [
|
||||
{ value: 'small', label: t('profile.fontSizeSmall'), size: '14px' },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium'), size: '16px' },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge'), size: '18px' },
|
||||
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge'), size: '20px' },
|
||||
]
|
||||
|
||||
const handleFontSizeChange = async (size: string) => {
|
||||
setIsUpdatingFontSize(true)
|
||||
try {
|
||||
const result = await updateFontSize(size)
|
||||
if (result?.error) {
|
||||
toast.error(t('profile.fontSizeUpdateFailed'))
|
||||
} else {
|
||||
setFontSize(size)
|
||||
// Apply font size immediately
|
||||
applyFontSize(size)
|
||||
toast.success(t('profile.fontSizeUpdateSuccess'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('profile.fontSizeUpdateFailed'))
|
||||
} finally {
|
||||
setIsUpdatingFontSize(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyFontSize = (size: string) => {
|
||||
// Base font size in pixels (16px is standard)
|
||||
const fontSizeMap = {
|
||||
'small': '14px', // ~87% of 16px
|
||||
'medium': '16px', // 100% (standard)
|
||||
'large': '18px', // ~112% of 16px
|
||||
'extra-large': '20px' // 125% of 16px
|
||||
}
|
||||
const fontSizeFactorMap = {
|
||||
'small': 0.95,
|
||||
'medium': 1.0,
|
||||
'large': 1.1,
|
||||
'extra-large': 1.25
|
||||
}
|
||||
const fontSizeValue = fontSizeMap[size as keyof typeof fontSizeMap] || '16px'
|
||||
const fontSizeFactor = fontSizeFactorMap[size as keyof typeof fontSizeFactorMap] || 1.0
|
||||
|
||||
document.documentElement.style.setProperty('--user-font-size', fontSizeValue)
|
||||
document.documentElement.style.setProperty('--user-font-size-factor', fontSizeFactor.toString())
|
||||
localStorage.setItem('user-font-size', size)
|
||||
}
|
||||
|
||||
// Apply saved font size on mount
|
||||
useEffect(() => {
|
||||
const savedFontSize = localStorage.getItem('user-font-size') || userAISettings?.fontSize || 'medium'
|
||||
applyFontSize(savedFontSize as string)
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
const handleShowRecentNotesChange = async (enabled: boolean) => {
|
||||
setIsUpdatingRecentNotes(true)
|
||||
const previousValue = showRecentNotes
|
||||
|
||||
try {
|
||||
const result = await updateShowRecentNotes(enabled)
|
||||
if (result?.error) {
|
||||
toast.error(result.error)
|
||||
} else {
|
||||
setShowRecentNotes(enabled)
|
||||
toast.success(t('profile.recentNotesUpdateSuccess') || 'Paramètre mis à jour')
|
||||
// Force full page reload to ensure settings are reloaded
|
||||
window.location.href = '/settings/profile'
|
||||
}
|
||||
} catch (error: any) {
|
||||
setShowRecentNotes(previousValue)
|
||||
toast.error(error?.message || 'Erreur')
|
||||
} finally {
|
||||
setIsUpdatingRecentNotes(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('profile.title')}</CardTitle>
|
||||
<CardDescription>{t('profile.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={async (formData) => {
|
||||
const result = await updateProfile({ name: formData.get('name') as string })
|
||||
if (result?.error) {
|
||||
toast.error(t('profile.updateFailed'))
|
||||
} else {
|
||||
toast.success(t('profile.updateSuccess'))
|
||||
}
|
||||
}}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.displayName')}</label>
|
||||
<Input id="name" name="name" defaultValue={user.name} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.email')}</label>
|
||||
<Input id="email" value={user.email} disabled className="bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit">{t('general.save')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('profile.changePassword')}</CardTitle>
|
||||
<CardDescription>{t('profile.changePasswordDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={async (formData) => {
|
||||
const result = await changePassword(formData)
|
||||
if (result?.error) {
|
||||
const msg = '_form' in result.error
|
||||
? result.error._form[0]
|
||||
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
|
||||
toast.error(msg)
|
||||
} else {
|
||||
toast.success(t('profile.passwordChangeSuccess'))
|
||||
// Reset form manually or redirect
|
||||
const form = document.querySelector('form#password-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
}
|
||||
}} id="password-form">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="currentPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.currentPassword')}</label>
|
||||
<Input id="currentPassword" name="currentPassword" type="password" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="newPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.newPassword')}</label>
|
||||
<Input id="newPassword" name="newPassword" type="password" required minLength={6} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="confirmPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.confirmPassword')}</label>
|
||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit">{t('profile.updatePassword')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
memento-note/app/(main)/support/page.tsx
Normal file
156
memento-note/app/(main)/support/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
export default function SupportPage() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 max-w-4xl">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
{t('support.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t('support.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-10">
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">☕</span>
|
||||
{t('support.buyMeACoffee')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
{t('support.donationDescription')}
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
{t('support.donateOnKofi')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('support.kofiDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💚</span>
|
||||
{t('support.sponsorOnGithub')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
{t('support.sponsorDescription')}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
{t('support.sponsorOnGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('support.githubDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mb-10">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>☕ Keeps me fueled with coffee</li>
|
||||
<li>🐛 Covers hosting and server costs</li>
|
||||
<li>✨ Funds development of new features</li>
|
||||
<li>📚 Improves documentation</li>
|
||||
<li>🌍 Keeps Memento 100% open-source</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>🥉 $5/month - Bronze: Name in supporters list</li>
|
||||
<li>🥈 $15/month - Silver: Priority feature requests</li>
|
||||
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
|
||||
<li>💎 $100/month - Platinum: Custom features, consulting</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>💡 {t('support.transparency')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm mb-4">
|
||||
{t('support.transparencyDescription')}
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.hostingServers')}</span>
|
||||
<span className="font-mono">~$20/month</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.domainSSL')}</span>
|
||||
<span className="font-mono">~$15/year</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.aiApiCosts')}</span>
|
||||
<span className="font-mono">~$30/month</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-2">
|
||||
<span className="font-semibold">{t('support.totalExpenses')}</span>
|
||||
<span className="font-mono font-semibold">~$50/month</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Any amount beyond these costs goes directly into improving Memento
|
||||
and funding new features. Thank you for your support! 💚
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
⭐ {t('support.starGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
|
||||
🐛 {t('support.reportBug')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
📝 {t('support.contributeCode')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
🐦 {t('support.shareTwitter')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
memento-note/app/(main)/trash/page.tsx
Normal file
28
memento-note/app/(main)/trash/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function TrashPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashContent />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function TrashContent() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
|
||||
<Trash2 className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-2">{t('trash.empty')}</h2>
|
||||
<p className="max-w-md text-sm opacity-80">
|
||||
{t('trash.restore')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
memento-note/app/actions/admin-settings.ts
Normal file
66
memento-note/app/actions/admin-settings.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
|
||||
async function checkAdmin() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||
throw new Error('Unauthorized: Admin access required')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function testSMTP() {
|
||||
const session = await checkAdmin()
|
||||
const email = session.user?.email
|
||||
|
||||
if (!email) throw new Error("No admin email found")
|
||||
|
||||
const result = await sendEmail({
|
||||
to: email,
|
||||
subject: "Memento SMTP Test",
|
||||
html: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getSystemConfig() {
|
||||
await checkAdmin()
|
||||
const configs = await prisma.systemConfig.findMany()
|
||||
return configs.reduce((acc, conf) => {
|
||||
acc[conf.key] = conf.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
}
|
||||
|
||||
export async function updateSystemConfig(data: Record<string, string>) {
|
||||
await checkAdmin()
|
||||
|
||||
try {
|
||||
// Filter out empty values but keep 'false' as valid
|
||||
const filteredData = Object.fromEntries(
|
||||
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
|
||||
)
|
||||
|
||||
|
||||
|
||||
const operations = Object.entries(filteredData).map(([key, value]) =>
|
||||
prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value }
|
||||
})
|
||||
)
|
||||
|
||||
await prisma.$transaction(operations)
|
||||
revalidatePath('/admin/settings')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to update settings:', error)
|
||||
return { error: 'Failed to update settings' }
|
||||
}
|
||||
}
|
||||
120
memento-note/app/actions/admin.ts
Normal file
120
memento-note/app/actions/admin.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Schema pour la création d'utilisateur
|
||||
const CreateUserSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
role: z.enum(["USER", "ADMIN"]).default("USER"),
|
||||
})
|
||||
|
||||
async function checkAdmin() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||
throw new Error('Unauthorized: Admin access required')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function getUsers() {
|
||||
await checkAdmin()
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
return users
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error)
|
||||
throw new Error('Failed to fetch users')
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(formData: FormData) {
|
||||
await checkAdmin()
|
||||
|
||||
const rawData = {
|
||||
name: formData.get('name'),
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
role: formData.get('role'),
|
||||
}
|
||||
|
||||
const validatedFields = CreateUserSchema.safeParse(rawData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
error: validatedFields.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { name, email, password, role } = validatedFields.data
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
role,
|
||||
},
|
||||
})
|
||||
revalidatePath('/admin')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
return { error: { email: ['Email already exists'] } }
|
||||
}
|
||||
return { error: { _form: ['Failed to create user'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
const session = await checkAdmin()
|
||||
|
||||
if (session.user?.id === userId) {
|
||||
throw new Error("You cannot delete your own account")
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
})
|
||||
revalidatePath('/admin')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new Error('Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserRole(userId: string, newRole: string) {
|
||||
const session = await checkAdmin()
|
||||
|
||||
if (session.user?.id === userId) {
|
||||
throw new Error("You cannot change your own role")
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role: newRole },
|
||||
})
|
||||
revalidatePath('/admin')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update role')
|
||||
}
|
||||
}
|
||||
382
memento-note/app/actions/ai-actions.ts
Normal file
382
memento-note/app/actions/ai-actions.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
'use server'
|
||||
|
||||
/**
|
||||
* AI Server Actions Stub File
|
||||
*
|
||||
* This file provides a centralized location for all AI-related server action interfaces
|
||||
* and serves as documentation for the AI server action architecture.
|
||||
*
|
||||
* IMPLEMENTATION STATUS:
|
||||
* - Title Suggestions: ✅ Implemented (see app/actions/title-suggestions.ts)
|
||||
* - Semantic Search: ✅ Implemented (see app/actions/semantic-search.ts)
|
||||
* - Paragraph Reformulation: ✅ Implemented (see app/actions/paragraph-refactor.ts)
|
||||
* - Memory Echo: ⏳ STUB - To be implemented in Epic 5 (Story 5-1)
|
||||
* - Language Detection: ✅ Implemented (see app/actions/detect-language.ts)
|
||||
* - AI Settings: ✅ Implemented (see app/actions/ai-settings.ts)
|
||||
*
|
||||
* NOTE: This file defines TypeScript interfaces and placeholder functions.
|
||||
* Actual implementations are in separate action files (see references above).
|
||||
*/
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// ============================================================================
|
||||
// TYPESCRIPT INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Title Suggestions Interfaces
|
||||
* @see app/actions/title-suggestions.ts for implementation
|
||||
*/
|
||||
export interface GenerateTitlesRequest {
|
||||
noteId: string
|
||||
}
|
||||
|
||||
export interface GenerateTitlesResponse {
|
||||
suggestions: Array<{
|
||||
title: string
|
||||
confidence: number
|
||||
reasoning?: string
|
||||
}>
|
||||
noteId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Search Interfaces
|
||||
* @see app/actions/semantic-search.ts for implementation
|
||||
*/
|
||||
export interface SearchResult {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
similarity: number
|
||||
matchType: 'exact' | 'related'
|
||||
}
|
||||
|
||||
export interface SemanticSearchRequest {
|
||||
query: string
|
||||
options?: {
|
||||
limit?: number
|
||||
threshold?: number
|
||||
notebookId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
results: SearchResult[]
|
||||
query: string
|
||||
totalResults: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph Reformulation Interfaces
|
||||
* @see app/actions/paragraph-refactor.ts for implementation
|
||||
*/
|
||||
export type RefactorMode = 'clarify' | 'shorten' | 'improve'
|
||||
|
||||
export interface RefactorParagraphRequest {
|
||||
noteId: string
|
||||
selectedText: string
|
||||
option: RefactorMode
|
||||
}
|
||||
|
||||
export interface RefactorParagraphResponse {
|
||||
originalText: string
|
||||
refactoredText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory Echo Interfaces
|
||||
* STUB - To be implemented in Epic 5 (Story 5-1)
|
||||
*
|
||||
* This feature will analyze all user notes with embeddings to find
|
||||
* connections with cosine similarity > 0.75 and provide proactive insights.
|
||||
*/
|
||||
export interface GenerateMemoryEchoRequest {
|
||||
// No params - uses current user session
|
||||
}
|
||||
|
||||
export interface MemoryEchoInsight {
|
||||
note1Id: string
|
||||
note2Id: string
|
||||
similarityScore: number
|
||||
}
|
||||
|
||||
export interface GenerateMemoryEchoResponse {
|
||||
success: boolean
|
||||
insight: MemoryEchoInsight | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Language Detection Interfaces
|
||||
* @see app/actions/detect-language.ts for implementation
|
||||
*/
|
||||
export interface DetectLanguageRequest {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface DetectLanguageResponse {
|
||||
language: string
|
||||
confidence: number
|
||||
method: 'tinyld' | 'ai'
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Settings Interfaces
|
||||
* @see app/actions/ai-settings.ts for implementation
|
||||
*/
|
||||
export interface AISettingsConfig {
|
||||
titleSuggestions?: boolean
|
||||
semanticSearch?: boolean
|
||||
paragraphRefactor?: boolean
|
||||
memoryEcho?: boolean
|
||||
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
|
||||
aiProvider?: 'auto' | 'openai' | 'ollama'
|
||||
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
demoMode?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateAISettingsRequest {
|
||||
settings: Partial<AISettingsConfig>
|
||||
}
|
||||
|
||||
export interface UpdateAISettingsResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLACEHOLDER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate Title Suggestions
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/title-suggestions.ts
|
||||
*
|
||||
* This function generates 3 AI-powered title suggestions for a note when it
|
||||
* reaches 50+ words without a title.
|
||||
*
|
||||
* @see generateTitleSuggestions in app/actions/title-suggestions.ts
|
||||
*/
|
||||
export async function generateTitles(
|
||||
request: GenerateTitlesRequest
|
||||
): Promise<GenerateTitlesResponse> {
|
||||
// TODO: Import and use implementation from title-suggestions.ts
|
||||
// import { generateTitleSuggestions } from './title-suggestions'
|
||||
// return generateTitleSuggestions(request.noteId)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/title-suggestions.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Search
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/semantic-search.ts
|
||||
*
|
||||
* This function performs hybrid semantic + keyword search across user notes.
|
||||
*
|
||||
* @see semanticSearch in app/actions/semantic-search.ts
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
request: SemanticSearchRequest
|
||||
): Promise<SemanticSearchResponse> {
|
||||
// TODO: Import and use implementation from semantic-search.ts
|
||||
// import { semanticSearch } from './semantic-search'
|
||||
// return semanticSearch(request.query, request.options)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/semantic-search.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Refactor Paragraph
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/paragraph-refactor.ts
|
||||
*
|
||||
* This function refactors a paragraph using AI with specific mode (clarify/shorten/improve).
|
||||
*
|
||||
* @see refactorParagraph in app/actions/paragraph-refactor.ts
|
||||
*/
|
||||
export async function refactorParagraph(
|
||||
request: RefactorParagraphRequest
|
||||
): Promise<RefactorParagraphResponse> {
|
||||
// TODO: Import and use implementation from paragraph-refactor.ts
|
||||
// import { refactorParagraph } from './paragraph-refactor'
|
||||
// return refactorParagraph(request.selectedText, request.option)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/paragraph-refactor.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Memory Echo Insights
|
||||
*
|
||||
* STUB: To be implemented in Epic 5 (Story 5-1)
|
||||
*
|
||||
* This will analyze all user notes with embeddings to find
|
||||
* connections with cosine similarity > 0.75.
|
||||
*
|
||||
* Implementation Plan:
|
||||
* - Fetch all user notes with embeddings
|
||||
* - Calculate pairwise cosine similarities
|
||||
* - Find top connection with similarity > 0.75
|
||||
* - Store in MemoryEchoInsight table
|
||||
* - Return insight or null if none found
|
||||
*
|
||||
* @see Epic 5 Story 5-1 in planning/epics.md
|
||||
*/
|
||||
export async function generateMemoryEcho(): Promise<GenerateMemoryEchoResponse> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// TODO: Implement Memory Echo background processing
|
||||
// - Fetch all user notes with embeddings from prisma.note
|
||||
// - Calculate pairwise cosine similarities using embedding vectors
|
||||
// - Filter for similarity > 0.75
|
||||
// - Select top insight
|
||||
// - Store in prisma.memoryEchoInsight table (if it exists)
|
||||
// - Return { success: true, insight: {...} }
|
||||
|
||||
throw new Error('Not implemented: See Epic 5 Story 5-1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Language
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/detect-language.ts
|
||||
*
|
||||
* This function detects the language of user content.
|
||||
*
|
||||
* @see getInitialLanguage in app/actions/detect-language.ts
|
||||
*/
|
||||
export async function detectLanguage(
|
||||
request: DetectLanguageRequest
|
||||
): Promise<DetectLanguageResponse> {
|
||||
// TODO: Import and use implementation from detect-language.ts
|
||||
// import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
// const language = await detectUserLanguage()
|
||||
// return { language, confidence: 0.95, method: 'tinyld' }
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/detect-language.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AI Settings
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
|
||||
*
|
||||
* This function updates user AI preferences.
|
||||
*
|
||||
* @see updateAISettings in app/actions/ai-settings.ts
|
||||
*/
|
||||
export async function updateAISettings(
|
||||
request: UpdateAISettingsRequest
|
||||
): Promise<UpdateAISettingsResponse> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// TODO: Import and use implementation from ai-settings.ts
|
||||
// import { updateAISettings } from './ai-settings'
|
||||
// return updateAISettings(request.settings)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI Settings
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
|
||||
*
|
||||
* This function retrieves user AI preferences.
|
||||
*
|
||||
* @see getAISettings in app/actions/ai-settings.ts
|
||||
*/
|
||||
export async function getAISettings(): Promise<AISettingsConfig> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// TODO: Import and use implementation from ai-settings.ts
|
||||
// import { getAISettings } from './ai-settings'
|
||||
// return getAISettings()
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if a specific AI feature is enabled for the user
|
||||
*
|
||||
* UTILITY: Helper function to check feature flags
|
||||
*
|
||||
* @param feature - The AI feature to check
|
||||
* @returns Promise<boolean> - Whether the feature is enabled
|
||||
*/
|
||||
export async function isAIFeatureEnabled(
|
||||
feature: keyof AISettingsConfig
|
||||
): Promise<boolean> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
if (!settings) {
|
||||
// Default to enabled for new users
|
||||
return true
|
||||
}
|
||||
|
||||
switch (feature) {
|
||||
case 'titleSuggestions':
|
||||
return settings.titleSuggestions ?? true
|
||||
case 'semanticSearch':
|
||||
return settings.semanticSearch ?? true
|
||||
case 'paragraphRefactor':
|
||||
return settings.paragraphRefactor ?? true
|
||||
case 'memoryEcho':
|
||||
return settings.memoryEcho ?? true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking AI feature enabled:', error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred AI provider
|
||||
*
|
||||
* UTILITY: Helper function to get provider preference
|
||||
*
|
||||
* @returns Promise<'auto' | 'openai' | 'ollama'> - The AI provider
|
||||
*/
|
||||
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
return (settings?.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama'
|
||||
} catch (error) {
|
||||
console.error('Error getting user AI preference:', error)
|
||||
return 'auto'
|
||||
}
|
||||
}
|
||||
190
memento-note/app/actions/ai-settings.ts
Normal file
190
memento-note/app/actions/ai-settings.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
export type UserAISettingsData = {
|
||||
titleSuggestions?: boolean
|
||||
semanticSearch?: boolean
|
||||
paragraphRefactor?: boolean
|
||||
memoryEcho?: boolean
|
||||
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
|
||||
aiProvider?: 'auto' | 'openai' | 'ollama'
|
||||
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
demoMode?: boolean
|
||||
showRecentNotes?: boolean
|
||||
emailNotifications?: boolean
|
||||
desktopNotifications?: boolean
|
||||
anonymousAnalytics?: boolean
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
fontSize?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AI settings for the current user
|
||||
*/
|
||||
export async function updateAISettings(settings: UserAISettingsData) {
|
||||
|
||||
const session = await auth()
|
||||
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.error('[updateAISettings] Unauthorized: No session or user ID')
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Upsert settings (create if not exists, update if exists)
|
||||
const result = await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
...settings
|
||||
},
|
||||
update: settings
|
||||
})
|
||||
|
||||
|
||||
revalidatePath('/settings/ai', 'page')
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('ai-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error updating AI settings:', error)
|
||||
throw new Error('Failed to update AI settings')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI settings for the current user (Cached)
|
||||
*/
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
// Internal cached function to fetch settings from DB
|
||||
const getCachedAISettings = unstable_cache(
|
||||
async (userId: string) => {
|
||||
try {
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId }
|
||||
})
|
||||
|
||||
if (!settings) {
|
||||
return {
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily' as const,
|
||||
aiProvider: 'auto' as const,
|
||||
preferredLanguage: 'auto' as const,
|
||||
demoMode: false,
|
||||
showRecentNotes: false,
|
||||
emailNotifications: false,
|
||||
desktopNotifications: false,
|
||||
anonymousAnalytics: false,
|
||||
theme: 'light' as const,
|
||||
fontSize: 'medium' as const
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
titleSuggestions: settings.titleSuggestions,
|
||||
semanticSearch: settings.semanticSearch,
|
||||
paragraphRefactor: settings.paragraphRefactor,
|
||||
memoryEcho: settings.memoryEcho,
|
||||
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
|
||||
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
|
||||
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
|
||||
demoMode: settings.demoMode,
|
||||
showRecentNotes: settings.showRecentNotes,
|
||||
emailNotifications: settings.emailNotifications,
|
||||
desktopNotifications: settings.desktopNotifications,
|
||||
anonymousAnalytics: settings.anonymousAnalytics,
|
||||
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
|
||||
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting AI settings:', error)
|
||||
// Return defaults on error
|
||||
return {
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily' as const,
|
||||
aiProvider: 'auto' as const,
|
||||
preferredLanguage: 'auto' as const,
|
||||
demoMode: false,
|
||||
showRecentNotes: false,
|
||||
emailNotifications: false,
|
||||
desktopNotifications: false,
|
||||
anonymousAnalytics: false,
|
||||
theme: 'light' as const,
|
||||
fontSize: 'medium' as const
|
||||
}
|
||||
}
|
||||
},
|
||||
['user-ai-settings'],
|
||||
{ tags: ['ai-settings'] }
|
||||
)
|
||||
|
||||
export async function getAISettings(userId?: string) {
|
||||
let id = userId
|
||||
|
||||
if (!id) {
|
||||
const session = await auth()
|
||||
id = session?.user?.id
|
||||
}
|
||||
|
||||
// Return defaults for non-logged-in users
|
||||
if (!id) {
|
||||
return {
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily' as const,
|
||||
aiProvider: 'auto' as const,
|
||||
preferredLanguage: 'auto' as const,
|
||||
demoMode: false,
|
||||
showRecentNotes: false,
|
||||
emailNotifications: false,
|
||||
desktopNotifications: false,
|
||||
anonymousAnalytics: false,
|
||||
theme: 'light' as const,
|
||||
fontSize: 'medium' as const
|
||||
}
|
||||
}
|
||||
|
||||
return getCachedAISettings(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred AI provider
|
||||
*/
|
||||
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
|
||||
const settings = await getAISettings()
|
||||
return settings.aiProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific AI feature is enabled for the user
|
||||
*/
|
||||
export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Promise<boolean> {
|
||||
const settings = await getAISettings()
|
||||
|
||||
switch (feature) {
|
||||
case 'titleSuggestions':
|
||||
return settings.titleSuggestions
|
||||
case 'semanticSearch':
|
||||
return settings.semanticSearch
|
||||
case 'paragraphRefactor':
|
||||
return settings.paragraphRefactor
|
||||
case 'memoryEcho':
|
||||
return settings.memoryEcho
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
86
memento-note/app/actions/auth-reset.ts
Normal file
86
memento-note/app/actions/auth-reset.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getEmailTemplate } from '@/lib/email-template'
|
||||
|
||||
// Helper simple pour générer un token sans dépendance externe lourde
|
||||
function generateToken() {
|
||||
const array = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string) {
|
||||
if (!email) return { error: "Email is required" };
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!user) {
|
||||
// Pour des raisons de sécurité, on ne dit pas si l'email existe ou pas
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiry = new Date(Date.now() + 3600000); // 1 hour
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: expiry
|
||||
}
|
||||
});
|
||||
|
||||
const resetLink = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
||||
|
||||
const html = getEmailTemplate(
|
||||
"Reset your Password",
|
||||
"<p>You requested a password reset for your Memento account.</p><p>Click the button below to set a new password. This link is valid for 1 hour.</p>",
|
||||
resetLink,
|
||||
"Reset Password"
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your Memento password",
|
||||
html
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error);
|
||||
return { error: "Failed to send reset email" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, newPassword: string) {
|
||||
if (!token || !newPassword) return { error: "Missing token or password" };
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { resetToken: token }
|
||||
});
|
||||
|
||||
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
|
||||
return { error: "Invalid or expired token" };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
return { error: "Failed to reset password" };
|
||||
}
|
||||
}
|
||||
29
memento-note/app/actions/auth.ts
Normal file
29
memento-note/app/actions/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use server';
|
||||
|
||||
import { signIn } from '@/auth';
|
||||
import { AuthError } from 'next-auth';
|
||||
|
||||
export async function authenticate(
|
||||
prevState: string | undefined,
|
||||
formData: FormData,
|
||||
) {
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
redirectTo: '/',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
console.error('AuthError details:', error.type, error.message);
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return 'Invalid credentials.';
|
||||
default:
|
||||
return `Auth error: ${error.type}`;
|
||||
}
|
||||
}
|
||||
// IMPORTANT: Next.js redirects throw a special error that must be rethrown
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
12
memento-note/app/actions/detect-language.ts
Normal file
12
memento-note/app/actions/detect-language.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use server'
|
||||
|
||||
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
import { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||
|
||||
/**
|
||||
* Server action to detect user's preferred language
|
||||
* Called on app load to set initial language
|
||||
*/
|
||||
export async function getInitialLanguage(): Promise<SupportedLanguage> {
|
||||
return await detectUserLanguage()
|
||||
}
|
||||
1357
memento-note/app/actions/notes.ts
Normal file
1357
memento-note/app/actions/notes.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
memento-note/app/actions/ollama.ts
Normal file
57
memento-note/app/actions/ollama.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
'use server'
|
||||
|
||||
interface OllamaModel {
|
||||
name: string
|
||||
modified_at: string
|
||||
size: number
|
||||
digest: string
|
||||
details: {
|
||||
format: string
|
||||
family: string
|
||||
families: string[]
|
||||
parameter_size: string
|
||||
quantization_level: string
|
||||
}
|
||||
}
|
||||
|
||||
interface OllamaTagsResponse {
|
||||
models: OllamaModel[]
|
||||
}
|
||||
|
||||
export async function getOllamaModels(baseUrl: string): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
// Ensure URL doesn't end with slash
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${cleanUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Set a reasonable timeout
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OllamaTagsResponse
|
||||
|
||||
// Extract model names
|
||||
const modelNames = data.models?.map(m => m.name) || []
|
||||
|
||||
return { success: true, models: modelNames }
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch Ollama models:', error)
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
error: error.message || 'Failed to connect to Ollama'
|
||||
}
|
||||
}
|
||||
}
|
||||
49
memento-note/app/actions/paragraph-refactor.ts
Normal file
49
memento-note/app/actions/paragraph-refactor.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
'use server'
|
||||
|
||||
import { paragraphRefactorService, RefactorMode, RefactorResult } from '@/lib/ai/services/paragraph-refactor.service'
|
||||
|
||||
export interface RefactorResponse {
|
||||
result: RefactorResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Refactor a paragraph with a specific mode
|
||||
*/
|
||||
export async function refactorParagraph(
|
||||
content: string,
|
||||
mode: RefactorMode
|
||||
): Promise<RefactorResponse> {
|
||||
try {
|
||||
const result = await paragraphRefactorService.refactor(content, mode)
|
||||
|
||||
return { result }
|
||||
} catch (error) {
|
||||
console.error('Error refactoring paragraph:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all 3 refactor options at once
|
||||
*/
|
||||
export async function refactorParagraphAllModes(
|
||||
content: string
|
||||
): Promise<{ results: RefactorResult[] }> {
|
||||
try {
|
||||
const results = await paragraphRefactorService.refactorAllModes(content)
|
||||
|
||||
return { results }
|
||||
} catch (error) {
|
||||
console.error('Error refactoring paragraph in all modes:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate word count before refactoring
|
||||
*/
|
||||
export async function validateRefactorWordCount(
|
||||
content: string
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
return paragraphRefactorService.validateWordCount(content)
|
||||
}
|
||||
232
memento-note/app/actions/profile-broken.ts
Normal file
232
memento-note/app/actions/profile-broken.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
export async function updateProfile(data: { name: string }) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const validated = ProfileSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: validated.data.name },
|
||||
})
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to update profile'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(formData: FormData) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const rawData = {
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
}
|
||||
|
||||
const validated = PasswordSchema.safeParse(rawData)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validated.data
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
return { error: { _form: ['User not found'] } }
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
|
||||
if (!passwordsMatch) {
|
||||
return { error: { currentPassword: ['Incorrect current password'] } }
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to change password'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { theme },
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: 'Failed to update theme' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLanguage(language: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Update or create UserAISettings with the preferred language
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
preferredLanguage: language,
|
||||
},
|
||||
update: {
|
||||
preferredLanguage: language,
|
||||
},
|
||||
})
|
||||
|
||||
// Note: The language will be applied on next page load
|
||||
// The client component should handle updating localStorage and reloading
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, language }
|
||||
} catch (error) {
|
||||
console.error('Failed to update language:', error)
|
||||
return { error: 'Failed to update language' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFontSize(fontSize: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Check if UserAISettings exists
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// Update existing - only update fontSize field
|
||||
result = await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { fontSize: fontSize }
|
||||
})
|
||||
} else {
|
||||
// Create new with all required fields
|
||||
result = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
fontSize: fontSize,
|
||||
// Set default values for required fields
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
showRecentNotes: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, fontSize }
|
||||
} catch (error) {
|
||||
console.error('[updateFontSize] Failed to update font size:', error)
|
||||
return { error: 'Failed to update font size' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShowRecentNotes(showRecentNotes: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Use EXACT same pattern as updateFontSize which works
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
|
||||
try {
|
||||
await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { showRecentNotes: showRecentNotes } as any
|
||||
})
|
||||
} catch (prismaError: any) {
|
||||
// If Prisma client doesn't know about showRecentNotes, use raw SQL
|
||||
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
|
||||
const value = showRecentNotes ? 1 : 0
|
||||
await prisma.$executeRaw`
|
||||
UPDATE UserAISettings
|
||||
SET showRecentNotes = ${value}
|
||||
WHERE userId = ${session.user.id}
|
||||
`
|
||||
} else {
|
||||
throw prismaError
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new - same as updateFontSize
|
||||
await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
fontSize: 'medium',
|
||||
showRecentNotes: showRecentNotes
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, showRecentNotes }
|
||||
} catch (error) {
|
||||
console.error('[updateShowRecentNotes] Failed:', error)
|
||||
return { error: 'Failed to update show recent notes setting' }
|
||||
}
|
||||
}
|
||||
232
memento-note/app/actions/profile-old.ts
Normal file
232
memento-note/app/actions/profile-old.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
export async function updateProfile(data: { name: string }) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const validated = ProfileSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: validated.data.name },
|
||||
})
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to update profile'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(formData: FormData) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const rawData = {
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
}
|
||||
|
||||
const validated = PasswordSchema.safeParse(rawData)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validated.data
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
return { error: { _form: ['User not found'] } }
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
|
||||
if (!passwordsMatch) {
|
||||
return { error: { currentPassword: ['Incorrect current password'] } }
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to change password'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { theme },
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: 'Failed to update theme' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLanguage(language: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Update or create UserAISettings with the preferred language
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
preferredLanguage: language,
|
||||
},
|
||||
update: {
|
||||
preferredLanguage: language,
|
||||
},
|
||||
})
|
||||
|
||||
// Note: The language will be applied on next page load
|
||||
// The client component should handle updating localStorage and reloading
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, language }
|
||||
} catch (error) {
|
||||
console.error('Failed to update language:', error)
|
||||
return { error: 'Failed to update language' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFontSize(fontSize: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Check if UserAISettings exists
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// Update existing - only update fontSize field
|
||||
result = await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { fontSize: fontSize }
|
||||
})
|
||||
} else {
|
||||
// Create new with all required fields
|
||||
result = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
fontSize: fontSize,
|
||||
// Set default values for required fields
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
showRecentNotes: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, fontSize }
|
||||
} catch (error) {
|
||||
console.error('[updateFontSize] Failed to update font size:', error)
|
||||
return { error: 'Failed to update font size' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShowRecentNotes(showRecentNotes: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Use EXACT same pattern as updateFontSize which works
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
|
||||
try {
|
||||
await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { showRecentNotes: showRecentNotes } as any
|
||||
})
|
||||
} catch (prismaError: any) {
|
||||
// If Prisma client doesn't know about showRecentNotes, use raw SQL
|
||||
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
|
||||
const value = showRecentNotes ? 1 : 0
|
||||
await prisma.$executeRaw`
|
||||
UPDATE UserAISettings
|
||||
SET showRecentNotes = ${value}
|
||||
WHERE userId = ${session.user.id}
|
||||
`
|
||||
} else {
|
||||
throw prismaError
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new - same as updateFontSize
|
||||
await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
fontSize: 'medium',
|
||||
showRecentNotes: showRecentNotes
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, showRecentNotes }
|
||||
} catch (error) {
|
||||
console.error('[updateShowRecentNotes] Failed:', error)
|
||||
return { error: 'Failed to update show recent notes setting' }
|
||||
}
|
||||
}
|
||||
201
memento-note/app/actions/profile.ts
Normal file
201
memento-note/app/actions/profile.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
export async function updateProfile(data: { name: string }) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const validated = ProfileSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: validated.data.name },
|
||||
})
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to update profile'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(formData: FormData) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const rawData = {
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
}
|
||||
|
||||
const validated = PasswordSchema.safeParse(rawData)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validated.data
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
return { error: { _form: ['User not found'] } }
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
|
||||
if (!passwordsMatch) {
|
||||
return { error: { currentPassword: ['Incorrect current password'] } }
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to change password'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { theme },
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: 'Failed to update theme' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLanguage(language: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Update or create UserAISettings with the preferred language
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
preferredLanguage: language,
|
||||
},
|
||||
update: {
|
||||
preferredLanguage: language,
|
||||
},
|
||||
})
|
||||
|
||||
// Note: The language will be applied on next page load
|
||||
// The client component should handle updating localStorage and reloading
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, language }
|
||||
} catch (error) {
|
||||
console.error('Failed to update language:', error)
|
||||
return { error: 'Failed to update language' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFontSize(fontSize: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Check if UserAISettings exists
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// Update existing - only update fontSize field
|
||||
result = await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { fontSize: fontSize }
|
||||
})
|
||||
} else {
|
||||
// Create new with all required fields
|
||||
result = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
fontSize: fontSize,
|
||||
// Set default values for required fields
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
showRecentNotes: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, fontSize }
|
||||
} catch (error) {
|
||||
console.error('[updateFontSize] Failed to update font size:', error)
|
||||
return { error: 'Failed to update font size' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShowRecentNotes(showRecentNotes: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
showRecentNotes,
|
||||
// Defaults will be used for other fields
|
||||
},
|
||||
update: {
|
||||
showRecentNotes,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, showRecentNotes }
|
||||
} catch (error) {
|
||||
console.error('[updateShowRecentNotes] Failed:', error)
|
||||
return { error: 'Failed to update show recent notes setting' }
|
||||
}
|
||||
}
|
||||
63
memento-note/app/actions/register.ts
Normal file
63
memento-note/app/actions/register.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
'use server';
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
name: z.string().min(2),
|
||||
});
|
||||
|
||||
export async function register(prevState: string | undefined, formData: FormData) {
|
||||
// Check if registration is allowed
|
||||
const config = await getSystemConfig();
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
if (!allowRegister) {
|
||||
return 'Registration is currently disabled by the administrator.';
|
||||
}
|
||||
|
||||
const validatedFields = RegisterSchema.safeParse({
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
name: formData.get('name'),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return 'Invalid fields. Failed to register.';
|
||||
}
|
||||
|
||||
const { email, password, name } = validatedFields.data;
|
||||
|
||||
try {
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (existingUser) {
|
||||
return 'User already exists.';
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
// Attempt to sign in immediately after registration
|
||||
// We cannot import signIn here directly if it causes circular deps or issues,
|
||||
// but usually it works. If not, redirecting to login is fine.
|
||||
// Let's stick to redirecting to login but with a clear success message?
|
||||
// Or better: lowercase the email to fix the potential bug.
|
||||
} catch (error) {
|
||||
console.error('Registration Error:', error);
|
||||
return 'Database Error: Failed to create user.';
|
||||
}
|
||||
|
||||
redirect('/login');
|
||||
}
|
||||
59
memento-note/app/actions/scrape.ts
Normal file
59
memento-note/app/actions/scrape.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
'use server'
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export interface LinkMetadata {
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
||||
try {
|
||||
// Add protocol if missing
|
||||
let targetUrl = url;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
targetUrl = 'https://' + url;
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5'
|
||||
},
|
||||
next: { revalidate: 3600 } // Cache for 1 hour
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const getMeta = (prop: string) =>
|
||||
$(`meta[property="${prop}"]`).attr('content') ||
|
||||
$(`meta[name="${prop}"]`).attr('content');
|
||||
|
||||
// Robust extraction with fallbacks
|
||||
const title = getMeta('og:title') || $('title').text() || getMeta('twitter:title') || url;
|
||||
const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || '';
|
||||
const imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href');
|
||||
const siteName = getMeta('og:site_name') || '';
|
||||
|
||||
return {
|
||||
url: targetUrl,
|
||||
title: title.substring(0, 100),
|
||||
description: description.substring(0, 200),
|
||||
imageUrl,
|
||||
siteName
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Scrape] Error fetching ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
63
memento-note/app/actions/semantic-search.ts
Normal file
63
memento-note/app/actions/semantic-search.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
'use server'
|
||||
|
||||
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
results: SearchResult[]
|
||||
query: string
|
||||
totalResults: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform hybrid semantic + keyword search
|
||||
* Supports contextual search within notebook (IA5)
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
query: string,
|
||||
options?: {
|
||||
limit?: number
|
||||
threshold?: number
|
||||
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
|
||||
}
|
||||
): Promise<SemanticSearchResponse> {
|
||||
try {
|
||||
const results = await semanticSearchService.search(query, {
|
||||
limit: options?.limit || 20,
|
||||
threshold: options?.threshold || 0.6,
|
||||
notebookId: options?.notebookId // NEW: Pass notebook filter
|
||||
})
|
||||
|
||||
return {
|
||||
results,
|
||||
query,
|
||||
totalResults: results.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in semantic search action:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a note for semantic search (generate embedding)
|
||||
*/
|
||||
export async function indexNote(noteId: string): Promise<void> {
|
||||
try {
|
||||
await semanticSearchService.indexNote(noteId)
|
||||
} catch (error) {
|
||||
console.error('Error indexing note:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch index notes (for initial setup)
|
||||
*/
|
||||
export async function batchIndexNotes(noteIds: string[]): Promise<void> {
|
||||
try {
|
||||
await semanticSearchService.indexBatchNotes(noteIds)
|
||||
} catch (error) {
|
||||
console.error('Error batch indexing notes:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
128
memento-note/app/actions/title-suggestions.ts
Normal file
128
memento-note/app/actions/title-suggestions.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { titleSuggestionService } from '@/lib/ai/services/title-suggestion.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export interface GenerateTitlesResponse {
|
||||
suggestions: Array<{
|
||||
title: string
|
||||
confidence: number
|
||||
reasoning?: string
|
||||
}>
|
||||
noteId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate title suggestions for a note
|
||||
* Triggered when note reaches 50+ words without a title
|
||||
*/
|
||||
export async function generateTitleSuggestions(noteId: string): Promise<GenerateTitlesResponse> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch note content
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: { id: true, content: true, userId: true }
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new Error('Note not found')
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
throw new Error('Forbidden')
|
||||
}
|
||||
|
||||
if (!note.content || note.content.trim().length === 0) {
|
||||
throw new Error('Note content is empty')
|
||||
}
|
||||
|
||||
// Generate suggestions
|
||||
const suggestions = await titleSuggestionService.generateSuggestions(note.content)
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
noteId
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating title suggestions:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply selected title to note
|
||||
*/
|
||||
export async function applyTitleSuggestion(
|
||||
noteId: string,
|
||||
selectedTitle: string
|
||||
): Promise<void> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Update note with selected title
|
||||
await prisma.note.update({
|
||||
where: {
|
||||
id: noteId,
|
||||
userId: session.user.id
|
||||
},
|
||||
data: {
|
||||
title: selectedTitle,
|
||||
autoGenerated: true,
|
||||
lastAiAnalysis: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath(`/note/${noteId}`)
|
||||
} catch (error) {
|
||||
console.error('Error applying title suggestion:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record user feedback on title suggestions
|
||||
* (Phase 3 - for improving future suggestions)
|
||||
*/
|
||||
export async function recordTitleFeedback(
|
||||
noteId: string,
|
||||
selectedTitle: string,
|
||||
allSuggestions: Array<{ title: string; confidence: number }>
|
||||
): Promise<void> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Save to AiFeedback table for learning
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId,
|
||||
userId: session.user.id,
|
||||
feedbackType: 'thumbs_up', // User chose one of our suggestions
|
||||
feature: 'title_suggestion',
|
||||
originalContent: JSON.stringify(allSuggestions),
|
||||
correctedContent: selectedTitle,
|
||||
metadata: JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'auto' // Will be dynamic based on user settings
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error recording title feedback:', error)
|
||||
// Don't throw - feedback is optional
|
||||
}
|
||||
}
|
||||
83
memento-note/app/actions/user-settings.ts
Normal file
83
memento-note/app/actions/user-settings.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user settings (theme, etc.)
|
||||
*/
|
||||
export async function updateUserSettings(settings: UserSettingsData) {
|
||||
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.error('[updateUserSettings] Unauthorized')
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: settings
|
||||
})
|
||||
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('user-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error updating user settings:', error)
|
||||
throw new Error('Failed to update user settings')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user settings for current user (Cached)
|
||||
*/
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
// Internal cached function
|
||||
const getCachedUserSettings = unstable_cache(
|
||||
async (userId: string) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { theme: true }
|
||||
})
|
||||
|
||||
return {
|
||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting user settings:', error)
|
||||
return {
|
||||
theme: 'light' as const
|
||||
}
|
||||
}
|
||||
},
|
||||
['user-settings'],
|
||||
{ tags: ['user-settings'] }
|
||||
)
|
||||
|
||||
export async function getUserSettings(userId?: string) {
|
||||
let id = userId
|
||||
|
||||
if (!id) {
|
||||
const session = await auth()
|
||||
id = session?.user?.id
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
theme: 'light' as const
|
||||
}
|
||||
}
|
||||
|
||||
return getCachedUserSettings(id)
|
||||
}
|
||||
100
memento-note/app/api/admin/embeddings/validate/route.ts
Normal file
100
memento-note/app/api/admin/embeddings/validate/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { validateEmbedding } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Admin endpoint to validate all embeddings in the database
|
||||
* Returns a list of notes with invalid embeddings
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
})
|
||||
|
||||
if (!user || user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Fetch all notes with embeddings
|
||||
const allNotes = await prisma.note.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
embedding: true
|
||||
}
|
||||
})
|
||||
|
||||
const invalidNotes: Array<{
|
||||
id: string
|
||||
title: string
|
||||
issues: string[]
|
||||
}> = []
|
||||
|
||||
let validCount = 0
|
||||
let missingCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const note of allNotes) {
|
||||
// Check if embedding is missing
|
||||
if (!note.embedding) {
|
||||
missingCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: ['Missing embedding']
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse and validate embedding
|
||||
try {
|
||||
const embedding = JSON.parse(note.embedding)
|
||||
const validation = validateEmbedding(embedding)
|
||||
|
||||
if (!validation.valid) {
|
||||
invalidCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: validation.issues
|
||||
})
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
} catch (error) {
|
||||
invalidCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: [`Failed to parse embedding: ${error}`]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
total: allNotes.length,
|
||||
valid: validCount,
|
||||
missing: missingCount,
|
||||
invalid: invalidCount
|
||||
},
|
||||
invalidNotes
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EMBEDDING_VALIDATION] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
memento-note/app/api/admin/randomize-labels/route.ts
Normal file
31
memento-note/app/api/admin/randomize-labels/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { LABEL_COLORS } from '@/lib/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const labels = await prisma.label.findMany();
|
||||
const colors = Object.keys(LABEL_COLORS).filter(c => c !== 'gray'); // Exclude gray to force colors
|
||||
|
||||
const updates = labels.map((label: any) => {
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
return prisma.label.update({
|
||||
where: { id: label.id },
|
||||
data: { color: randomColor }
|
||||
});
|
||||
});
|
||||
|
||||
await prisma.$transaction(updates);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updated: updates.length,
|
||||
message: "All labels have been assigned a random non-gray color."
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
57
memento-note/app/api/admin/sync-labels/route.ts
Normal file
57
memento-note/app/api/admin/sync-labels/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 1. Get all notes
|
||||
const notes = await prisma.note.findMany({
|
||||
select: { labels: true }
|
||||
});
|
||||
|
||||
// 2. Extract all unique labels from JSON
|
||||
const uniqueLabels = new Set<string>();
|
||||
notes.forEach((note: any) => {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const parsed = JSON.parse(note.labels);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((l: string) => uniqueLabels.add(l));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Get existing labels in DB
|
||||
const existingDbLabels = await prisma.label.findMany();
|
||||
const existingNames = new Set(existingDbLabels.map((l: any) => l.name));
|
||||
|
||||
// 4. Create missing labels
|
||||
const created = [];
|
||||
for (const name of uniqueLabels) {
|
||||
if (!existingNames.has(name)) {
|
||||
const newLabel = await prisma.label.create({
|
||||
data: {
|
||||
name,
|
||||
color: 'gray' // Default color
|
||||
}
|
||||
});
|
||||
created.push(newLabel);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
foundInNotes: uniqueLabels.size,
|
||||
alreadyInDb: existingNames.size,
|
||||
created: created.length,
|
||||
createdLabels: created
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
memento-note/app/api/ai/auto-labels/route.ts
Normal file
122
memento-note/app/api/ai/auto-labels/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||
|
||||
/**
|
||||
* POST /api/ai/auto-labels - Suggest new labels for a notebook
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId, language = 'en' } = body
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required field: notebookId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if notebook belongs to user
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
id: notebookId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get label suggestions
|
||||
const suggestions = await autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
if (!suggestions) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: 'No suggestions available (notebook may have fewer than 15 notes)',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: suggestions,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get label suggestions',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/ai/auto-labels - Create suggested labels
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { suggestions, selectedLabels } = body
|
||||
|
||||
if (!suggestions || !Array.isArray(selectedLabels)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required fields: suggestions, selectedLabels' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create labels
|
||||
const createdCount = await autoLabelCreationService.createLabels(
|
||||
suggestions.notebookId,
|
||||
session.user.id,
|
||||
suggestions,
|
||||
selectedLabels
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
createdCount,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create labels',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
101
memento-note/app/api/ai/batch-organize/route.ts
Normal file
101
memento-note/app/api/ai/batch-organize/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { batchOrganizationService } from '@/lib/ai/services'
|
||||
|
||||
/**
|
||||
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get language from request headers or body
|
||||
let language = 'en'
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (body.language) {
|
||||
language = body.language
|
||||
}
|
||||
} catch (e) {
|
||||
// If no body or invalid json, check headers
|
||||
const acceptLanguage = request.headers.get('accept-language')
|
||||
if (acceptLanguage) {
|
||||
language = acceptLanguage.split(',')[0].split('-')[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Create organization plan
|
||||
const plan = await batchOrganizationService.createOrganizationPlan(
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: plan,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create organization plan',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/ai/batch-organize - Apply organization plan
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { plan, selectedNoteIds } = body
|
||||
|
||||
if (!plan || !Array.isArray(selectedNoteIds)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required fields: plan, selectedNoteIds' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply organization plan
|
||||
const movedCount = await batchOrganizationService.applyOrganizationPlan(
|
||||
session.user.id,
|
||||
plan,
|
||||
selectedNoteIds
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
movedCount,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to apply organization plan',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
26
memento-note/app/api/ai/config/route.ts
Normal file
26
memento-note/app/api/ai/config/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
|
||||
return NextResponse.json({
|
||||
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
|
||||
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
|
||||
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
|
||||
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
|
||||
OPENAI_API_KEY: config.OPENAI_API_KEY ? '***configured***' : '',
|
||||
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? '***configured***' : '',
|
||||
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || '',
|
||||
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set'
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message || 'Failed to fetch config'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
85
memento-note/app/api/ai/echo/connections/route.ts
Normal file
85
memento-note/app/api/ai/echo/connections/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
||||
* Fetch all connections for a specific note
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
const { searchParams } = new URL(req.url)
|
||||
const noteId = searchParams.get('noteId')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
|
||||
// Validate noteId
|
||||
if (!noteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'noteId parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 50) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid pagination parameters. page >= 1, limit between 1 and 50' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get all connections for the note
|
||||
const allConnections = await memoryEchoService.getConnectionsForNote(noteId, session.user.id)
|
||||
|
||||
// Calculate pagination
|
||||
const total = allConnections.length
|
||||
const startIndex = (page - 1) * limit
|
||||
const endIndex = startIndex + limit
|
||||
const paginatedConnections = allConnections.slice(startIndex, endIndex)
|
||||
|
||||
// Format connections for response
|
||||
const connections = paginatedConnections.map(conn => {
|
||||
// Determine which note is the "other" note (not the target note)
|
||||
const isNote1Target = conn.note1.id === noteId
|
||||
const otherNote = isNote1Target ? conn.note2 : conn.note1
|
||||
|
||||
return {
|
||||
noteId: otherNote.id,
|
||||
title: otherNote.title,
|
||||
content: otherNote.content,
|
||||
createdAt: otherNote.createdAt,
|
||||
similarity: conn.similarityScore,
|
||||
daysApart: conn.daysApart
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
connections,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasNext: endIndex < total,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch connections' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
60
memento-note/app/api/ai/echo/dismiss/route.ts
Normal file
60
memento-note/app/api/ai/echo/dismiss/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo/dismiss
|
||||
* Dismiss a connection for a specific note
|
||||
* Body: { noteId, connectedNoteId }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteId, connectedNoteId } = body
|
||||
|
||||
if (!noteId || !connectedNoteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'noteId and connectedNoteId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find and mark matching insights as dismissed
|
||||
// We need to find insights where (note1Id = noteId AND note2Id = connectedNoteId) OR (note1Id = connectedNoteId AND note2Id = noteId)
|
||||
await prisma.memoryEchoInsight.updateMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
OR: [
|
||||
{
|
||||
note1Id: noteId,
|
||||
note2Id: connectedNoteId
|
||||
},
|
||||
{
|
||||
note1Id: connectedNoteId,
|
||||
note2Id: noteId
|
||||
}
|
||||
]
|
||||
},
|
||||
data: {
|
||||
dismissed: true
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to dismiss connection' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
107
memento-note/app/api/ai/echo/fusion/route.ts
Normal file
107
memento-note/app/api/ai/echo/fusion/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo/fusion
|
||||
* Generate intelligent fusion of multiple notes
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteIds, prompt } = body
|
||||
|
||||
if (!noteIds || !Array.isArray(noteIds) || noteIds.length < 2) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least 2 note IDs are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the notes
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
id: { in: noteIds },
|
||||
userId: session.user.id
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
if (notes.length !== noteIds.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Some notes not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get AI provider
|
||||
const config = await prisma.systemConfig.findFirst()
|
||||
const provider = getAIProvider(config || undefined)
|
||||
|
||||
// Build fusion prompt
|
||||
const notesDescriptions = notes.map((note, index) => {
|
||||
return `Note ${index + 1}: "${note.title || 'Untitled'}"
|
||||
${note.content}`
|
||||
}).join('\n\n')
|
||||
|
||||
const fusionPrompt = `You are an expert at synthesizing and merging information from multiple sources.
|
||||
|
||||
TASK: Create a unified, well-structured note by intelligently combining the following notes.
|
||||
|
||||
${prompt ? `ADDITIONAL INSTRUCTIONS: ${prompt}\n` : ''}
|
||||
|
||||
NOTES TO MERGE:
|
||||
${notesDescriptions}
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Create a clear, descriptive title that captures the essence of all notes
|
||||
2. Merge and consolidate related information
|
||||
3. Remove duplicates while preserving unique details from each note
|
||||
4. Organize the content logically (use headers, bullet points, etc.)
|
||||
5. Maintain the important details and context from all notes
|
||||
6. Keep the tone and style consistent
|
||||
7. Use markdown formatting for better readability
|
||||
|
||||
Output format:
|
||||
# [Fused Title]
|
||||
|
||||
[Merged and organized content...]
|
||||
|
||||
Begin:`
|
||||
|
||||
try {
|
||||
const fusedContent = await provider.generateText(fusionPrompt)
|
||||
|
||||
return NextResponse.json({
|
||||
fusedNote: fusedContent,
|
||||
notesCount: notes.length
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate fusion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process fusion request' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
92
memento-note/app/api/ai/echo/route.ts
Normal file
92
memento-note/app/api/ai/echo/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo
|
||||
* Fetch next Memory Echo insight for current user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get next insight (respects frequency limits)
|
||||
const insight = await memoryEchoService.getNextInsight(session.user.id)
|
||||
|
||||
if (!insight) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
insight: null,
|
||||
message: 'No new insights available at the moment. Memory Echo will notify you when we discover connections between your notes.'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ insight })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Memory Echo insight' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo
|
||||
* Submit feedback or mark as viewed
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { action, insightId, feedback } = body
|
||||
|
||||
if (action === 'view') {
|
||||
// Mark insight as viewed
|
||||
await memoryEchoService.markAsViewed(insightId)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
} else if (action === 'feedback') {
|
||||
// Submit feedback (thumbs_up or thumbs_down)
|
||||
if (!feedback || !['thumbs_up', 'thumbs_down'].includes(feedback)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid feedback. Must be thumbs_up or thumbs_down' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await memoryEchoService.submitFeedback(insightId, feedback)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Must be "view" or "feedback"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process request' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
96
memento-note/app/api/ai/models/route.ts
Normal file
96
memento-note/app/api/ai/models/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
// Modèles populaires pour chaque provider (2025)
|
||||
const PROVIDER_MODELS = {
|
||||
ollama: {
|
||||
tags: [
|
||||
'llama3:latest',
|
||||
'llama3.2:latest',
|
||||
'granite4:latest',
|
||||
'mistral:latest',
|
||||
'mixtral:latest',
|
||||
'phi3:latest',
|
||||
'gemma2:latest',
|
||||
'qwen2:latest'
|
||||
],
|
||||
embeddings: [
|
||||
'embeddinggemma:latest',
|
||||
'mxbai-embed-large:latest',
|
||||
'nomic-embed-text:latest'
|
||||
]
|
||||
},
|
||||
openai: {
|
||||
tags: [
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo'
|
||||
],
|
||||
embeddings: [
|
||||
'text-embedding-3-small',
|
||||
'text-embedding-3-large',
|
||||
'text-embedding-ada-002'
|
||||
]
|
||||
},
|
||||
custom: {
|
||||
tags: [], // Will be loaded dynamically
|
||||
embeddings: [] // Will be loaded dynamically
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = (config.AI_PROVIDER || 'ollama').toLowerCase()
|
||||
|
||||
let models = PROVIDER_MODELS[provider as keyof typeof PROVIDER_MODELS] || { tags: [], embeddings: [] }
|
||||
|
||||
// Pour Ollama, essayer de récupérer la liste réelle depuis l'API locale
|
||||
if (provider === 'ollama') {
|
||||
try {
|
||||
const ollamaBaseUrl = config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
const response = await fetch(`${ollamaBaseUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const allModels = data.models || []
|
||||
|
||||
// Séparer les modèles de tags et d'embeddings
|
||||
const tagModels = allModels
|
||||
.filter((m: any) => !m.name.includes('embed') && !m.name.includes('Embedding'))
|
||||
.map((m: any) => m.name)
|
||||
.slice(0, 20) // Limiter à 20 modèles
|
||||
|
||||
const embeddingModels = allModels
|
||||
.filter((m: any) => m.name.includes('embed') || m.name.includes('Embedding'))
|
||||
.map((m: any) => m.name)
|
||||
|
||||
models = {
|
||||
tags: tagModels.length > 0 ? tagModels : models.tags,
|
||||
embeddings: embeddingModels.length > 0 ? embeddingModels : models.embeddings
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Garder les modèles par défaut
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
provider,
|
||||
models: models || { tags: [], embeddings: [] }
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message || 'Failed to fetch models',
|
||||
models: { tags: [], embeddings: [] }
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
73
memento-note/app/api/ai/notebook-summary/route.ts
Normal file
73
memento-note/app/api/ai/notebook-summary/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSummaryService } from '@/lib/ai/services'
|
||||
|
||||
/**
|
||||
* POST /api/ai/notebook-summary - Generate summary for a notebook
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId, language = 'en' } = body
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required field: notebookId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if notebook belongs to user
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
id: notebookId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
const summary = await notebookSummaryService.generateSummary(
|
||||
notebookId,
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
if (!summary) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: 'No summary available (notebook may be empty)',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: summary,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to generate notebook summary',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
57
memento-note/app/api/ai/reformulate/route.ts
Normal file
57
memento-note/app/api/ai/reformulate/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { text, option } = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Map option to refactor mode
|
||||
const modeMap: Record<string, 'clarify' | 'shorten' | 'improveStyle'> = {
|
||||
'clarify': 'clarify',
|
||||
'shorten': 'shorten',
|
||||
'improve': 'improveStyle'
|
||||
}
|
||||
|
||||
const mode = modeMap[option]
|
||||
if (!mode) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid option. Use: clarify, shorten, or improve' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate word count
|
||||
const validation = paragraphRefactorService.validateWordCount(text)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// Use the ParagraphRefactorService
|
||||
const result = await paragraphRefactorService.refactor(text, mode)
|
||||
|
||||
return NextResponse.json({
|
||||
originalText: result.original,
|
||||
reformulatedText: result.refactored,
|
||||
option: option,
|
||||
language: result.language,
|
||||
wordCountChange: result.wordCountChange
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to reformulate text' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
46
memento-note/app/api/ai/suggest-notebook/route.ts
Normal file
46
memento-note/app/api/ai/suggest-notebook/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteContent, language = 'en' } = body
|
||||
|
||||
if (!noteContent || typeof noteContent !== 'string') {
|
||||
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Minimum content length for suggestion (20 words as per specs)
|
||||
const wordCount = noteContent.trim().split(/\s+/).length
|
||||
if (wordCount < 20) {
|
||||
return NextResponse.json({
|
||||
suggestion: null,
|
||||
reason: 'content_too_short',
|
||||
message: 'Note content too short for meaningful suggestion'
|
||||
})
|
||||
}
|
||||
|
||||
// Get suggestion from AI service
|
||||
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
suggestion: suggestedNotebook,
|
||||
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate suggestion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
memento-note/app/api/ai/tags/route.ts
Normal file
62
memento-note/app/api/ai/tags/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service';
|
||||
import { getAIProvider } from '@/lib/ai/factory';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
notebookId: z.string().optional(),
|
||||
language: z.string().default('en'),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { content, notebookId, language } = requestSchema.parse(body);
|
||||
|
||||
// If notebookId is provided, use contextual suggestions (IA2)
|
||||
if (notebookId) {
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
session.user.id,
|
||||
language
|
||||
);
|
||||
|
||||
// Convert label → tag to match TagSuggestion interface
|
||||
const convertedTags = suggestions.map(s => ({
|
||||
tag: s.label, // Convert label to tag
|
||||
confidence: s.confidence,
|
||||
// Keep additional properties for client-side use
|
||||
...(s.reasoning && { reasoning: s.reasoning }),
|
||||
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tags: convertedTags });
|
||||
}
|
||||
|
||||
// Otherwise, use legacy auto-tagging (generates new tags)
|
||||
const config = await getSystemConfig();
|
||||
const provider = getAIProvider(config);
|
||||
const tags = await provider.generateTags(content);
|
||||
|
||||
return NextResponse.json({ tags });
|
||||
} catch (error: any) {
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Erreur lors de la génération des tags' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
memento-note/app/api/ai/test-embeddings/route.ts
Normal file
90
memento-note/app/api/ai/test-embeddings/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getEmbeddingsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
function getProviderDetails(config: Record<string, string>, providerType: string) {
|
||||
const provider = providerType.toLowerCase()
|
||||
|
||||
switch (provider) {
|
||||
case 'ollama':
|
||||
return {
|
||||
provider: 'Ollama',
|
||||
baseUrl: config.OLLAMA_BASE_URL || 'http://localhost:11434',
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
|
||||
}
|
||||
case 'openai':
|
||||
return {
|
||||
provider: 'OpenAI',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
case 'custom':
|
||||
return {
|
||||
provider: 'Custom OpenAI',
|
||||
baseUrl: config.CUSTOM_OPENAI_BASE_URL || 'Not configured',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
provider: provider,
|
||||
baseUrl: 'unknown',
|
||||
model: config.AI_MODEL_EMBEDDING || 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getEmbeddingsProvider(config)
|
||||
|
||||
const testText = 'test'
|
||||
const startTime = Date.now()
|
||||
const embeddings = await provider.getEmbeddings(testText)
|
||||
const endTime = Date.now()
|
||||
|
||||
if (!embeddings || embeddings.length === 0) {
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No embeddings returned',
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
details
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
embeddingLength: embeddings.length,
|
||||
firstValues: embeddings.slice(0, 5),
|
||||
responseTime: endTime - startTime,
|
||||
details
|
||||
})
|
||||
} catch (error: any) {
|
||||
const config = await getSystemConfig()
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Unknown error',
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
details,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
memento-note/app/api/ai/test-tags/route.ts
Normal file
49
memento-note/app/api/ai/test-tags/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const testContent = "This is a test note about artificial intelligence and machine learning. It contains keywords like AI, ML, neural networks, and deep learning."
|
||||
|
||||
const startTime = Date.now()
|
||||
const tags = await provider.generateTags(testContent)
|
||||
const endTime = Date.now()
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No tags generated',
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest',
|
||||
tags: tags,
|
||||
responseTime: endTime - startTime
|
||||
})
|
||||
} catch (error: any) {
|
||||
const config = await getSystemConfig()
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Unknown error',
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest',
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
89
memento-note/app/api/ai/test/route.ts
Normal file
89
memento-note/app/api/ai/test/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTagsProvider, getEmbeddingsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
function getProviderDetails(config: Record<string, string>, providerType: string) {
|
||||
const provider = providerType.toLowerCase()
|
||||
|
||||
switch (provider) {
|
||||
case 'ollama':
|
||||
return {
|
||||
provider: 'Ollama',
|
||||
baseUrl: config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
|
||||
}
|
||||
case 'openai':
|
||||
return {
|
||||
provider: 'OpenAI',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
case 'custom':
|
||||
return {
|
||||
provider: 'Custom OpenAI',
|
||||
baseUrl: config.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || 'Not configured',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
provider: provider,
|
||||
baseUrl: 'unknown',
|
||||
model: config.AI_MODEL_EMBEDDING || 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const tagsProvider = getTagsProvider(config)
|
||||
const embeddingsProvider = getEmbeddingsProvider(config)
|
||||
|
||||
const testText = 'test'
|
||||
|
||||
// Test embeddings provider
|
||||
const embeddings = await embeddingsProvider.getEmbeddings(testText)
|
||||
|
||||
if (!embeddings || embeddings.length === 0) {
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
tagsProvider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
embeddingsProvider: providerType,
|
||||
error: 'No embeddings returned',
|
||||
details
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const tagsProviderType = config.AI_PROVIDER_TAGS || 'ollama'
|
||||
const embeddingsProviderType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, embeddingsProviderType)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tagsProvider: tagsProviderType,
|
||||
embeddingsProvider: embeddingsProviderType,
|
||||
embeddingLength: embeddings.length,
|
||||
firstValues: embeddings.slice(0, 5),
|
||||
details
|
||||
})
|
||||
} catch (error: any) {
|
||||
const config = await getSystemConfig()
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Unknown error',
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
|
||||
details
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
99
memento-note/app/api/ai/title-suggestions/route.ts
Normal file
99
memento-note/app/api/ai/title-suggestions/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { z } from 'zod'
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { content } = requestSchema.parse(body)
|
||||
|
||||
// Vérifier qu'il y a au moins 10 mots
|
||||
const wordCount = content.split(/\s+/).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Le contenu doit avoir au moins 10 mots' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères)
|
||||
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
|
||||
const isPersian = /[\u0600-\u06FF]/.test(content)
|
||||
const isChinese = /[\u4E00-\u9FFF]/.test(content)
|
||||
const isRussian = /[\u0400-\u04FF]/.test(content)
|
||||
const isArabic = /[\u0600-\u06FF]/.test(content)
|
||||
|
||||
// Déterminer la langue du prompt système
|
||||
let promptLanguage = 'en'
|
||||
let responseLanguage = 'English'
|
||||
|
||||
if (isPersian) {
|
||||
promptLanguage = 'fa' // Persan
|
||||
responseLanguage = 'Persian'
|
||||
} else if (isChinese) {
|
||||
promptLanguage = 'zh' // Chinois
|
||||
responseLanguage = 'Chinese'
|
||||
} else if (isRussian) {
|
||||
promptLanguage = 'ru' // Russe
|
||||
responseLanguage = 'Russian'
|
||||
} else if (isArabic) {
|
||||
promptLanguage = 'ar' // Arabe
|
||||
responseLanguage = 'Arabic'
|
||||
}
|
||||
|
||||
// Générer des titres appropriés basés sur le contenu
|
||||
const titlePrompt = promptLanguage === 'en'
|
||||
? `You are a title generator. Generate 3 concise, descriptive titles for the following content.
|
||||
|
||||
IMPORTANT INSTRUCTIONS:
|
||||
- Use ONLY the content provided below between the CONTENT_START and CONTENT_END markers
|
||||
- Do NOT use any external knowledge or training data
|
||||
- Focus on the main topics and themes in THIS SPECIFIC content
|
||||
- Be specific to what is actually discussed
|
||||
|
||||
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
|
||||
|
||||
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
|
||||
: `Tu es un générateur de titres. Génère 3 titres concis et descriptifs pour le contenu suivant en ${responseLanguage}.
|
||||
|
||||
INSTRUCTIONS IMPORTANTES :
|
||||
- Utilise SEULEMENT le contenu fourni entre les marqueurs CONTENT_START et CONTENT_END
|
||||
- N'utilise AUCUNE connaissance externe ou données d'entraînement
|
||||
- Concentre-toi sur les sujets principaux et thèmes de CE CONTENU SPÉCIFIQUE
|
||||
- Sois spécifique à ce qui est réellement discuté
|
||||
|
||||
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
|
||||
|
||||
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`
|
||||
|
||||
const titles = await provider.generateTitles(titlePrompt)
|
||||
|
||||
// Créer les suggestions
|
||||
const suggestions = titles.map((t: any) => ({
|
||||
title: t.title,
|
||||
confidence: Math.round(t.confidence * 100),
|
||||
reasoning: `Basé sur le contenu`
|
||||
}))
|
||||
|
||||
return NextResponse.json({ suggestions })
|
||||
} catch (error: any) {
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Erreur lors de la génération des titres' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
90
memento-note/app/api/ai/transform-markdown/route.ts
Normal file
90
memento-note/app/api/ai/transform-markdown/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { text } = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate word count
|
||||
const wordCount = text.split(/\s+/).length
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Text must have at least 10 words to transform' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (wordCount > 500) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Text must have maximum 500 words to transform' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
// Detect language from text
|
||||
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
|
||||
const responseLanguage = hasFrench ? 'French' : 'English'
|
||||
|
||||
// Build prompt to transform text to Markdown
|
||||
const prompt = hasFrench
|
||||
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
|
||||
|
||||
IMPORTANT :
|
||||
- Ajoute des titres avec ## pour les sections principales
|
||||
- Utilise des listes à puces (-) ou numérotées (1.) quand approprié
|
||||
- Ajoute de l'emphase (gras **texte**, italique *texte*) pour les mots clés
|
||||
- Utilise des blocs de code pour le code ou les commandes
|
||||
- Présente l'information de manière claire et structurée
|
||||
- GARDE le même sens et le contenu, seul le format change
|
||||
|
||||
Texte à transformer :
|
||||
${text}
|
||||
|
||||
Réponds SEULEMENT avec le texte transformé en Markdown, sans explications.`
|
||||
: `You are a Markdown expert. Transform this ${responseLanguage} text into well-formatted Markdown.
|
||||
|
||||
IMPORTANT:
|
||||
- Add headings with ## for main sections
|
||||
- Use bullet lists (-) or numbered lists (1.) when appropriate
|
||||
- Add emphasis (bold **text**, italic *text*) for key terms
|
||||
- Use code blocks for code or commands
|
||||
- Present information clearly and structured
|
||||
- KEEP the same meaning and content, only change the format
|
||||
|
||||
Text to transform:
|
||||
${text}
|
||||
|
||||
Respond ONLY with the transformed Markdown text, no explanations.`
|
||||
|
||||
|
||||
const transformedText = await provider.generateText(prompt)
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
originalText: text,
|
||||
transformedText: transformedText,
|
||||
language: responseLanguage
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to transform text to Markdown' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
2
memento-note/app/api/auth/[...nextauth]/route.ts
Normal file
2
memento-note/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/auth"
|
||||
export const { GET, POST } = handlers
|
||||
62
memento-note/app/api/cron/reminders/route.ts
Normal file
62
memento-note/app/api/cron/reminders/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // No caching
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// 1. Find all due reminders that haven't been processed
|
||||
const dueNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
reminder: {
|
||||
lte: now, // Less than or equal to now
|
||||
},
|
||||
isReminderDone: false,
|
||||
isArchived: false, // Optional: exclude archived notes
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
reminder: true,
|
||||
// Add other fields useful for notification
|
||||
},
|
||||
});
|
||||
|
||||
if (dueNotes.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: 0,
|
||||
message: 'No due reminders found'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Mark them as done (Atomic operation logic would be better but simple batch update is fine here)
|
||||
const noteIds = dueNotes.map((n: any) => n.id);
|
||||
|
||||
await prisma.note.updateMany({
|
||||
where: {
|
||||
id: { in: noteIds }
|
||||
},
|
||||
data: {
|
||||
isReminderDone: true
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Return the notes to N8N so it can send emails/messages
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: dueNotes.length,
|
||||
reminders: dueNotes
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing cron reminders:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
memento-note/app/api/debug/config/route.ts
Normal file
32
memento-note/app/api/debug/config/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Debug endpoint to check AI configuration
|
||||
* This helps verify that OpenAI is properly configured
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = await getSystemConfig();
|
||||
|
||||
// Return only AI-related config for debugging
|
||||
const aiConfig = {
|
||||
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
|
||||
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
|
||||
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
|
||||
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
|
||||
OPENAI_API_KEY: config.OPENAI_API_KEY ? 'set (hidden)' : 'not set',
|
||||
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set',
|
||||
OLLAMA_MODEL: config.OLLAMA_MODEL || 'not set',
|
||||
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || 'not set',
|
||||
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? 'set (hidden)' : 'not set',
|
||||
};
|
||||
|
||||
return NextResponse.json(aiConfig);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get config', details: error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
118
memento-note/app/api/fix-labels/route.ts
Normal file
118
memento-note/app/api/fix-labels/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
function getHashColor(name: string): string {
|
||||
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = { created: 0, deleted: 0, missing: [] as string[] }
|
||||
|
||||
// Get ALL users
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, email: true }
|
||||
})
|
||||
|
||||
|
||||
for (const user of users) {
|
||||
const userId = user.id
|
||||
|
||||
// 1. Get all labels from notes
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: { userId },
|
||||
select: { labels: true }
|
||||
})
|
||||
|
||||
const labelsInNotes = new Set<string>()
|
||||
allNotes.forEach(note => {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const parsed: string[] = JSON.parse(note.labels)
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(l => {
|
||||
if (l && l.trim()) labelsInNotes.add(l.trim())
|
||||
})
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 2. Get existing Label records
|
||||
const existingLabels = await prisma.label.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
|
||||
|
||||
const existingLabelMap = new Map<string, any>()
|
||||
existingLabels.forEach(label => {
|
||||
existingLabelMap.set(label.name.toLowerCase(), label)
|
||||
})
|
||||
|
||||
// 3. Create missing Label records
|
||||
for (const labelName of labelsInNotes) {
|
||||
if (!existingLabelMap.has(labelName.toLowerCase())) {
|
||||
try {
|
||||
await prisma.label.create({
|
||||
data: {
|
||||
userId,
|
||||
name: labelName,
|
||||
color: getHashColor(labelName)
|
||||
}
|
||||
})
|
||||
result.created++
|
||||
} catch (e: any) {
|
||||
console.error(`[FIX] ✗ Failed to create "${labelName}":`, e.message, e.code)
|
||||
result.missing.push(labelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete orphan Label records
|
||||
const usedLabelsSet = new Set<string>()
|
||||
allNotes.forEach(note => {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const parsed: string[] = JSON.parse(note.labels)
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(l => usedLabelsSet.add(l.toLowerCase()))
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
|
||||
for (const label of existingLabels) {
|
||||
if (!usedLabelsSet.has(label.name.toLowerCase())) {
|
||||
try {
|
||||
await prisma.label.delete({
|
||||
where: { id: label.id }
|
||||
})
|
||||
result.deleted++
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
message: `Created ${result.created} labels, deleted ${result.deleted} orphans`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[FIX] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
247
memento-note/app/api/labels/[id]/route.ts
Normal file
247
memento-note/app/api/labels/[id]/route.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// GET /api/labels/[id] - Get a specific label
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const label = await prisma.label.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!label) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (label.notebookId) {
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: label.notebookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
if (notebook?.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
} else if (label.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/labels/[id] - Update a label
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { name, color } = body
|
||||
|
||||
// Get the current label first
|
||||
const currentLabel = await prisma.label.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, userId: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!currentLabel) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (currentLabel.notebookId) {
|
||||
if (currentLabel.notebook?.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
} else if (currentLabel.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const newName = name ? name.trim() : currentLabel.name
|
||||
|
||||
// For backward compatibility, update old label field in notes if renaming
|
||||
if (name && name.trim() !== currentLabel.name && currentLabel.userId) {
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: currentLabel.userId,
|
||||
labels: { not: null }
|
||||
},
|
||||
select: { id: true, labels: true }
|
||||
})
|
||||
|
||||
for (const note of allNotes) {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const noteLabels: string[] = JSON.parse(note.labels)
|
||||
const updatedLabels = noteLabels.map(l =>
|
||||
l.toLowerCase() === currentLabel.name.toLowerCase() ? newName : l
|
||||
)
|
||||
|
||||
if (JSON.stringify(updatedLabels) !== JSON.stringify(noteLabels)) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
labels: JSON.stringify(updatedLabels)
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now update the label record
|
||||
const label = await prisma.label.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name && { name: newName }),
|
||||
...(color && { color })
|
||||
}
|
||||
})
|
||||
|
||||
// Revalidate to refresh UI
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/labels/[id] - Delete a label
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// First, get the label to know its name and userId
|
||||
const label = await prisma.label.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, userId: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!label) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (label.notebookId) {
|
||||
if (label.notebook?.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
} else if (label.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// For backward compatibility, remove from old label field in notes
|
||||
if (label.userId) {
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: label.userId,
|
||||
labels: { not: null }
|
||||
},
|
||||
select: { id: true, labels: true }
|
||||
})
|
||||
|
||||
for (const note of allNotes) {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const noteLabels: string[] = JSON.parse(note.labels)
|
||||
const filteredLabels = noteLabels.filter(
|
||||
l => l.toLowerCase() !== label.name.toLowerCase()
|
||||
)
|
||||
|
||||
if (filteredLabels.length !== noteLabels.length) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
labels: filteredLabels.length > 0 ? JSON.stringify(filteredLabels) : null
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now delete the label record
|
||||
await prisma.label.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
// Revalidate to refresh UI
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Label "${label.name}" deleted successfully`
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
129
memento-note/app/api/labels/route.ts
Normal file
129
memento-note/app/api/labels/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
|
||||
// GET /api/labels - Get all labels (supports optional notebookId filter)
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const notebookId = searchParams.get('notebookId')
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
|
||||
if (notebookId === 'null' || notebookId === '') {
|
||||
// Get labels without a notebook (backward compatibility)
|
||||
where.notebookId = null
|
||||
} else if (notebookId) {
|
||||
// Get labels for a specific notebook
|
||||
where.notebookId = notebookId
|
||||
} else {
|
||||
// Get all labels for the user (both old and new system)
|
||||
where.OR = [
|
||||
{ notebookId: { not: null } },
|
||||
{ userId: session.user.id }
|
||||
]
|
||||
}
|
||||
|
||||
const labels = await prisma.label.findMany({
|
||||
where,
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: labels
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch labels' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/labels - Create a new label
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, color, notebookId } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'notebookId is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify notebook ownership
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
if (!notebook || notebook.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found or unauthorized' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if label already exists in this notebook
|
||||
const existing = await prisma.label.findFirst({
|
||||
where: {
|
||||
name: name.trim(),
|
||||
notebookId: notebookId
|
||||
}
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label already exists in this notebook' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const label = await prisma.label.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
color: color || COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||
notebookId: notebookId,
|
||||
userId: session.user.id // Keep for backward compatibility
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: label
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
133
memento-note/app/api/notebooks/[id]/route.ts
Normal file
133
memento-note/app/api/notebooks/[id]/route.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// PATCH /api/notebooks/[id] - Update a notebook
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { name, icon, color, order } = body
|
||||
|
||||
// Verify ownership
|
||||
const existing = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existing.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: any = {}
|
||||
if (name !== undefined) updateData.name = name.trim()
|
||||
if (icon !== undefined) updateData.icon = icon
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (order !== undefined) updateData.order = order
|
||||
|
||||
// Update notebook
|
||||
const notebook = await prisma.notebook.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...notebook,
|
||||
notesCount: notebook._count.notes
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update notebook' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notebooks/[id] - Delete a notebook
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Verify ownership and get notebook info
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
userId: true,
|
||||
name: true,
|
||||
_count: {
|
||||
select: { notes: true, labels: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (notebook.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete notebook (cascade will handle labels and notes)
|
||||
await prisma.notebook.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Notebook "${notebook.name}" deleted`,
|
||||
notesCount: notebook._count.notes,
|
||||
labelsCount: notebook._count.labels
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete notebook' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
memento-note/app/api/notebooks/reorder/route.ts
Normal file
62
memento-note/app/api/notebooks/reorder/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// POST /api/notebooks/reorder - Reorder notebooks
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { notebookIds } = body
|
||||
|
||||
if (!Array.isArray(notebookIds)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'notebookIds must be an array' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify all notebooks belong to the user
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: {
|
||||
id: { in: notebookIds },
|
||||
userId: session.user.id
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
if (notebooks.length !== notebookIds.length) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'One or more notebooks not found or unauthorized' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update order for each notebook
|
||||
const updates = notebookIds.map((id, index) =>
|
||||
prisma.notebook.update({
|
||||
where: { id },
|
||||
data: { order: index }
|
||||
})
|
||||
)
|
||||
|
||||
await prisma.$transaction(updates)
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Notebooks reordered successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to reorder notebooks' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
102
memento-note/app/api/notebooks/route.ts
Normal file
102
memento-note/app/api/notebooks/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
|
||||
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
|
||||
|
||||
// GET /api/notebooks - Get all notebooks for current user
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
labels: {
|
||||
orderBy: { name: 'asc' }
|
||||
},
|
||||
_count: {
|
||||
select: { notes: true }
|
||||
}
|
||||
},
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
notebooks: notebooks.map(nb => ({
|
||||
...nb,
|
||||
notesCount: nb._count.notes
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notebooks' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/notebooks - Create a new notebook
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, icon, color } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the highest order value for this user
|
||||
const highestOrder = await prisma.notebook.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true }
|
||||
})
|
||||
|
||||
const nextOrder = (highestOrder?.order ?? -1) + 1
|
||||
|
||||
// Create notebook
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
icon: icon || DEFAULT_ICONS[Math.floor(Math.random() * DEFAULT_ICONS.length)],
|
||||
color: color || DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)],
|
||||
order: nextOrder,
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...notebook,
|
||||
notesCount: notebook._count.notes
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create notebook' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
90
memento-note/app/api/notes/[id]/move/route.ts
Normal file
90
memento-note/app/api/notes/[id]/move/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { notebookId } = body
|
||||
|
||||
// Get the note
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
notebookId: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (note.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// If notebookId is provided, verify it exists and belongs to the user
|
||||
if (notebookId !== null && notebookId !== '') {
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
if (!notebook || notebook.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found or unauthorized' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the note's notebook
|
||||
// notebookId = null or "" means move to Inbox (Notes générales)
|
||||
const updatedNote = await prisma.note.update({
|
||||
where: { id },
|
||||
data: {
|
||||
notebookId: notebookId && notebookId !== '' ? notebookId : null
|
||||
},
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedNote,
|
||||
message: notebookId && notebookId !== ''
|
||||
? `Note moved to "${updatedNote.notebook?.name || 'notebook'}"`
|
||||
: 'Note moved to Inbox'
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to move note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
100
memento-note/app/api/notes/[id]/route.ts
Normal file
100
memento-note/app/api/notes/[id]/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
// Helper to parse JSON fields
|
||||
function parseNote(dbNote: any) {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/notes/[id] - Get a single note
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/notes/[id] - Update a note
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const updateData: any = { ...body }
|
||||
|
||||
// Stringify JSON fields if they exist
|
||||
if ('checkItems' in body) {
|
||||
updateData.checkItems = body.checkItems ? JSON.stringify(body.checkItems) : null
|
||||
}
|
||||
if ('labels' in body) {
|
||||
updateData.labels = body.labels ? JSON.stringify(body.labels) : null
|
||||
}
|
||||
updateData.updatedAt = new Date()
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notes/[id] - Delete a note
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
53
memento-note/app/api/notes/delete-all/route.ts
Normal file
53
memento-note/app/api/notes/delete-all/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete all notes for the user (cascade will handle labels-note relationships)
|
||||
const result = await prisma.note.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Delete all labels for the user
|
||||
await prisma.label.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Delete all notebooks for the user
|
||||
await prisma.notebook.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Revalidate paths
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedNotes: result.count
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Delete all error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
121
memento-note/app/api/notes/export/route.ts
Normal file
121
memento-note/app/api/notes/export/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch all notes with related data
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
labels: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
notebook: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch labels separately
|
||||
const labels = await prisma.label.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
notes: {
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch notebooks
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
notes: {
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create export object
|
||||
const exportData = {
|
||||
version: '1.0.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name
|
||||
},
|
||||
data: {
|
||||
labels: labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
noteCount: label.notes.length
|
||||
})),
|
||||
notebooks: notebooks.map(notebook => ({
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
description: notebook.description,
|
||||
noteCount: notebook.notes.length
|
||||
})),
|
||||
notes: notes.map(note => ({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
isPinned: note.isPinned,
|
||||
notebookId: note.notebookId,
|
||||
labels: note.labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Return as JSON file
|
||||
const jsonString = JSON.stringify(exportData, null, 2)
|
||||
return new NextResponse(jsonString, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="memento-export-${new Date().toISOString().split('T')[0]}.json"`
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to export notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
158
memento-note/app/api/notes/import/route.ts
Normal file
158
memento-note/app/api/notes/import/route.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse JSON file
|
||||
const text = await file.text()
|
||||
let importData: any
|
||||
|
||||
try {
|
||||
importData = JSON.parse(text)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid JSON file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate import data structure
|
||||
if (!importData.data || !importData.data.notes) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid import format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let importedNotes = 0
|
||||
let importedLabels = 0
|
||||
let importedNotebooks = 0
|
||||
|
||||
// Import labels first
|
||||
if (importData.data.labels && Array.isArray(importData.data.labels)) {
|
||||
for (const label of importData.data.labels) {
|
||||
// Check if label already exists
|
||||
const existing = await prisma.label.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: label.name
|
||||
}
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await prisma.label.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: label.name,
|
||||
color: label.color
|
||||
}
|
||||
})
|
||||
importedLabels++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import notebooks
|
||||
const notebookIdMap = new Map<string, string>()
|
||||
if (importData.data.notebooks && Array.isArray(importData.data.notebooks)) {
|
||||
for (const notebook of importData.data.notebooks) {
|
||||
// Check if notebook already exists
|
||||
const existing = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name
|
||||
}
|
||||
})
|
||||
|
||||
let newNotebookId
|
||||
if (!existing) {
|
||||
const created = await prisma.notebook.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name,
|
||||
description: notebook.description || null,
|
||||
position: 0
|
||||
}
|
||||
})
|
||||
newNotebookId = created.id
|
||||
notebookIdMap.set(notebook.id, newNotebookId)
|
||||
importedNotebooks++
|
||||
} else {
|
||||
notebookIdMap.set(notebook.id, existing.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import notes
|
||||
if (importData.data.notes && Array.isArray(importData.data.notes)) {
|
||||
for (const note of importData.data.notes) {
|
||||
// Map notebook ID
|
||||
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
|
||||
|
||||
// Get label IDs
|
||||
const labels = await prisma.label.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: {
|
||||
in: note.labels.map((l: any) => l.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create note
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: note.title || 'Untitled',
|
||||
content: note.content,
|
||||
isPinned: note.isPinned || false,
|
||||
notebookId: mappedNotebookId,
|
||||
labels: {
|
||||
connect: labels.map(label => ({ id: label.id }))
|
||||
}
|
||||
}
|
||||
})
|
||||
importedNotes++
|
||||
}
|
||||
}
|
||||
|
||||
// Revalidate paths
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: importedNotes,
|
||||
labels: importedLabels,
|
||||
notebooks: importedNotebooks
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to import notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
162
memento-note/app/api/notes/route.ts
Normal file
162
memento-note/app/api/notes/route.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { CheckItem } from '@/lib/types'
|
||||
|
||||
// Helper to parse JSON fields
|
||||
function parseNote(dbNote: any) {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/notes - Get all notes
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeArchived = searchParams.get('archived') === 'true'
|
||||
const search = searchParams.get('search')
|
||||
|
||||
let where: any = {}
|
||||
|
||||
if (!includeArchived) {
|
||||
where.isArchived = false
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ content: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ order: 'asc' },
|
||||
{ updatedAt: 'desc' }
|
||||
]
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: notes.map(parseNote)
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/notes - Create a new note
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title, content, color, type, checkItems, labels, images } = body
|
||||
|
||||
if (!content && type !== 'checklist') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Content is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: title || null,
|
||||
content: content || '',
|
||||
color: color || 'default',
|
||||
type: type || 'text',
|
||||
checkItems: checkItems ? JSON.stringify(checkItems) : null,
|
||||
labels: labels ? JSON.stringify(labels) : null,
|
||||
images: images ? JSON.stringify(images) : null,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/notes - Update an existing note
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
|
||||
if (title !== undefined) updateData.title = title
|
||||
if (content !== undefined) updateData.content = content
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (type !== undefined) updateData.type = type
|
||||
if (checkItems !== undefined) updateData.checkItems = checkItems ? JSON.stringify(checkItems) : null
|
||||
if (labels !== undefined) updateData.labels = labels ? JSON.stringify(labels) : null
|
||||
if (isPinned !== undefined) updateData.isPinned = isPinned
|
||||
if (isArchived !== undefined) updateData.isArchived = isArchived
|
||||
if (images !== undefined) updateData.images = images ? JSON.stringify(images) : null
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notes?id=xxx - Delete a note
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
38
memento-note/app/api/upload/route.ts
Normal file
38
memento-note/app/api/upload/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file uploaded' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const filename = `${randomUUID()}${path.extname(file.name)}`
|
||||
|
||||
// Ensure directory exists
|
||||
const uploadDir = path.join(process.cwd(), 'public/uploads/notes')
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
|
||||
const filePath = path.join(uploadDir, filename)
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: `/uploads/notes/${filename}`
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user