mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 12:32:58 +00:00
Compare commits
5 Commits
1e377a01b0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dfdd500b4 | |||
| b539aa1b91 | |||
| 616568ca7b | |||
| 5da6360ed9 | |||
| 67dc5cc737 |
+10
-9
@@ -1,13 +1,14 @@
|
|||||||
# Trackeep Configuration for Casa OS
|
# Trackeep All-in-One Configuration
|
||||||
# Only required variables - everything else is auto-configured
|
# PostgreSQL is bundled inside the container — no external database needed.
|
||||||
|
# Everything below is optional; the container auto-generates sensible defaults.
|
||||||
|
|
||||||
# Host port for the application (default: 8080)
|
# Host port mapping (default: 8080)
|
||||||
HOST_PORT=8080
|
HOST_PORT=8080
|
||||||
|
|
||||||
# Database Configuration
|
# Database credentials (auto-generated if left empty)
|
||||||
DB_PASSWORD=your_secure_password_here
|
# DB_PASSWORD=your_secure_password_here
|
||||||
DB_USER=trackeep
|
# DB_USER=trackeep
|
||||||
DB_NAME=trackeep
|
# DB_NAME=trackeep
|
||||||
|
|
||||||
# JWT Secret (generate with: openssl rand -hex 32)
|
# JWT Secret (auto-generated and persisted in /data if left empty)
|
||||||
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
# JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# Optional repository variables (Settings > Secrets and variables > Actions > Variables).
|
||||||
|
# VITE_API_URL defaults to empty for same-origin relative URLs in unified deployments.
|
||||||
|
build-args: |
|
||||||
|
VITE_API_URL=${{ vars.VITE_API_URL || '' }}
|
||||||
|
VITE_DEMO_MODE=${{ vars.VITE_DEMO_MODE || 'false' }}
|
||||||
|
|
||||||
# deploy:
|
# deploy:
|
||||||
# name: Deploy to Production
|
# name: Deploy to Production
|
||||||
|
|||||||
+17
-5
@@ -4,8 +4,16 @@
|
|||||||
# Stage 1: Build Frontend
|
# Stage 1: Build Frontend
|
||||||
FROM node:22-alpine AS frontend-builder
|
FROM node:22-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Accept build arguments for Vite environment variables.
|
||||||
|
# If unset, the frontend falls back to same-origin relative URLs in production.
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_DEMO_MODE=false
|
||||||
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
|
ENV VITE_DEMO_MODE=${VITE_DEMO_MODE}
|
||||||
|
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci --only=production
|
RUN npm install
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -20,8 +28,12 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
|||||||
# Stage 3: Final unified image
|
# Stage 3: Final unified image
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies including PostgreSQL
|
||||||
RUN apk --no-cache add ca-certificates tzdata nginx
|
RUN apk --no-cache add ca-certificates tzdata nginx postgresql postgresql-contrib
|
||||||
|
|
||||||
|
# Create postgres user directories and fix permissions
|
||||||
|
RUN mkdir -p /var/lib/postgresql/data /run/postgresql /var/log/postgresql && \
|
||||||
|
chown -R postgres:postgres /var/lib/postgresql /run/postgresql /var/log/postgresql
|
||||||
|
|
||||||
# Copy backend binary and migrations
|
# Copy backend binary and migrations
|
||||||
COPY --from=backend-builder /app/backend/main /app/main
|
COPY --from=backend-builder /app/backend/main /app/main
|
||||||
@@ -45,10 +57,10 @@ RUN mkdir -p /app/uploads /data /var/log/nginx
|
|||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
# Start script to run both backend and nginx
|
# Start script to run PostgreSQL, backend and nginx
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -33,34 +33,23 @@
|
|||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### One-Command Deployment (GHCR Image - Recommended for Casa OS)
|
### One-Command Deployment (Docker Run)
|
||||||
|
|
||||||
|
PostgreSQL is bundled inside the image. Zero external dependencies.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name trackeep \
|
--name trackeep \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-e DB_PASSWORD=your_password \
|
-e DB_PASSWORD=your_secure_password \
|
||||||
-e DB_USER=trackeep \
|
-e JWT_SECRET=$(openssl rand -hex 32) \
|
||||||
-e DB_NAME=trackeep \
|
-v trackeep_postgres:/var/lib/postgresql/data \
|
||||||
-e JWT_SECRET=your_jwt_secret \
|
-v trackeep_uploads:/app/uploads \
|
||||||
|
-v trackeep_data:/data \
|
||||||
ghcr.io/dvorinka/trackeep:latest
|
ghcr.io/dvorinka/trackeep:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: This requires an external PostgreSQL database. For a complete deployment with the database included, use Docker Compose below.
|
### CasaOS / Docker Compose (Copy-Paste Ready)
|
||||||
|
|
||||||
### Production Deployment with Docker Compose
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/dvorinka/trackeep.git
|
|
||||||
cd trackeep
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env file with your configuration
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The setup uses a unified Docker image with frontend and backend in a single container.
|
|
||||||
|
|
||||||
**Complete docker-compose.yml**:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
|
icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
|
||||||
@@ -68,78 +57,47 @@ icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
|
|||||||
services:
|
services:
|
||||||
trackeep:
|
trackeep:
|
||||||
image: ghcr.io/dvorinka/trackeep:latest
|
image: ghcr.io/dvorinka/trackeep:latest
|
||||||
|
container_name: trackeep
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-8080}:8080"
|
- "${HOST_PORT:-8080}:8080"
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_PORT=8080
|
DB_PASSWORD: ${DB_PASSWORD:-}
|
||||||
- DB_HOST=postgres
|
DB_USER: ${DB_USER:-trackeep}
|
||||||
- DB_PORT=5432
|
DB_NAME: ${DB_NAME:-trackeep}
|
||||||
- GIN_MODE=release
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
GIN_MODE: release
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- trackeep_postgres:/var/lib/postgresql/data
|
||||||
- ./data:/data
|
- trackeep_uploads:/app/uploads
|
||||||
|
- trackeep_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${DB_NAME:-trackeep}
|
|
||||||
POSTGRES_USER: ${DB_USER:-trackeep}
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
trackeep_postgres:
|
||||||
|
trackeep_uploads:
|
||||||
|
trackeep_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Architecture
|
**Why this is CasaOS-ready:**
|
||||||
|
- **Single service** — PostgreSQL runs inside the same container
|
||||||
|
- **No `BACKEND_PORT`** — internal backend runs on 8081, only port 8080 is exposed
|
||||||
|
- **Named volumes** — CasaOS handles them automatically
|
||||||
|
- **Optional env vars** — if `DB_PASSWORD` or `JWT_SECRET` are empty, the container auto-generates them
|
||||||
|
- **Icon header** — CasaOS reads the `icon:` field for the app tile
|
||||||
|
|
||||||
Trackeep deployment consists of **2 services**:
|
### Optional Environment Variables
|
||||||
|
|
||||||
#### **🎯 Trackeep Service (Unified)**
|
All variables have sensible defaults. Only override what you need:
|
||||||
- **Image**: Built from unified Dockerfile (frontend + backend in one)
|
|
||||||
- **Ports**: `${HOST_PORT:-8080}:8080`
|
|
||||||
- **Purpose**: Web interface, API server, and business logic combined
|
|
||||||
- **Health**: HTTP health check endpoint
|
|
||||||
- **Auto-configuration**: Frontend automatically connects to backend via nginx proxy
|
|
||||||
|
|
||||||
#### **🗄️ Database Service**
|
|
||||||
- **Image**: `postgres:15-alpine`
|
|
||||||
- **Purpose**: Data persistence and storage
|
|
||||||
- **Health**: PostgreSQL readiness check
|
|
||||||
- **Storage**: Persistent volume for data
|
|
||||||
|
|
||||||
### Required Environment Variables
|
|
||||||
|
|
||||||
Create a `.env` file from the provided `.env.example` and configure these required variables:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Host port for the application (default: 8080)
|
|
||||||
HOST_PORT=8080
|
HOST_PORT=8080
|
||||||
|
DB_PASSWORD=your_secure_password_here # auto-generated if empty
|
||||||
# Database Configuration
|
|
||||||
DB_PASSWORD=your_secure_password_here
|
|
||||||
DB_USER=trackeep
|
DB_USER=trackeep
|
||||||
DB_NAME=trackeep
|
DB_NAME=trackeep
|
||||||
|
JWT_SECRET=your_jwt_secret_here # auto-generated & persisted if empty
|
||||||
# JWT Secret (generate with: openssl rand -hex 32)
|
|
||||||
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: The frontend automatically connects to the backend via nginx proxy - no VITE_API_URL or additional configuration needed.
|
**Note:** The frontend automatically connects to the backend via nginx proxy — no `VITE_API_URL` or additional configuration needed.
|
||||||
|
|
||||||
### AI Services Configuration
|
### AI Services Configuration
|
||||||
|
|
||||||
@@ -404,25 +362,15 @@ DISABLE_CHINESE_AI=true
|
|||||||
cd Trackeep
|
cd Trackeep
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure environment**
|
2. **Start the container**
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Start all services**
|
|
||||||
```bash
|
|
||||||
# Using the startup script
|
|
||||||
./start.sh
|
|
||||||
|
|
||||||
# Or manually with Docker Compose
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Access the application**
|
3. **Access the application**
|
||||||
- Application: http://localhost:${HOST_PORT:-8080}
|
- Application: http://localhost:8080
|
||||||
- Health Check: http://localhost:${HOST_PORT:-8080}/health
|
- Health Check: http://localhost:8080/health
|
||||||
- API: http://localhost:${HOST_PORT:-8080}/api/
|
- API: http://localhost:8080/api/
|
||||||
|
|
||||||
### Demo Login
|
### Demo Login
|
||||||
- Email: `demo@trackeep.com`
|
- Email: `demo@trackeep.com`
|
||||||
@@ -489,22 +437,22 @@ Additional documentation files:
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Key environment variables to configure:
|
Only override what you need — everything else auto-configures:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Host port for the application
|
# Host port for the application
|
||||||
HOST_PORT=8080
|
HOST_PORT=8080
|
||||||
|
|
||||||
# Database Configuration
|
# Database credentials (auto-generated if omitted)
|
||||||
DB_PASSWORD=your_secure_password_here
|
DB_PASSWORD=your_secure_password_here
|
||||||
DB_USER=trackeep
|
DB_USER=trackeep
|
||||||
DB_NAME=trackeep
|
DB_NAME=trackeep
|
||||||
|
|
||||||
# JWT Configuration (generate with: openssl rand -hex 32)
|
# JWT Secret (auto-generated & persisted if omitted)
|
||||||
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: All other configuration has sensible defaults. The frontend automatically connects to the backend via nginx proxy - no additional API URL configuration needed.
|
**Note:** All other configuration has sensible defaults. The frontend automatically connects to the backend via nginx proxy — no additional API URL configuration needed.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
+22
-27
@@ -3,38 +3,33 @@ icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
|
|||||||
services:
|
services:
|
||||||
trackeep:
|
trackeep:
|
||||||
image: ghcr.io/dvorinka/trackeep:latest
|
image: ghcr.io/dvorinka/trackeep:latest
|
||||||
|
container_name: trackeep
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-8080}:8080"
|
- "${HOST_PORT:-8080}:8080"
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_PORT=8080
|
DB_PASSWORD: ${DB_PASSWORD:-}
|
||||||
- DB_HOST=postgres
|
DB_USER: ${DB_USER:-trackeep}
|
||||||
- DB_PORT=5432
|
DB_NAME: ${DB_NAME:-trackeep}
|
||||||
- GIN_MODE=release
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
GIN_MODE: release
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*}
|
||||||
|
# VITE_API_URL defaults to empty for same-origin relative URLs.
|
||||||
|
# Set explicitly only when frontend and backend are on different origins.
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
|
VITE_DEMO_MODE: ${VITE_DEMO_MODE:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- trackeep_postgres:/var/lib/postgresql/data
|
||||||
- ./data:/data
|
- trackeep_uploads:/app/uploads
|
||||||
restart: unless-stopped
|
- trackeep_data:/data
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${DB_NAME:-trackeep}
|
|
||||||
POSTGRES_USER: ${DB_USER:-trackeep}
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||||
interval: 10s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 60s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
trackeep_postgres:
|
||||||
|
trackeep_uploads:
|
||||||
|
trackeep_data:
|
||||||
|
|||||||
+90
-15
@@ -1,29 +1,95 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Unified entrypoint for Trackeep
|
# All-in-one entrypoint for Trackeep
|
||||||
# Starts both backend and nginx in one container
|
# Initializes and starts PostgreSQL, then backend + nginx
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Backend configuration
|
PGDATA=${PGDATA:-/var/lib/postgresql/data}
|
||||||
export BACKEND_PORT=${BACKEND_PORT:-8080}
|
|
||||||
export DB_HOST=${DB_HOST:-postgres}
|
# Auto-generate DB_PASSWORD if not provided
|
||||||
export DB_PORT=${DB_PORT:-5432}
|
if [ -z "$DB_PASSWORD" ]; then
|
||||||
export DB_NAME=${DB_NAME:-trackeep}
|
DB_PASSWORD=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 32)
|
||||||
export DB_USER=${DB_USER:-trackeep}
|
echo "========================================"
|
||||||
export DB_PASSWORD=${DB_PASSWORD}
|
echo "WARNING: DB_PASSWORD was not set."
|
||||||
export JWT_SECRET=${JWT_SECRET}
|
echo "Auto-generated password: $DB_PASSWORD"
|
||||||
|
echo "Set DB_PASSWORD explicitly to keep it stable across restarts."
|
||||||
|
echo "========================================"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_USER=${DB_USER:-trackeep}
|
||||||
|
DB_NAME=${DB_NAME:-trackeep}
|
||||||
|
|
||||||
|
# Ensure PostgreSQL directories are owned by postgres (fixes volume permission issues)
|
||||||
|
mkdir -p "$PGDATA" /run/postgresql /var/log/postgresql
|
||||||
|
chown -R postgres:postgres "$PGDATA" /run/postgresql /var/log/postgresql
|
||||||
|
|
||||||
|
# Initialize PostgreSQL if data directory is empty
|
||||||
|
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||||
|
echo "Initializing PostgreSQL database cluster..."
|
||||||
|
su -s /bin/sh postgres -c "initdb -D $PGDATA --auth-local=trust --auth-host=md5"
|
||||||
|
|
||||||
|
# Allow local TCP connections
|
||||||
|
echo "host all all 127.0.0.1/32 md5" >> "$PGDATA/pg_hba.conf"
|
||||||
|
echo "host all all ::1/128 md5" >> "$PGDATA/pg_hba.conf"
|
||||||
|
|
||||||
|
# Start postgres temporarily to create user and database
|
||||||
|
su -s /bin/sh postgres -c "pg_ctl -D $PGDATA -l /var/log/postgresql/server.log start"
|
||||||
|
|
||||||
|
# Wait until postgres accepts connections
|
||||||
|
echo "Waiting for PostgreSQL to accept connections..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if su -s /bin/sh postgres -c "pg_isready -q"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create role and database
|
||||||
|
su -s /bin/sh postgres -c "psql -c \"CREATE USER \\\"$DB_USER\\\" WITH PASSWORD '$DB_PASSWORD';\""
|
||||||
|
su -s /bin/sh postgres -c "psql -c \"CREATE DATABASE \\\"$DB_NAME\\\" OWNER \\\"$DB_USER\\\";\""
|
||||||
|
|
||||||
|
su -s /bin/sh postgres -c "pg_ctl -D $PGDATA stop"
|
||||||
|
echo "PostgreSQL initialized."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start PostgreSQL
|
||||||
|
echo "Starting PostgreSQL..."
|
||||||
|
su -s /bin/sh postgres -c "pg_ctl -D $PGDATA -l /var/log/postgresql/server.log start"
|
||||||
|
|
||||||
|
# Wait for PostgreSQL to be ready
|
||||||
|
echo "Waiting for PostgreSQL to be ready..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if su -s /bin/sh postgres -c "pg_isready -q"; then
|
||||||
|
echo "PostgreSQL is ready."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for PostgreSQL... ($i/30)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Backend connects to the bundled local PostgreSQL
|
||||||
|
export BACKEND_PORT=8081
|
||||||
|
export DB_HOST=localhost
|
||||||
|
export DB_PORT=5432
|
||||||
|
export DB_NAME="$DB_NAME"
|
||||||
|
export DB_USER="$DB_USER"
|
||||||
|
export DB_PASSWORD="$DB_PASSWORD"
|
||||||
|
export DB_SSL_MODE=disable
|
||||||
|
export JWT_SECRET=${JWT_SECRET:-}
|
||||||
export GIN_MODE=${GIN_MODE:-release}
|
export GIN_MODE=${GIN_MODE:-release}
|
||||||
|
export CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-*}
|
||||||
|
|
||||||
# Start backend in background
|
# Start backend in background
|
||||||
cd /app
|
cd /app
|
||||||
echo "Starting Trackeep backend on port ${BACKEND_PORT}..."
|
echo "Starting Trackeep backend on port ${BACKEND_PORT}..."
|
||||||
./main &
|
./main &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# Wait for backend to be ready
|
# Wait for backend to be ready
|
||||||
echo "Waiting for backend to be ready..."
|
echo "Waiting for backend to be ready..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if wget --no-verbose --tries=1 --spider http://localhost:${BACKEND_PORT}/health 2>/dev/null; then
|
if wget --no-verbose --tries=1 --spider http://localhost:8081/health 2>/dev/null; then
|
||||||
echo "Backend is ready!"
|
echo "Backend is ready!"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -31,9 +97,18 @@ for i in $(seq 1 30); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
# Update nginx config to proxy to localhost backend
|
# Runtime environment variable injection for frontend.
|
||||||
sed -i "s|http://trackeep-backend:8080/|http://localhost:${BACKEND_PORT}/|g" /etc/nginx/nginx.conf
|
# The frontend is built with placeholders; at container startup we replace
|
||||||
|
# them so the same image works for any deployment target (Casa, local, etc.).
|
||||||
|
HTML_FILE="/usr/share/nginx/html/index.html"
|
||||||
|
if [ -f "$HTML_FILE" ]; then
|
||||||
|
VITE_API_URL=${VITE_API_URL:-}
|
||||||
|
VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
|
||||||
|
sed -i "s|VITE_API_URL_PLACEHOLDER|$VITE_API_URL|g" "$HTML_FILE"
|
||||||
|
sed -i "s|VITE_DEMO_MODE_PLACEHOLDER|$VITE_DEMO_MODE|g" "$HTML_FILE"
|
||||||
|
echo "Frontend env injected: VITE_API_URL='$VITE_API_URL', VITE_DEMO_MODE='$VITE_DEMO_MODE'"
|
||||||
|
fi
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx in foreground (keeps container alive)
|
||||||
echo "Starting nginx..."
|
echo "Starting nginx on port 8080..."
|
||||||
nginx -g "daemon off;"
|
nginx -g "daemon off;"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<meta name="theme-color" content="#39b9ff" />
|
<meta name="theme-color" content="#39b9ff" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Trackeep - Your Self-Hosted Productivity & Knowledge Hub</title>
|
<title>Trackeep - Your Self-Hosted Productivity & Knowledge Hub</title>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-LnCEqXC_.css">
|
|
||||||
<script>
|
<script>
|
||||||
// Runtime environment variable injection
|
// Runtime environment variable injection
|
||||||
window.ENV = {
|
window.ENV = {
|
||||||
|
|||||||
+28
-2
@@ -39,7 +39,8 @@ http {
|
|||||||
image/svg+xml;
|
image/svg+xml;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
@@ -50,14 +51,39 @@ http {
|
|||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Explicit root files (prevent SPA fallback)
|
||||||
|
location = /manifest.json {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /trackeep.svg {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /trackeepfavi_bg.png {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /trackeepfavi.png {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
# Handle client-side routing
|
# Handle client-side routing
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Health check proxy to backend
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://localhost:8081/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
# API proxy to backend (internal localhost)
|
# API proxy to backend (internal localhost)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://localhost:8080/;
|
proxy_pass http://localhost:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, onMount, Show, For } from 'solid-js';
|
import { createSignal, onMount, Show, For } from 'solid-js';
|
||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
|
import { getApiOrigin } from '@/lib/api-url';
|
||||||
|
|
||||||
interface TOTPSetupResponse {
|
interface TOTPSetupResponse {
|
||||||
secret: string;
|
secret: string;
|
||||||
@@ -42,7 +43,7 @@ export function TwoFactorAuth() {
|
|||||||
|
|
||||||
const fetchTOTPStatus = async () => {
|
const fetchTOTPStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/status`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/2fa/status`, {
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export function TwoFactorAuth() {
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/setup`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/2fa/setup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -102,7 +103,7 @@ export function TwoFactorAuth() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/verify`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/2fa/verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -135,7 +136,7 @@ export function TwoFactorAuth() {
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/enable`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/2fa/enable`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -171,7 +172,7 @@ export function TwoFactorAuth() {
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/disable`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/2fa/disable`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -206,7 +207,7 @@ export function TwoFactorAuth() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/backup-codes/verify`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/2fa/backup-codes/verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -240,7 +241,7 @@ export function TwoFactorAuth() {
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/backup-codes/regenerate`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/2fa/backup-codes/regenerate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -182,10 +182,10 @@ export function Layout(props: LayoutProps) {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div class="flex-1 min-h-0 flex flex-col">
|
<div class="flex-1 min-h-0 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header title={props.title} onMenuClick={toggleSidebar} />
|
{!props.fullBleed && <Header title={props.title} onMenuClick={toggleSidebar} />}
|
||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<main class="flex-1 overflow-auto max-w-screen">
|
<main class={`flex-1 ${props.fullBleed ? 'overflow-hidden' : 'overflow-auto w-full'}`}>
|
||||||
<div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}>
|
<div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}>
|
||||||
{resolved()}
|
{resolved()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, For, Show, onMount } from 'solid-js';
|
import { createSignal, For, Show, onMount } from 'solid-js';
|
||||||
|
import { getApiOrigin } from '@/lib/api-url';
|
||||||
import { useSearchParams } from '@solidjs/router';
|
import { useSearchParams } from '@solidjs/router';
|
||||||
import {
|
import {
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@@ -118,7 +119,7 @@ export const EnhancedSearch = () => {
|
|||||||
|
|
||||||
if (currentFilters.search_mode === 'semantic') {
|
if (currentFilters.search_mode === 'semantic') {
|
||||||
// Use semantic search API
|
// Use semantic search API
|
||||||
response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/semantic`, {
|
response = await fetch(`${getApiOrigin()}/api/v1/search/semantic`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -145,7 +146,7 @@ export const EnhancedSearch = () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use enhanced full-text search API
|
// Use enhanced full-text search API
|
||||||
response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/enhanced`, {
|
response = await fetch(`${getApiOrigin()}/api/v1/search/enhanced`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, For, Show, onMount } from 'solid-js';
|
import { createSignal, For, Show, onMount } from 'solid-js';
|
||||||
|
import { getApiOrigin } from '@/lib/api-url';
|
||||||
import {
|
import {
|
||||||
IconBookmark,
|
IconBookmark,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@@ -61,7 +62,7 @@ export const SavedSearches = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/search/saved`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ export const SavedSearches = () => {
|
|||||||
const loadTags = async () => {
|
const loadTags = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved/tags`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/search/saved/tags`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,7 @@ export const SavedSearches = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved/${id}`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/search/saved/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
@@ -160,7 +161,7 @@ export const SavedSearches = () => {
|
|||||||
const runSavedSearch = async (id: number) => {
|
const runSavedSearch = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved/${id}/run`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/search/saved/${id}/run`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
|
|||||||
Vendored
+4
@@ -49,6 +49,10 @@ interface ImportMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
ENV?: {
|
||||||
|
VITE_API_URL?: string;
|
||||||
|
VITE_DEMO_MODE?: string;
|
||||||
|
};
|
||||||
importMetaEnv?: {
|
importMetaEnv?: {
|
||||||
VITE_API_URL?: string;
|
VITE_API_URL?: string;
|
||||||
VITE_DEMO_MODE?: string;
|
VITE_DEMO_MODE?: string;
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Centralized API URL resolver.
|
||||||
|
*
|
||||||
|
* Problem: Vite bakes import.meta.env values at build time. When the unified
|
||||||
|
* Docker image is built without VITE_API_URL, every API call fell back to
|
||||||
|
* 'http://localhost:8080', which broke production deployments (e.g. Casa).
|
||||||
|
*
|
||||||
|
* Solution: This helper checks the runtime-injected window.ENV first
|
||||||
|
* (set by docker-entrypoint.sh via sed replacement in index.html), then
|
||||||
|
* build-time import.meta.env, then dev fallback. In production unified
|
||||||
|
* deployments (same origin) it returns '' so all API calls use relative
|
||||||
|
* URLs like '/api/v1/...' that nginx proxies to the backend.
|
||||||
|
*/
|
||||||
|
|
||||||
const DEFAULT_API_ORIGIN = 'http://localhost:8080';
|
const DEFAULT_API_ORIGIN = 'http://localhost:8080';
|
||||||
|
|
||||||
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
||||||
@@ -5,16 +19,30 @@ const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
|||||||
const trimApiSuffix = (value: string): string => value.replace(/\/api\/v1$/, '');
|
const trimApiSuffix = (value: string): string => value.replace(/\/api\/v1$/, '');
|
||||||
|
|
||||||
export const getApiOrigin = (): string => {
|
export const getApiOrigin = (): string => {
|
||||||
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim();
|
// 1. Runtime injection from index.html (highest priority for Docker deployments)
|
||||||
if (!raw) {
|
const runtimeUrl = ((window as any).ENV?.VITE_API_URL as string | undefined)?.trim();
|
||||||
|
if (runtimeUrl && runtimeUrl !== 'VITE_API_URL_PLACEHOLDER') {
|
||||||
|
const normalized = trimTrailingSlash(runtimeUrl);
|
||||||
|
return trimApiSuffix(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build-time Vite env variable (for dev builds or pre-built images)
|
||||||
|
const buildUrl = (import.meta.env.VITE_API_URL as string | undefined)?.trim();
|
||||||
|
if (buildUrl) {
|
||||||
|
const normalized = trimTrailingSlash(buildUrl);
|
||||||
|
return trimApiSuffix(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Development fallback
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
return DEFAULT_API_ORIGIN;
|
return DEFAULT_API_ORIGIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = trimTrailingSlash(raw);
|
// 4. Production unified deployment: same-origin relative URLs
|
||||||
return trimApiSuffix(normalized);
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getApiV1BaseUrl = (): string => {
|
export const getApiV1BaseUrl = (): string => {
|
||||||
const origin = getApiOrigin();
|
const origin = getApiOrigin();
|
||||||
return `${origin}/api/v1`;
|
return origin ? `${origin}/api/v1` : '/api/v1';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export const getSearchProvider = (): string => {
|
|||||||
import.meta.env.VITE_SERPER_API_KEY ? 'serper' : 'demo');
|
import.meta.env.VITE_SERPER_API_KEY ? 'serper' : 'demo');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get API base URL
|
// Delegates to getApiOrigin so all API URL resolution goes through the
|
||||||
|
// centralized helper that supports runtime env injection.
|
||||||
|
import { getApiOrigin } from './api-url';
|
||||||
|
|
||||||
export const getApiBaseUrl = (): string => {
|
export const getApiBaseUrl = (): string => {
|
||||||
return import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
return getApiOrigin();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getMockStats
|
getMockStats
|
||||||
} from './mockData';
|
} from './mockData';
|
||||||
import { isDemoMode } from './demo-mode';
|
import { isDemoMode } from './demo-mode';
|
||||||
|
import { getApiV1BaseUrl } from './api-url';
|
||||||
|
|
||||||
// Demo mode API client that falls back to mock data
|
// Demo mode API client that falls back to mock data
|
||||||
export class DemoModeApiClient {
|
export class DemoModeApiClient {
|
||||||
@@ -280,8 +281,8 @@ export class DemoModeApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create demo mode API client
|
// Uses getApiV1BaseUrl so demo client respects runtime env injection.
|
||||||
const demoApi = new DemoModeApiClient(import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1');
|
const demoApi = new DemoModeApiClient(getApiV1BaseUrl());
|
||||||
|
|
||||||
// Export demo mode API functions that match the regular API
|
// Export demo mode API functions that match the regular API
|
||||||
export const demoBookmarksApi = {
|
export const demoBookmarksApi = {
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ export interface WsEvent {
|
|||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
// Switched from raw import.meta.env to getApiOrigin for runtime env support.
|
||||||
|
import { getApiOrigin } from './api-url';
|
||||||
|
|
||||||
|
const API_BASE_URL = getApiOrigin();
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
IconClock,
|
IconClock,
|
||||||
IconChecklist
|
IconChecklist
|
||||||
} from '@tabler/icons-solidjs';
|
} from '@tabler/icons-solidjs';
|
||||||
import { ColorSwitcher } from './ColorSwitcher';
|
import { ColorSwitcher } from '@/pages/settings/ColorSwitcher';
|
||||||
import { useHaptics } from '@/lib/haptics';
|
import { useHaptics } from '@/lib/haptics';
|
||||||
|
|
||||||
interface ProjectStats {
|
interface ProjectStats {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useHaptics } from '@/lib/haptics';
|
import { useHaptics } from '@/lib/haptics';
|
||||||
|
import { getApiOrigin } from '@/lib/api-url';
|
||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
period: {
|
period: {
|
||||||
@@ -183,7 +184,7 @@ export const Analytics = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/analytics/dashboard?days=${selectedPeriod()}`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/analytics/dashboard?days=${selectedPeriod()}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||||
import { useHaptics } from '@/lib/haptics'
|
import { useHaptics } from '@/lib/haptics'
|
||||||
|
import { getApiOrigin } from '@/lib/api-url'
|
||||||
|
|
||||||
interface AIModel {
|
interface AIModel {
|
||||||
id: string
|
id: string
|
||||||
@@ -133,7 +134,7 @@ export const AIChat = () => {
|
|||||||
|
|
||||||
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
|
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
const apiUrl = getApiOrigin()
|
||||||
|
|
||||||
const response = await fetch(`${apiUrl}/api/v1/ai/chat`, {
|
const response = await fetch(`${apiUrl}/api/v1/ai/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createEffect, createResource, createSignal, For, Show, onMount } from 'solid-js'
|
import { createEffect, createResource, createSignal, For, Show, onMount } from 'solid-js'
|
||||||
|
import { getApiOrigin } from '@/lib/api-url'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Card } from '@/components/ui/Card'
|
import { Card } from '@/components/ui/Card'
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
FileText as FileTextIcon,
|
FileText as FileTextIcon,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings,
|
|
||||||
Trash,
|
Trash,
|
||||||
User
|
User
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
@@ -65,7 +65,7 @@ const Chat = () => {
|
|||||||
const loadAIProviders = async () => {
|
const loadAIProviders = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/ai/providers`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/ai/providers`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -84,7 +84,7 @@ const Chat = () => {
|
|||||||
const loadAISettings = async () => {
|
const loadAISettings = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/ai/settings`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -176,7 +176,7 @@ const Chat = () => {
|
|||||||
const fetchSessions = async () => {
|
const fetchSessions = async () => {
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/chat/sessions`, {
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
@@ -240,7 +240,7 @@ const Chat = () => {
|
|||||||
const loadSessionMessages = async (sessionId: string) => {
|
const loadSessionMessages = async (sessionId: string) => {
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${sessionId}/messages`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/chat/sessions/${sessionId}/messages`, {
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
@@ -341,7 +341,7 @@ const Chat = () => {
|
|||||||
payload.session_id = currentSessionId()
|
payload.session_id = currentSessionId()
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/send`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/chat/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -386,109 +386,8 @@ const Chat = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="mt-4 pb-32 max-w-7xl mx-auto">
|
<div class="mt-4 pb-32 max-w-7xl mx-auto flex flex-col md:flex-row gap-4">
|
||||||
<div class="bg-background rounded-lg border shadow-sm">
|
<div class="w-72 flex-shrink-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
|
||||||
{/* Header with Model Selection */}
|
|
||||||
<div class="p-6 border-b bg-card/95 backdrop-blur-sm">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-lg">AI Assistant</h2>
|
|
||||||
<p class="text-sm text-muted-foreground">Your intelligent workspace companion</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveView('chat')}
|
|
||||||
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
activeView() === 'chat'
|
|
||||||
? 'bg-background shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Chat
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveView('ai-tools')}
|
|
||||||
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
activeView() === 'ai-tools'
|
|
||||||
? 'bg-background shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
AI Tools
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings Button */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSettings(!showSettings())}
|
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted rounded-lg text-sm transition-colors"
|
|
||||||
>
|
|
||||||
<Settings class="h-4 w-4" />
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Enhanced AI Model Picker */}
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg text-sm transition-colors"
|
|
||||||
>
|
|
||||||
<span>{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}</span>
|
|
||||||
<ChevronDown class={`h-4 w-4 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Show when={showModelPicker()}>
|
|
||||||
<div class="absolute right-0 mt-2 w-80 bg-background border rounded-lg shadow-lg z-50 p-2 max-h-96 overflow-y-auto">
|
|
||||||
<div class="p-2 border-b mb-2">
|
|
||||||
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
|
|
||||||
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
|
|
||||||
</div>
|
|
||||||
<For each={getAIModels()}>
|
|
||||||
{model => (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedModel(model.id)
|
|
||||||
setShowModelPicker(false)
|
|
||||||
}}
|
|
||||||
class={`w-full text-left p-3 rounded-lg transition-colors ${
|
|
||||||
selectedModel() === model.id
|
|
||||||
? 'bg-primary/10 border border-primary/20'
|
|
||||||
: 'hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-medium text-sm">{model.name}</div>
|
|
||||||
<div class="text-xs text-muted-foreground mt-1">{model.description}</div>
|
|
||||||
<div class="flex items-center gap-2 mt-2">
|
|
||||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
|
||||||
{model.provider}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs px-2 py-1 bg-muted text-muted-foreground rounded-full">
|
|
||||||
{model.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedModel() === model.id && (
|
|
||||||
<div class="w-2 h-2 bg-primary rounded-full"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={showSettings()}>
|
<Show when={showSettings()}>
|
||||||
<div class="p-6 border-b bg-muted/30">
|
<div class="p-6 border-b bg-muted/30">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -708,7 +607,7 @@ const Chat = () => {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${session.id}`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/chat/sessions/${session.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
@@ -741,7 +640,7 @@ const Chat = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Area */}
|
{/* Chat Area */}
|
||||||
<div class="flex-1 flex flex-col min-w-0 ml-80">
|
<div class="flex-1 flex flex-col min-w-0 bg-background rounded-lg border shadow-sm overflow-hidden">
|
||||||
<div class="hidden md:flex items-center justify-between p-6 border-b bg-card/95 backdrop-blur-sm">
|
<div class="hidden md:flex items-center justify-between p-6 border-b bg-card/95 backdrop-blur-sm">
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -811,9 +710,9 @@ const Chat = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div class="flex flex-col">
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
<Show when={activeView() === 'chat'}>
|
<Show when={activeView() === 'chat'}>
|
||||||
<div class="flex-1 overflow-y-auto h-[calc(100vh-320px)]">
|
<div class="flex-1 overflow-y-auto min-h-0">
|
||||||
<div class="space-y-6 max-w-5xl mx-auto p-6">
|
<div class="space-y-6 max-w-5xl mx-auto p-6">
|
||||||
<For each={messages()}>
|
<For each={messages()}>
|
||||||
{message => (
|
{message => (
|
||||||
@@ -1111,7 +1010,6 @@ const Chat = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="clear-both"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -606,13 +606,16 @@
|
|||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.messages-shell {
|
.messages-shell {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-sidebar,
|
.messages-sidebar,
|
||||||
.messages-main {
|
.messages-main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-shell-list .messages-main {
|
.messages-shell-list .messages-main {
|
||||||
@@ -622,6 +625,10 @@
|
|||||||
.messages-shell-conversation .messages-sidebar {
|
.messages-shell-conversation .messages-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messages-composer {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-composer-drag {
|
.messages-composer-drag {
|
||||||
@@ -830,9 +837,9 @@
|
|||||||
|
|
||||||
/* Responsive design */
|
/* Responsive design */
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.messages-sidebar,
|
.messages-sidebar {
|
||||||
.messages-main {
|
width: 16rem;
|
||||||
width: 100%;
|
min-width: 16rem;
|
||||||
border-inline: none;
|
border-inline: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
@@ -848,11 +855,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.messages-composer-row {
|
.messages-composer-row {
|
||||||
grid-template-columns: auto auto 1fr;
|
grid-template-columns: repeat(3, auto) 1fr auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-composer-row > button:last-child {
|
.messages-composer-row > button:last-child {
|
||||||
grid-column: 3;
|
grid-column: 5;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, For, Show, onCleanup, onMount } from 'solid-js';
|
import { createSignal, For, Show, onCleanup, onMount } from 'solid-js';
|
||||||
|
import { getApiOrigin } from '@/lib/api-url';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { toast } from '@/components/ui/Toast';
|
import { toast } from '@/components/ui/Toast';
|
||||||
@@ -973,7 +974,7 @@ export const Messages = () => {
|
|||||||
kind: 'voice_note',
|
kind: 'voice_note',
|
||||||
file_id: uploaded.id,
|
file_id: uploaded.id,
|
||||||
title: uploaded.original_name || 'Voice note',
|
title: uploaded.original_name || 'Voice note',
|
||||||
url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${uploaded.id}/download`,
|
url: `${getApiOrigin()}/api/v1/files/${uploaded.id}/download`,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const transcript = `${voiceFinalTranscript} ${voiceInterimTranscript}`.trim();
|
const transcript = `${voiceFinalTranscript} ${voiceInterimTranscript}`.trim();
|
||||||
@@ -1366,7 +1367,7 @@ export const Messages = () => {
|
|||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/members?limit=200`, {
|
const res = await fetch(`${getApiOrigin()}/api/v1/members?limit=200`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -1385,7 +1386,7 @@ export const Messages = () => {
|
|||||||
const loadTeams = async () => {
|
const loadTeams = async () => {
|
||||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/teams?limit=200`, {
|
const res = await fetch(`${getApiOrigin()}/api/v1/teams?limit=200`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -1403,7 +1404,7 @@ export const Messages = () => {
|
|||||||
const loadAIProviders = async () => {
|
const loadAIProviders = async () => {
|
||||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/ai/providers`, {
|
const res = await fetch(`${getApiOrigin()}/api/v1/ai/providers`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -1424,7 +1425,7 @@ export const Messages = () => {
|
|||||||
const loadAISettings = async () => {
|
const loadAISettings = async () => {
|
||||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
const res = await fetch(`${getApiOrigin()}/api/v1/auth/ai/settings`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -1454,7 +1455,7 @@ export const Messages = () => {
|
|||||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||||
setAiShareLoadingSessions(true);
|
setAiShareLoadingSessions(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`, {
|
const res = await fetch(`${getApiOrigin()}/api/v1/chat/sessions`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1486,7 +1487,7 @@ export const Messages = () => {
|
|||||||
if (aiShareMessagesBySession()[sessionId]) return;
|
if (aiShareMessagesBySession()[sessionId]) return;
|
||||||
setAiShareLoadingMessages(true);
|
setAiShareLoadingMessages(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${sessionId}/messages`, {
|
const res = await fetch(`${getApiOrigin()}/api/v1/chat/sessions/${sessionId}/messages`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1768,6 +1769,7 @@ export const Messages = () => {
|
|||||||
if (!selectedConversationId()) return;
|
if (!selectedConversationId()) return;
|
||||||
const body = inputText().trim();
|
const body = inputText().trim();
|
||||||
if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) return;
|
if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) return;
|
||||||
|
if (sendingMessage()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const localFiles = [...selectedFiles()];
|
const localFiles = [...selectedFiles()];
|
||||||
@@ -1785,7 +1787,7 @@ export const Messages = () => {
|
|||||||
kind: uploaded.mime_type?.startsWith('image/') ? 'image' : 'file',
|
kind: uploaded.mime_type?.startsWith('image/') ? 'image' : 'file',
|
||||||
file_id: uploaded.id,
|
file_id: uploaded.id,
|
||||||
title: uploaded.original_name,
|
title: uploaded.original_name,
|
||||||
url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${uploaded.id}/download`,
|
url: `${getApiOrigin()}/api/v1/files/${uploaded.id}/download`,
|
||||||
});
|
});
|
||||||
setUploadProgress({ done: i + 1, total: localFiles.length });
|
setUploadProgress({ done: i + 1, total: localFiles.length });
|
||||||
}
|
}
|
||||||
@@ -1795,7 +1797,7 @@ export const Messages = () => {
|
|||||||
kind: file.mime_type?.startsWith('image/') ? 'image' : 'file',
|
kind: file.mime_type?.startsWith('image/') ? 'image' : 'file',
|
||||||
file_id: file.id,
|
file_id: file.id,
|
||||||
title: file.original_name,
|
title: file.original_name,
|
||||||
url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${file.id}/download`,
|
url: `${getApiOrigin()}/api/v1/files/${file.id}/download`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2751,6 +2753,7 @@ export const Messages = () => {
|
|||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
sendingMessage() ||
|
sendingMessage() ||
|
||||||
|
uploadProgress() !== null ||
|
||||||
(!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0)
|
(!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ export const Bookmarks = () => {
|
|||||||
|
|
||||||
const handleAddBookmark = async (bookmarkData: any) => {
|
const handleAddBookmark = async (bookmarkData: any) => {
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
|
||||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -271,7 +270,6 @@ export const Bookmarks = () => {
|
|||||||
const deleteBookmark = async (bookmarkId: number) => {
|
const deleteBookmark = async (bookmarkId: number) => {
|
||||||
if (confirm('Are you sure you want to delete this bookmark?')) {
|
if (confirm('Are you sure you want to delete this bookmark?')) {
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
|
||||||
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
|
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -322,7 +320,6 @@ export const Bookmarks = () => {
|
|||||||
if (!editingBookmark()) return;
|
if (!editingBookmark()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
|
||||||
const response = await fetch(`${API_BASE_URL}/bookmarks/${editingBookmark()!.id}`, {
|
const response = await fetch(`${API_BASE_URL}/bookmarks/${editingBookmark()!.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -482,114 +479,122 @@ export const Bookmarks = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="space-y-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{filteredBookmarks().map((bookmark) => {
|
{filteredBookmarks().map((bookmark) => {
|
||||||
const faviconUrl = getFaviconUrl(bookmark);
|
const faviconUrl = getFaviconUrl(bookmark);
|
||||||
const screenshotUrl = getScreenshotUrl(bookmark);
|
const screenshotUrl = getScreenshotUrl(bookmark);
|
||||||
return (
|
return (
|
||||||
<Card class="p-6 hover:bg-accent transition-colors group">
|
<Card class="p-4 hover:bg-accent/50 transition-colors group flex flex-col h-full">
|
||||||
<div class="flex justify-between items-start gap-4">
|
{screenshotUrl && (
|
||||||
{/* Left side: preview image + favicon + title + URL + tags */}
|
<div class="mb-3 rounded-lg overflow-hidden border border-border/50 bg-muted/30 -mx-4 -mt-4">
|
||||||
<div class="flex-1 min-w-0">
|
<a href={bookmark.url} target="_blank" rel="noopener noreferrer">
|
||||||
{screenshotUrl && (
|
<img
|
||||||
<div class="mb-3 rounded-md overflow-hidden border border-border bg-muted/40">
|
src={screenshotUrl}
|
||||||
<img
|
alt="Website preview"
|
||||||
src={screenshotUrl}
|
class="w-full h-28 object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
alt="Website preview"
|
loading="lazy"
|
||||||
class="w-full h-32 sm:h-40 object-cover"
|
onError={(e) => {
|
||||||
loading="lazy"
|
e.currentTarget.style.display = 'none';
|
||||||
onError={(e) => {
|
}}
|
||||||
e.currentTarget.style.display = 'none';
|
/>
|
||||||
}}
|
</a>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
<div class="flex items-start gap-3 mb-3">
|
||||||
|
<div class="flex-shrink-0 w-9 h-9 bg-muted rounded-lg flex items-center justify-center overflow-hidden border border-border/50">
|
||||||
|
{faviconUrl ? (
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
class="w-5 h-5 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
img.style.display = 'none';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'text-xs text-muted-foreground font-bold';
|
||||||
|
span.textContent = getBookmarkInitial(bookmark.title);
|
||||||
|
img.parentElement!.appendChild(span);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span class="text-xs text-muted-foreground font-bold">
|
||||||
|
{getBookmarkInitial(bookmark.title)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<div class="flex items-center gap-3 mb-2">
|
</div>
|
||||||
<div class="flex-shrink-0 w-8 h-8 bg-muted rounded-md flex items-center justify-center overflow-hidden">
|
<div class="flex-1 min-w-0">
|
||||||
{faviconUrl ? (
|
<h3 class="text-sm font-semibold text-foreground leading-tight">
|
||||||
<img
|
<a
|
||||||
src={faviconUrl}
|
href={bookmark.url}
|
||||||
alt=""
|
target="_blank"
|
||||||
class="w-6 h-6 object-contain"
|
rel="noopener noreferrer"
|
||||||
onError={(e) => {
|
class="text-foreground hover:text-primary transition-colors"
|
||||||
const img = e.currentTarget;
|
|
||||||
img.style.display = 'none';
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = 'text-xs text-muted-foreground font-medium';
|
|
||||||
span.textContent = getBookmarkInitial(bookmark.title);
|
|
||||||
img.parentElement!.appendChild(span);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span class="text-xs text-muted-foreground font-medium">
|
|
||||||
{getBookmarkInitial(bookmark.title)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="text-lg font-semibold text-foreground truncate">
|
|
||||||
<a
|
|
||||||
href={bookmark.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{bookmark.title}
|
|
||||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current group-hover:text-white" />
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<p class="text-muted-foreground text-sm truncate">{bookmark.url}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{bookmark.description && (
|
|
||||||
<p class="text-foreground text-sm mb-3 line-clamp-2">{bookmark.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mt-1">
|
|
||||||
{(bookmark.tags || []).map((tag) => (
|
|
||||||
<button
|
|
||||||
onClick={() => handleTagClick(tag)}
|
|
||||||
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
|
|
||||||
${selectedTag() === tag
|
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
|
||||||
: 'bg-muted/80 text-muted-foreground border-transparent group-hover:bg-accent group-hover:text-accent-foreground group-hover:border-border'
|
|
||||||
}`}
|
|
||||||
title={`Click to filter by ${tag}`}
|
|
||||||
>
|
>
|
||||||
{tag}
|
{bookmark.title}
|
||||||
</button>
|
</a>
|
||||||
))}
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-xs truncate mt-0.5">{bookmark.url}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: optional date above important star + menu */}
|
{bookmark.description && (
|
||||||
<div class="flex flex-col items-end gap-2 ml-2">
|
<p class="text-foreground/80 text-xs mb-3 line-clamp-2 flex-grow">{bookmark.description}</p>
|
||||||
{bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) && (
|
)}
|
||||||
<div class="text-muted-foreground text-xs">
|
|
||||||
{new Date(bookmark.created_at).toLocaleDateString()}
|
<div class="flex flex-wrap gap-1.5 mt-auto">
|
||||||
</div>
|
{(bookmark.tags || []).slice(0, 4).map((tag) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
|
class={`px-2 py-0.5 text-[10px] rounded-md border transition-colors cursor-pointer
|
||||||
|
${selectedTag() === tag
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-muted/60 text-muted-foreground border-transparent hover:bg-accent hover:text-accent-foreground'
|
||||||
|
}`}
|
||||||
|
title={`Click to filter by ${tag}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{(bookmark.tags || []).length > 4 && (
|
||||||
|
<span class="px-2 py-0.5 text-[10px] text-muted-foreground">+{(bookmark.tags || []).length - 4}</span>
|
||||||
)}
|
)}
|
||||||
<div class="flex items-center gap-2">
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-3 pt-3 border-t border-border/50">
|
||||||
|
{bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) ? (
|
||||||
|
<span class="text-muted-foreground text-[10px]">
|
||||||
|
{new Date(bookmark.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleImportant(bookmark.id)}
|
onClick={() => toggleImportant(bookmark.id)}
|
||||||
class={`flex-shrink-0 p-1 rounded hover:bg-accent/50 transition-colors ${
|
class="p-1.5 rounded-md hover:bg-accent transition-colors"
|
||||||
bookmark.isImportant ? 'order-first' : ''
|
|
||||||
}`}
|
|
||||||
title={bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
|
title={bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
|
||||||
>
|
>
|
||||||
<IconStar
|
<IconStar
|
||||||
class={`size-4 ${
|
class={`size-3.5 ${
|
||||||
bookmark.isImportant
|
bookmark.isImportant
|
||||||
? 'text-primary fill-primary'
|
? 'text-primary fill-primary'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
href={bookmark.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
title="Open in new tab"
|
||||||
|
>
|
||||||
|
<IconExternalLink class="size-3.5" />
|
||||||
|
</a>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
trigger={
|
trigger={
|
||||||
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8">
|
<button class="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground">
|
||||||
<IconDotsVertical class="size-4" />
|
<IconDotsVertical class="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -612,17 +617,18 @@ export const Bookmarks = () => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{filteredBookmarks().length === 0 && (
|
{filteredBookmarks().length === 0 && (
|
||||||
<Card class="p-12 text-center">
|
<div class="col-span-full">
|
||||||
<p class="text-muted-foreground">
|
<Card class="p-12 text-center">
|
||||||
{searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'}
|
<p class="text-muted-foreground">
|
||||||
</p>
|
{searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'}
|
||||||
</Card>
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
|||||||
import { NoteModal } from '@/components/ui/NoteModal';
|
import { NoteModal } from '@/components/ui/NoteModal';
|
||||||
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
|
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
|
||||||
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
|
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
|
||||||
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
import { IconPin, IconTrash, IconEdit, IconCopy } from '@tabler/icons-solidjs';
|
||||||
import { getMockNotes } from '@/lib/mockData';
|
import { getMockNotes } from '@/lib/mockData';
|
||||||
import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
|
import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
|
||||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||||
@@ -103,7 +103,6 @@ export const Notes = () => {
|
|||||||
const [editingNote, setEditingNote] = createSignal<Note | null>(null);
|
const [editingNote, setEditingNote] = createSignal<Note | null>(null);
|
||||||
const [viewingNote, setViewingNote] = createSignal<Note | null>(null);
|
const [viewingNote, setViewingNote] = createSignal<Note | null>(null);
|
||||||
const [copiedContent, setCopiedContent] = createSignal(false);
|
const [copiedContent, setCopiedContent] = createSignal(false);
|
||||||
const [expandedNotes, setExpandedNotes] = createSignal<Set<number>>(new Set());
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -400,18 +399,6 @@ export const Notes = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleNoteExpansion = (noteId: number) => {
|
|
||||||
setExpandedNotes(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(noteId)) {
|
|
||||||
newSet.delete(noteId);
|
|
||||||
} else {
|
|
||||||
newSet.add(noteId);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportNote = (note: Note) => {
|
const exportNote = (note: Note) => {
|
||||||
const content = note.isMarkdown ? `# ${note.title}\n\n${note.content}` : note.content;
|
const content = note.isMarkdown ? `# ${note.title}\n\n${note.content}` : note.content;
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
@@ -527,21 +514,6 @@ export const Notes = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
||||||
<Card class="p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Total Notes</p>
|
|
||||||
<p class="text-xl font-semibold text-foreground">{filteredNotes().length}</p>
|
|
||||||
</Card>
|
|
||||||
<Card class="p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Pinned</p>
|
|
||||||
<p class="text-xl font-semibold text-foreground">{filteredNotes().filter((note) => note.pinned).length}</p>
|
|
||||||
</Card>
|
|
||||||
<Card class="p-4">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Tags</p>
|
|
||||||
<p class="text-xl font-semibold text-foreground">{allTags().length}</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={loadError()}>
|
<Show when={loadError()}>
|
||||||
<Card class="border-destructive/30 bg-destructive/5 p-4">
|
<Card class="border-destructive/30 bg-destructive/5 p-4">
|
||||||
<p class="text-sm font-medium text-foreground">Notes could not be loaded</p>
|
<p class="text-sm font-medium text-foreground">Notes could not be loaded</p>
|
||||||
@@ -555,170 +527,117 @@ export const Notes = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={isLoading()}>
|
{isLoading() ? (
|
||||||
<div class="space-y-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{[...Array(3)].map(() => (
|
{[...Array(6)].map(() => (
|
||||||
<Card class="p-6">
|
<Card class="p-5 h-40">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse space-y-3">
|
||||||
<div class="h-6 bg-muted rounded mb-2"></div>
|
<div class="h-5 bg-muted rounded w-2/3"></div>
|
||||||
<div class="h-4 bg-muted rounded w-3/4"></div>
|
<div class="h-3 bg-muted rounded w-full"></div>
|
||||||
|
<div class="h-3 bg-muted rounded w-4/5"></div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
) : (
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<Show when={!isLoading()}>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<For each={filteredNotes()}>
|
<For each={filteredNotes()}>
|
||||||
{(note) => (
|
{(note) => (
|
||||||
<Card
|
<div
|
||||||
data-note-id={note.id}
|
class={`group relative bg-card rounded-xl border border-border p-5 cursor-pointer hover:shadow-lg hover:border-primary/20 transition-all ${note.pinned ? 'ring-1 ring-primary/20' : ''}`}
|
||||||
class={`p-6 cursor-pointer transition-colors hover:bg-accent/50 ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
|
|
||||||
onClick={() => viewNote(note)}
|
onClick={() => viewNote(note)}
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-start mb-3 gap-3">
|
<Show when={note.pinned}>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="absolute top-3 right-3">
|
||||||
<h3 class="text-lg font-semibold text-foreground truncate">{note.title}</h3>
|
<IconPin class="size-3.5 text-primary" />
|
||||||
<Show when={note.pinned}>
|
|
||||||
<IconPin class="size-4 text-primary" />
|
|
||||||
</Show>
|
|
||||||
<Show when={note.isMarkdown}>
|
|
||||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={note.isHtml}>
|
|
||||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
copyNoteContent(note);
|
|
||||||
}}
|
|
||||||
class="text-muted-foreground hover:text-foreground p-1"
|
|
||||||
>
|
|
||||||
<IconCopy size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
exportNote(note);
|
|
||||||
}}
|
|
||||||
class="text-muted-foreground hover:text-foreground p-1"
|
|
||||||
>
|
|
||||||
<IconDownload size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
startEditNote(note);
|
|
||||||
}}
|
|
||||||
class="text-muted-foreground hover:text-foreground p-1"
|
|
||||||
>
|
|
||||||
<IconEdit size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
togglePin(note.id);
|
|
||||||
}}
|
|
||||||
class="text-primary hover:text-primary/80 p-1"
|
|
||||||
{...{ title: note.pinned ? 'Unpin note' : 'Pin note' }}
|
|
||||||
>
|
|
||||||
<IconPin size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteNote(note.id);
|
|
||||||
}}
|
|
||||||
class="text-destructive hover:text-destructive/80 p-1"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-muted-foreground text-sm mb-3">
|
|
||||||
<div class={expandedNotes().has(note.id) ? '' : 'max-h-72 overflow-hidden'}>
|
|
||||||
<NoteContentRenderer
|
|
||||||
content={note.content}
|
|
||||||
kind={getNoteKind(note)}
|
|
||||||
preview={!expandedNotes().has(note.id)}
|
|
||||||
maxBlocks={4}
|
|
||||||
onToggleTask={(taskIndex, nextChecked) => updateNoteCheckbox(note.id, taskIndex, nextChecked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleNoteExpansion(note.id);
|
|
||||||
}}
|
|
||||||
class="mt-2 text-xs text-primary hover:text-primary/80 font-medium cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={note.attachments && note.attachments.length > 0}>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<IconPaperclip class="size-4 text-muted-foreground" />
|
|
||||||
<span class="text-xs text-muted-foreground">Attachments ({note.attachments?.length || 0})</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<For each={note.attachments || []}>
|
|
||||||
{(attachment) => (
|
|
||||||
<div class="flex items-center gap-2 px-2 py-1 bg-muted rounded-md text-xs">
|
|
||||||
<span class="text-foreground">{attachment.name}</span>
|
|
||||||
<span class="text-muted-foreground">({attachment.size})</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mb-3">
|
<h3 class={`text-base font-semibold text-foreground mb-2 pr-5 ${note.pinned ? 'text-primary' : ''}`}>
|
||||||
<For each={note.tags}>
|
{note.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="text-muted-foreground text-sm line-clamp-3 mb-4">
|
||||||
|
<NoteContentRenderer
|
||||||
|
content={note.content}
|
||||||
|
kind={getNoteKind(note)}
|
||||||
|
preview={true}
|
||||||
|
maxBlocks={3}
|
||||||
|
onToggleTask={(taskIndex, nextChecked) => updateNoteCheckbox(note.id, taskIndex, nextChecked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
<For each={note.tags.slice(0, 4)}>
|
||||||
{(tag) => (
|
{(tag) => (
|
||||||
<button
|
<span
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleTag(tag);
|
toggleTag(tag);
|
||||||
}}
|
}}
|
||||||
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer"
|
class="px-2 py-0.5 bg-muted/60 text-muted-foreground text-[10px] rounded-full cursor-pointer hover:bg-muted hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={note.tags.length > 4}>
|
||||||
|
<span class="px-2 py-0.5 text-muted-foreground text-[10px]">+{note.tags.length - 4}</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-muted-foreground text-xs">
|
<div class="flex items-center justify-between">
|
||||||
Updated: {formatDisplayDate(note.updatedAt)}
|
<span class="text-[10px] text-muted-foreground">
|
||||||
</p>
|
{formatDisplayDate(note.updatedAt)}
|
||||||
</Card>
|
</span>
|
||||||
|
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); copyNoteContent(note); }}
|
||||||
|
class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<IconCopy class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); startEditNote(note); }}
|
||||||
|
class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<IconEdit class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); togglePin(note.id); }}
|
||||||
|
class="p-1.5 rounded-md hover:bg-muted text-primary hover:text-primary/80"
|
||||||
|
title={note.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
<IconPin class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteNote(note.id); }}
|
||||||
|
class="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<IconTrash class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={filteredNotes().length === 0}>
|
<Show when={filteredNotes().length === 0}>
|
||||||
<Card class="p-12 text-center">
|
<div class="col-span-full">
|
||||||
<p class="text-muted-foreground">
|
<Card class="p-12 text-center">
|
||||||
{searchTerm() || selectedTags().length > 0
|
<p class="text-muted-foreground">
|
||||||
? 'No notes found matching your search or filters.'
|
{searchTerm() || selectedTags().length > 0
|
||||||
: 'No notes yet. Add your first note!'}
|
? 'No notes found matching your search or filters.'
|
||||||
</p>
|
: 'No notes yet. Add your first note!'}
|
||||||
</Card>
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
)}
|
||||||
|
|
||||||
{/* Add Note Modal */}
|
{/* Add Note Modal */}
|
||||||
<NoteModal
|
<NoteModal
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '@tabler/icons-solidjs';
|
} from '@tabler/icons-solidjs';
|
||||||
import { BrowserSearch } from '@/components/search/BrowserSearch';
|
import { BrowserSearch } from '@/components/search/BrowserSearch';
|
||||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||||
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
||||||
import { UploadModal } from '@/components/ui/UploadModal';
|
import { UploadModal } from '@/components/ui/UploadModal';
|
||||||
@@ -525,129 +526,105 @@ export const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||||
<div class="border rounded-lg p-4">
|
<Card class="p-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
<div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
|
||||||
<IconFileText class="size-5 text-primary" />
|
<IconFileText class="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-light">{stats().totalDocuments}</p>
|
<p class="text-2xl font-bold text-foreground">{stats().totalDocuments}</p>
|
||||||
<p class="text-sm text-muted-foreground">Documents</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconBookmark class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-2xl font-light">{stats().totalBookmarks}</p>
|
|
||||||
<p class="text-sm text-muted-foreground">Bookmarks</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconChecklist class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-2xl font-light">{stats().totalTasks}</p>
|
|
||||||
<p class="text-sm text-muted-foreground">Tasks</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconNotebook class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-2xl font-light">{stats().totalNotes}</p>
|
|
||||||
<p class="text-sm text-muted-foreground">Notes</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enhanced Stats Row */}
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-8">
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex flex-col items-center text-center gap-2">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconVideo class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xl font-bold text-foreground">{stats().totalVideos}</p>
|
|
||||||
<p class="text-xs text-muted-foreground font-medium">Videos</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex flex-col items-center text-center gap-2">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconSchool class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xl font-bold text-foreground">{stats().totalLearningPaths}</p>
|
|
||||||
<p class="text-xs text-muted-foreground font-medium">Learning</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex flex-col items-center text-center gap-2">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconClock class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xl font-bold text-foreground">{formatDuration(stats().totalTimeTracked)}</p>
|
|
||||||
<p class="text-xs text-muted-foreground font-medium">Time</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex flex-col items-center text-center gap-2">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconTrendingUp class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xl font-bold text-foreground">{stats().averageProductivity}%</p>
|
|
||||||
<p class="text-xs text-muted-foreground font-medium">Productivity</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<div class="flex flex-col items-center text-center gap-2">
|
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
|
||||||
<IconFolder class="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xl font-bold text-foreground">{stats().totalDocuments}</p>
|
|
||||||
<p class="text-xs text-muted-foreground font-medium">Documents</p>
|
<p class="text-xs text-muted-foreground font-medium">Documents</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div class="border rounded-lg p-4">
|
<Card class="p-4">
|
||||||
<div class="flex flex-col items-center text-center gap-2">
|
<div class="flex items-center gap-3">
|
||||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
<div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
|
||||||
<IconActivity class="size-5 text-primary" />
|
<IconBookmark class="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xl font-bold text-foreground">{stats().totalNotes}</p>
|
<p class="text-2xl font-bold text-foreground">{stats().totalBookmarks}</p>
|
||||||
|
<p class="text-xs text-muted-foreground font-medium">Bookmarks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
|
||||||
|
<IconChecklist class="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-foreground">{stats().totalTasks}</p>
|
||||||
|
<p class="text-xs text-muted-foreground font-medium">Tasks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
|
||||||
|
<IconNotebook class="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-foreground">{stats().totalNotes}</p>
|
||||||
<p class="text-xs text-muted-foreground font-medium">Notes</p>
|
<p class="text-xs text-muted-foreground font-medium">Notes</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Stats */}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||||
|
<Card class="p-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
|
||||||
|
<IconVideo class="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-foreground">{stats().totalVideos}</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-medium">Videos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
|
||||||
|
<IconSchool class="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-foreground">{stats().totalLearningPaths}</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-medium">Learning</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
|
||||||
|
<IconClock class="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-foreground">{formatDuration(stats().totalTimeTracked)}</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-medium">Tracked</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
|
||||||
|
<IconTrendingUp class="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-foreground">{stats().averageProductivity}%</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-medium">Productivity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Achievements and Deadlines */}
|
{/* Recent Achievements and Deadlines */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
|
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
|
||||||
|
import { getApiOrigin } from '@/lib/api-url'
|
||||||
import { DateRangePicker } from '@/components/ui/DateRangePicker';
|
import { DateRangePicker } from '@/components/ui/DateRangePicker';
|
||||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||||
import {
|
import {
|
||||||
@@ -149,9 +150,9 @@ export function Calendar() {
|
|||||||
|
|
||||||
// Fetch all calendar data in parallel
|
// Fetch all calendar data in parallel
|
||||||
const [upcomingRes, todayRes, deadlinesRes] = await Promise.all([
|
const [upcomingRes, todayRes, deadlinesRes] = await Promise.all([
|
||||||
fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/calendar/upcoming`, { headers }),
|
fetch(`${getApiOrigin()}/api/v1/calendar/upcoming`, { headers }),
|
||||||
fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/calendar/today`, { headers }),
|
fetch(`${getApiOrigin()}/api/v1/calendar/today`, { headers }),
|
||||||
fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/calendar/deadlines`, { headers })
|
fetch(`${getApiOrigin()}/api/v1/calendar/deadlines`, { headers })
|
||||||
])
|
])
|
||||||
|
|
||||||
if (upcomingRes.ok) {
|
if (upcomingRes.ok) {
|
||||||
@@ -247,7 +248,7 @@ export function Calendar() {
|
|||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/calendar`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/calendar`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -304,7 +305,7 @@ export function Calendar() {
|
|||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/calendar/${eventId}/toggle-complete`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/calendar/${eventId}/toggle-complete`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -328,22 +329,22 @@ export function Calendar() {
|
|||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
const getPriorityColor = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'urgent': return 'text-primary'
|
case 'urgent': return 'text-red-500'
|
||||||
case 'high': return 'text-primary'
|
case 'high': return 'text-orange-500'
|
||||||
case 'medium': return 'text-primary'
|
case 'medium': return 'text-yellow-500'
|
||||||
case 'low': return 'text-primary'
|
case 'low': return 'text-green-500'
|
||||||
default: return 'text-primary'
|
default: return 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
const getTypeColor = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'task': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
|
case 'task': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
case 'meeting': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
|
case 'meeting': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
case 'deadline': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
|
case 'deadline': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
case 'reminder': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
|
case 'reminder': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||||
case 'habit': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
|
case 'habit': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
default: return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
|
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createSignal, onMount } from 'solid-js';
|
import { createSignal, onMount } from 'solid-js';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
|
||||||
import { TaskModal } from '@/components/ui/TaskModal';
|
import { TaskModal } from '@/components/ui/TaskModal';
|
||||||
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
|
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
|
||||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||||
@@ -25,12 +24,53 @@ export const Tasks = () => {
|
|||||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||||
const [showEditModal, setShowEditModal] = createSignal(false);
|
const [showEditModal, setShowEditModal] = createSignal(false);
|
||||||
const [editingTask, setEditingTask] = createSignal<Task | null>(null);
|
const [editingTask, setEditingTask] = createSignal<Task | null>(null);
|
||||||
const [filter, setFilter] = createSignal<'all' | 'active' | 'completed'>('all');
|
|
||||||
const [searchTerm, setSearchTerm] = createSignal('');
|
const [searchTerm, setSearchTerm] = createSignal('');
|
||||||
const [selectedPriority, setSelectedPriority] = createSignal('');
|
const [selectedPriority, setSelectedPriority] = createSignal('');
|
||||||
|
const [draggedTaskId, setDraggedTaskId] = createSignal<number | null>(null);
|
||||||
|
const [dragOverColumn, setDragOverColumn] = createSignal<string | null>(null);
|
||||||
|
const [taskStatuses, setTaskStatuses] = createSignal<Record<number, 'todo' | 'inProgress' | 'done'>>({});
|
||||||
|
|
||||||
const haptics = useHaptics();
|
const haptics = useHaptics();
|
||||||
|
|
||||||
|
const getTaskColumn = (task: Task) => {
|
||||||
|
if (task.completed) return 'done';
|
||||||
|
return taskStatuses()[task.id] || 'todo';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTaskColumn = async (taskId: number, column: 'todo' | 'inProgress' | 'done') => {
|
||||||
|
const task = tasks().find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const shouldBeCompleted = column === 'done';
|
||||||
|
|
||||||
|
if (column === 'done') {
|
||||||
|
setTaskStatuses(prev => { const n = { ...prev }; delete n[taskId]; return n; });
|
||||||
|
} else {
|
||||||
|
setTaskStatuses(prev => ({ ...prev, [taskId]: column }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.completed !== shouldBeCompleted) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...task, completed: shouldBeCompleted }),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const updated = await response.json();
|
||||||
|
setTasks(prev => prev.map(t => t.id === taskId ? updated : t));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update task status:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, completed: shouldBeCompleted } : t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/tasks`, {
|
const response = await fetch(`${API_BASE_URL}/tasks`, {
|
||||||
@@ -51,30 +91,29 @@ export const Tasks = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTasks = () => {
|
const searchedTasks = () => {
|
||||||
const term = searchTerm().toLowerCase();
|
const term = searchTerm().toLowerCase();
|
||||||
const filtered = tasks().filter(task => {
|
return tasks().filter(task => {
|
||||||
const matchesSearch = !term ||
|
const matchesSearch = !term ||
|
||||||
task.title.toLowerCase().includes(term) ||
|
task.title.toLowerCase().includes(term) ||
|
||||||
(task.description && task.description.toLowerCase().includes(term));
|
(task.description && task.description.toLowerCase().includes(term));
|
||||||
|
|
||||||
const matchesPriority = !selectedPriority() || task.priority === selectedPriority();
|
const matchesPriority = !selectedPriority() || task.priority === selectedPriority();
|
||||||
|
return matchesSearch && matchesPriority;
|
||||||
const matchesFilter =
|
}).sort((a, b) => {
|
||||||
(filter() === 'active' && !task.completed) ||
|
|
||||||
(filter() === 'completed' && task.completed) ||
|
|
||||||
filter() === 'all';
|
|
||||||
|
|
||||||
return matchesSearch && matchesFilter && matchesPriority;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
|
||||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
|
||||||
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columnTasks = (column: 'todo' | 'inProgress' | 'done') =>
|
||||||
|
searchedTasks().filter(t => getTaskColumn(t) === column);
|
||||||
|
|
||||||
|
const columnCounts = () => ({
|
||||||
|
todo: columnTasks('todo').length,
|
||||||
|
inProgress: columnTasks('inProgress').length,
|
||||||
|
done: columnTasks('done').length,
|
||||||
|
});
|
||||||
|
|
||||||
const handleAddTask = async (task: Omit<Task, 'id'>) => {
|
const handleAddTask = async (task: Omit<Task, 'id'>) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/tasks`, {
|
const response = await fetch(`${API_BASE_URL}/tasks`, {
|
||||||
@@ -134,19 +173,6 @@ export const Tasks = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTaskComplete = async (taskId: number) => {
|
|
||||||
try {
|
|
||||||
// TODO: Replace with actual API call
|
|
||||||
setTasks(prev => prev.map(task =>
|
|
||||||
task.id === taskId ? { ...task, completed: !task.completed } : task
|
|
||||||
));
|
|
||||||
haptics.completion(); // Completion feedback for toggling task
|
|
||||||
} catch (error) {
|
|
||||||
haptics.error(); // Error feedback
|
|
||||||
console.error('Failed to update task:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTask = async (taskId: number) => {
|
const deleteTask = async (taskId: number) => {
|
||||||
if (confirm('Are you sure you want to delete this task?')) {
|
if (confirm('Are you sure you want to delete this task?')) {
|
||||||
try {
|
try {
|
||||||
@@ -185,20 +211,13 @@ export const Tasks = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskStats = () => {
|
|
||||||
const total = tasks().length;
|
|
||||||
const completed = tasks().filter(t => t.completed).length;
|
|
||||||
const active = total - completed;
|
|
||||||
return { total, completed, active };
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasSearchOrPriorityFilters = () =>
|
|
||||||
Boolean(searchTerm().trim()) || Boolean(selectedPriority());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<h1 class="text-3xl font-bold text-[#fafafa]">Tasks</h1>
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-foreground">Tasks</h1>
|
||||||
|
<p class="text-muted-foreground text-sm mt-1">{columnCounts().todo} todo · {columnCounts().inProgress} in progress · {columnCounts().done} done</p>
|
||||||
|
</div>
|
||||||
<Button onClick={() => setShowAddModal(true)} haptic="impact">
|
<Button onClick={() => setShowAddModal(true)} haptic="impact">
|
||||||
Add Task
|
Add Task
|
||||||
</Button>
|
</Button>
|
||||||
@@ -221,137 +240,106 @@ export const Tasks = () => {
|
|||||||
isEdit={true}
|
isEdit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
<Card class="p-4 text-center">
|
<input
|
||||||
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().total}</p>
|
type="text"
|
||||||
<p class="text-[#a3a3a3] text-sm">Total Tasks</p>
|
placeholder="Search tasks..."
|
||||||
</Card>
|
value={searchTerm()}
|
||||||
<Card class="p-4 text-center">
|
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
||||||
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().active}</p>
|
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
<p class="text-[#a3a3a3] text-sm">Active</p>
|
/>
|
||||||
</Card>
|
<select
|
||||||
<Card class="p-4 text-center">
|
value={selectedPriority()}
|
||||||
<p class="text-2xl font-bold text-blue-400">{taskStats().completed}</p>
|
onChange={(e) => setSelectedPriority(e.currentTarget.value)}
|
||||||
<p class="text-[#a3a3a3] text-sm">Completed</p>
|
class="px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
</Card>
|
>
|
||||||
</div>
|
<option value="">All priorities</option>
|
||||||
|
<option value="high">High</option>
|
||||||
<SearchTagFilterBar
|
<option value="medium">Medium</option>
|
||||||
searchPlaceholder="Search tasks..."
|
<option value="low">Low</option>
|
||||||
searchValue={searchTerm()}
|
</select>
|
||||||
onSearchChange={(value) => setSearchTerm(value)}
|
|
||||||
tagOptions={['high', 'medium', 'low']}
|
|
||||||
selectedTag={selectedPriority()}
|
|
||||||
onTagChange={(value) => setSelectedPriority(value)}
|
|
||||||
onReset={() => {
|
|
||||||
setSearchTerm('');
|
|
||||||
setSelectedPriority('');
|
|
||||||
}}
|
|
||||||
allOptionLabel="All Priorities"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 -mt-3 mb-6">
|
|
||||||
{(['all', 'active', 'completed'] as const).map((filterOption) => (
|
|
||||||
<Button
|
|
||||||
variant={filter() === filterOption ? 'default' : 'outline'}
|
|
||||||
onClick={() => setFilter(filterOption)}
|
|
||||||
class="capitalize"
|
|
||||||
haptic="selection"
|
|
||||||
>
|
|
||||||
{filterOption}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading() ? (
|
{isLoading() ? (
|
||||||
<div class="space-y-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{[...Array(3)].map(() => (
|
{[...Array(3)].map(() => (
|
||||||
<Card class="p-6">
|
<Card class="p-4 h-48">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse space-y-3">
|
||||||
<div class="h-6 bg-[#262626] rounded mb-2"></div>
|
<div class="h-5 bg-muted rounded w-1/2"></div>
|
||||||
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
|
<div class="h-20 bg-muted rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="space-y-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
|
||||||
{filteredTasks().map((task) => (
|
{([
|
||||||
<div
|
{ key: 'todo' as const, label: 'To Do', color: 'border-t-4 border-t-muted-foreground' },
|
||||||
class={`cursor-pointer transition-all ${task.completed ? 'opacity-60' : ''}`}
|
{ key: 'inProgress' as const, label: 'In Progress', color: 'border-t-4 border-t-primary' },
|
||||||
onClick={() => toggleTaskComplete(task.id)}
|
{ key: 'done' as const, label: 'Done', color: 'border-t-4 border-t-emerald-500' },
|
||||||
>
|
]).map((col) => {
|
||||||
<Card class={`p-6 hover:bg-[#141415]`}>
|
const items = columnTasks(col.key);
|
||||||
<div class="flex items-start space-x-3">
|
const isDropTarget = dragOverColumn() === col.key;
|
||||||
<input
|
return (
|
||||||
type="checkbox"
|
<div
|
||||||
checked={task.completed}
|
class={`flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4 min-h-[12rem] transition-all ${col.color} ${isDropTarget ? 'ring-2 ring-primary/30 bg-primary/5' : ''}`}
|
||||||
onChange={(e) => {
|
onDragOver={(e) => { e.preventDefault(); setDragOverColumn(col.key); }}
|
||||||
e.stopPropagation();
|
onDragLeave={() => setDragOverColumn(null)}
|
||||||
toggleTaskComplete(task.id);
|
onDrop={(e) => { e.preventDefault(); setDragOverColumn(null); const id = draggedTaskId(); if (id !== null) setTaskColumn(id, col.key); setDraggedTaskId(null); }}
|
||||||
}}
|
>
|
||||||
class="mt-1 w-4 h-4 text-[#39b9ff] bg-[#141415] border-[#262626] rounded focus:ring-[#39b9ff]"
|
<div class="flex items-center justify-between">
|
||||||
/>
|
<h2 class="font-semibold text-foreground">{col.label}</h2>
|
||||||
<div class="flex-1">
|
<span class="text-xs font-medium px-2 py-0.5 rounded-full bg-muted text-muted-foreground">{items.length}</span>
|
||||||
<div class="flex items-center justify-between">
|
</div>
|
||||||
<h3 class={`text-lg font-semibold text-[#fafafa] ${task.completed ? 'line-through' : ''}`}>
|
<div class="flex flex-col gap-2">
|
||||||
{task.title}
|
{items.map((task: Task) => (
|
||||||
</h3>
|
<div
|
||||||
<div class="flex items-center space-x-2">
|
draggable={true}
|
||||||
<span class={`px-2 py-1 text-xs rounded-md ${getPriorityColor(task.priority)}`}>
|
onDragStart={() => { setDraggedTaskId(task.id); haptics.impact(); }}
|
||||||
|
onDragEnd={() => setDraggedTaskId(null)}
|
||||||
|
class={`group bg-background border border-border rounded-lg p-3 cursor-grab active:cursor-grabbing hover:shadow-md hover:border-primary/20 transition-all ${draggedTaskId() === task.id ? 'opacity-40' : ''}`}
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h3 class="text-sm font-medium text-foreground leading-snug flex-1">{task.title}</h3>
|
||||||
|
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => editTask(task)}
|
||||||
|
class="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<IconEdit class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteTask(task.id)}
|
||||||
|
class="p-1 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<IconTrash class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task.description && (
|
||||||
|
<p class="text-xs text-muted-foreground mt-1 line-clamp-2">{task.description}</p>
|
||||||
|
)}
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<span class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getPriorityColor(task.priority)}`}>
|
||||||
{task.priority}
|
{task.priority}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
{task.dueDate && (
|
||||||
variant="ghost"
|
<span class="text-[10px] text-muted-foreground">
|
||||||
size="sm"
|
{new Date(task.dueDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||||
onClick={(e) => {
|
</span>
|
||||||
e.stopPropagation();
|
)}
|
||||||
editTask(task);
|
|
||||||
}}
|
|
||||||
class="text-blue-400 hover:text-blue-300"
|
|
||||||
haptic="impact"
|
|
||||||
>
|
|
||||||
<IconEdit class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteTask(task.id);
|
|
||||||
}}
|
|
||||||
class="text-red-400 hover:text-red-300"
|
|
||||||
haptic="warning"
|
|
||||||
>
|
|
||||||
<IconTrash class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{task.description && (
|
))}
|
||||||
<p class="text-[#a3a3a3] text-sm mt-1">{task.description}</p>
|
{items.length === 0 && (
|
||||||
)}
|
<div class="text-center py-8 text-xs text-muted-foreground border-2 border-dashed border-border rounded-lg">
|
||||||
{task.dueDate && (
|
Drop tasks here
|
||||||
<p class="text-[#a3a3a3] text-xs mt-2">
|
</div>
|
||||||
Due: {new Date(task.dueDate).toLocaleDateString()}
|
)}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{filteredTasks().length === 0 && (
|
|
||||||
<Card class="p-12 text-center">
|
|
||||||
<p class="text-[#a3a3a3]">
|
|
||||||
{hasSearchOrPriorityFilters()
|
|
||||||
? 'No tasks found matching your search or filters.'
|
|
||||||
: filter() === 'completed' ? 'No completed tasks yet.' :
|
|
||||||
filter() === 'active' ? 'No active tasks. Great job!' :
|
|
||||||
'No tasks yet. Add your first task!'}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createSignal, createEffect, Show, For } from 'solid-js';
|
import { createSignal, createEffect, Show, For } from 'solid-js';
|
||||||
import { Card } from '../components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '../components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { toast } from '../components/ui/Toast';
|
import { toast } from '@/components/ui/Toast';
|
||||||
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
|
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
|
||||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||||
|
|
||||||
@@ -478,7 +478,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label>
|
||||||
<Input
|
<Input
|
||||||
value={newKeyName()}
|
value={newKeyName()}
|
||||||
onInput={(e) => setNewKeyName((e.target as HTMLInputElement).value)}
|
onInput={(e: InputEvent) => setNewKeyName((e.target as HTMLInputElement).value)}
|
||||||
placeholder="e.g., Chrome Extension, Laptop Backup"
|
placeholder="e.g., Chrome Extension, Laptop Backup"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useAuth } from '@/lib/auth';
|
|||||||
import { IconUser, IconLock, IconKey, IconBrain, IconMail, IconSend, IconShield, IconDownload } from '@tabler/icons-solidjs';
|
import { IconUser, IconLock, IconKey, IconBrain, IconMail, IconSend, IconShield, IconDownload } from '@tabler/icons-solidjs';
|
||||||
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||||
import { useHaptics } from '@/lib/haptics';
|
import { useHaptics } from '@/lib/haptics';
|
||||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
import { getApiV1BaseUrl, getApiOrigin } from '@/lib/api-url';
|
||||||
|
|
||||||
interface BrowserExtensionApiKey {
|
interface BrowserExtensionApiKey {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -198,7 +199,7 @@ export const Settings = () => {
|
|||||||
|
|
||||||
const loadAISettings = async () => {
|
const loadAISettings = async () => {
|
||||||
try {
|
try {
|
||||||
const endpoint = `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`;
|
const endpoint = `${getApiOrigin()}/api/v1/auth/ai/settings`;
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -218,7 +219,7 @@ export const Settings = () => {
|
|||||||
|
|
||||||
const loadAvailableAIProviders = async () => {
|
const loadAvailableAIProviders = async () => {
|
||||||
try {
|
try {
|
||||||
const endpoint = `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/ai/providers`;
|
const endpoint = `${getApiOrigin()}/api/v1/ai/providers`;
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -240,7 +241,7 @@ export const Settings = () => {
|
|||||||
|
|
||||||
const loadSearchSettings = async () => {
|
const loadSearchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const endpoint = `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/search/settings`;
|
const endpoint = `${getApiOrigin()}/api/v1/auth/search/settings`;
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -292,7 +293,7 @@ export const Settings = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/ai/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -370,7 +371,7 @@ export const Settings = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/search/settings`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/search/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -412,7 +413,7 @@ export const Settings = () => {
|
|||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div class="border-b border-border mb-6">
|
<div class="border-b border-border mb-6">
|
||||||
<nav class="flex space-x-1">
|
<nav class="flex space-x-1 overflow-x-auto scrollbar-hide">
|
||||||
<For each={tabs}>
|
<For each={tabs}>
|
||||||
{(tab) => (
|
{(tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -420,7 +421,7 @@ export const Settings = () => {
|
|||||||
setActiveTab(tab.id);
|
setActiveTab(tab.id);
|
||||||
haptics.selection();
|
haptics.selection();
|
||||||
}}
|
}}
|
||||||
class={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
class={`flex items-center gap-2 px-3 sm:px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||||
activeTab() === tab.id
|
activeTab() === tab.id
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
@@ -440,9 +441,11 @@ export const Settings = () => {
|
|||||||
<Show when={activeTab() === 'account'}>
|
<Show when={activeTab() === 'account'}>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div class="border rounded-lg p-6">
|
<Card class="p-6">
|
||||||
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
|
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||||
<IconUser class="size-5" />
|
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||||
|
<IconUser class="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
Profile Settings
|
Profile Settings
|
||||||
</h2>
|
</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -546,9 +549,9 @@ export const Settings = () => {
|
|||||||
{isLoading() ? 'Updating...' : 'Update Profile'}
|
{isLoading() ? 'Updating...' : 'Update Profile'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div class="border rounded-lg p-6">
|
<Card class="p-6">
|
||||||
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
|
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||||
<IconLock class="size-5" />
|
<IconLock class="size-5" />
|
||||||
Change Password
|
Change Password
|
||||||
@@ -611,7 +614,7 @@ export const Settings = () => {
|
|||||||
{isLoading() ? 'Changing...' : 'Change Password'}
|
{isLoading() ? 'Changing...' : 'Change Password'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1550,7 +1553,7 @@ export const Settings = () => {
|
|||||||
// Save email settings
|
// Save email settings
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/email/settings`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/email/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -1579,7 +1582,7 @@ export const Settings = () => {
|
|||||||
// Test email configuration
|
// Test email configuration
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/email/test`, {
|
const response = await fetch(`${getApiOrigin()}/api/v1/auth/email/test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@
|
|||||||
"render:video:poster": "npm --workspace video run render:poster",
|
"render:video:poster": "npm --workspace video run render:poster",
|
||||||
"install:all": "npm install && cd frontend && npm install && cd ../mobile && npm install && cd ../desktop && npm install",
|
"install:all": "npm install && cd frontend && npm install && cd ../mobile && npm install && cd ../desktop && npm install",
|
||||||
"clean": "rm -rf dist node_modules frontend/node_modules mobile/node_modules desktop/node_modules backend/vendor desktop/dist desktop/src-tauri/target",
|
"clean": "rm -rf dist node_modules frontend/node_modules mobile/node_modules desktop/node_modules backend/vendor desktop/dist desktop/src-tauri/target",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package || true"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user