7 Commits

Author SHA1 Message Date
Tomas Dvorak 4dfdd500b4 fix(frontend): resolve production API URL fallback to localhost
CI/CD Pipeline / Test (push) Successful in 20m59s
CI/CD Pipeline / Security Scan (push) Successful in 10m38s
CI/CD Pipeline / Build and Push Images (push) Failing after 13s
Problem:
The unified Docker image builds the frontend at build time without
VITE_API_URL. Vite inlined import.meta.env.VITE_API_URL as undefined,
so every API call fell back to the hardcoded 'http://localhost:8080'.
This broke Casa deployments where the frontend loaded from the public
 domain but tried to reach the backend at localhost.

Solution:
1. Centralize API URL resolution in lib/api-url.ts via getApiOrigin().
   It checks runtime window.ENV first (injected by docker-entrypoint.sh
   at container startup), then build-time import.meta.env, then dev
   fallback. In production unified deployments it returns '' so API
   calls use same-origin relative URLs (/api/v1/...) that nginx proxies
   to the backend.
2. Replace all 50+ inline import.meta.env.VITE_API_URL || 'localhost'
   usages across 14 source files with getApiOrigin() / getApiV1BaseUrl().
3. Add build args and runtime sed substitution to Dockerfile and
   docker-entrypoint.sh so the same image works for any deployment.
4. Pass VITE_API_URL through docker-compose.yml and CI/CD build-args.

Verified:
- Production bundle contains 0 occurrences of localhost:8080.
- Container health check and /api/v1/auth/check-users both return 200.
- Runtime injection correctly sets VITE_API_URL='' for same-origin
  and VITE_API_URL='https://domain' for external backend.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-22 12:34:39 +02:00
Tomas Dvorak b539aa1b91 fix(docker): ensure correct permissions for PostgreSQL directories
CI/CD Pipeline / Test (push) Successful in 21m25s
CI/CD Pipeline / Security Scan (push) Successful in 10m38s
CI/CD Pipeline / Build and Push Images (push) Failing after 9s
Ensure that PGDATA, /run/postgresql, and /var/log/postgresql are owned by the postgres user to prevent volume permission issues during container startup.
2026-05-21 14:46:57 +02:00
Tomas Dvorak 616568ca7b fix(ui): enable IPv6 listening in nginx configuration 2026-05-21 14:16:11 +02:00
Tomas Dvorak 5da6360ed9 feat(docker): bundle PostgreSQL into the unified container
Transition from a multi-service architecture to an all-in-one container
by bundling PostgreSQL directly within the Docker image. This simplifies
deployment, especially for environments like CasaOS, by removing the
need for an external database service.

- Update Dockerfile to install and configure PostgreSQL
- Implement database initialization logic in docker-entrypoint.sh
- Update .env.example to reflect auto-generation of credentials
- Simplify docker-compose.yml to a single service
- Update README.md with new deployment instructions and architecture details
2026-05-21 13:21:19 +02:00
Tomas Dvorak 67dc5cc737 feat(ui): enhance frontend architecture and improve user experience
CI/CD Pipeline / Test (push) Successful in 21m56s
CI/CD Pipeline / Security Scan (push) Successful in 10m54s
CI/CD Pipeline / Build and Push Images (push) Failing after 2m12s
Refactor the frontend to use a more consistent design system and improve the overall user interface and experience.

- Implement a consistent use of `Card` components across various pages (Dashboard, Settings, Notes, etc.).
- Improve layout responsiveness and spacing in several modules.
- Enhance the Tasks page with drag-and-drop status updates and a Kanban-style view.
- Update the Calendar view with better color coding for task priorities and types.
- Refactor the Bookmarks page to use a grid layout with improved card previews.
- Update the Nginx configuration to handle SPA routing and health checks more effectively.
- Standardize import paths using `@/` aliases.
- Fix minor bugs in message sending and loading states.
- Update Docker configuration to build from source and use a specific backend port.
2026-05-20 16:36:48 +02:00
Tomas Dvorak 1e377a01b0 chore(config): remove dragonflydb and update deployment documentation
CI/CD Pipeline / Test (push) Failing after 14m0s
CI/CD Pipeline / Security Scan (push) Successful in 10m59s
CI/CD Pipeline / Build and Push Images (push) Has been skipped
Remove all references to DragonflyDB from the codebase, environment templates, and documentation following its removal from the service architecture. This includes cleaning up Docker configurations, CI/CD workflows, and production guides.

- **Cleanup**: Deleted `dragonfly.conf` and removed DragonflyDB service from `docker-compose.yml`.
- **Environment**: Removed `DRAGONFLY_PASSWORD` and `DRAGONFLY_ADDR` from `.env.example` and `docker-entrypoint.sh`.
- **Documentation**: Updated `README.md`, `PRODUCTION_DEPLOYMENT.md`, and `QUICK_START_PRODUCTION.md` to reflect a 2-service architecture (Trackeep + Postgres).
- **CI/CD**: Updated GitHub Actions to use Go 1.25.
- **Testing**: Updated `test-production.sh` to remove DragonflyDB variable validation.
2026-05-10 11:25:33 +02:00
Tomas Dvorak 6c448b336a refactor: unify docker deployment and restructure frontend architecture
This commit implements a unified Docker deployment strategy, moving from separate frontend and backend images to a single, multi-stage build image containing both services. It also introduces a major reorganization of the frontend directory structure and simplifies the environment configuration.

Key changes:
- **Deployment**: Added a multi-stage `Dockerfile` and `docker-entrypoint.sh` to package the Go backend and Nginx-served frontend into a single container.
- **CI/CD**: Updated GitHub Actions workflows (`ci-cd.yml`, `release.yml`) to build and push the new unified image instead of separate ones.
- **Frontend Refactor**: Reorganized `frontend/src/pages` into a domain-driven directory structure (e.g., `auth/`, `admin/`, `content/`, `communication/`, `productivity/`, `settings/`, `misc/`).
- **Configuration**: Simplified `.env.example` and updated `docker-compose.yml` to reflect the unified service model and single host port.
- **Cleanup**: Removed deprecated `docker-compose.demo.yml`, `docker-compose.prod.yml`, and various unused frontend components and services.
- **Backend**: Refactored configuration loading to use exported `GetDurationEnv` for better consistency.
2026-05-10 10:48:41 +02:00
88 changed files with 136251 additions and 5668 deletions
+11 -51
View File
@@ -1,54 +1,14 @@
# Server Configuration
FRONTEND_PORT=3000
FRONTEND_HOST_PORT=3900
BACKEND_PORT=8080
BACKEND_HOST_PORT=9000
GIN_MODE=debug
# Trackeep All-in-One Configuration
# PostgreSQL is bundled inside the container — no external database needed.
# Everything below is optional; the container auto-generates sensible defaults.
# Demo Mode Configuration
# Set to true for demo mode (read-only with demo data)
# Set to false for normal mode (full functionality)
VITE_DEMO_MODE=true
VITE_API_URL=http://localhost:9000
FRONTEND_URL=http://localhost:3900
PUBLIC_API_URL=http://localhost:9000
# Host port mapping (default: 8080)
HOST_PORT=8080
# Database Configuration
DB_TYPE=postgres
DB_HOST=localhost
DB_PORT=5432
DB_HOST_PORT=5433
DB_USER=trackeep
DB_PASSWORD=your_password_here
DB_NAME=trackeep
DB_SSL_MODE=disable
# Database credentials (auto-generated if left empty)
# DB_PASSWORD=your_secure_password_here
# DB_USER=trackeep
# DB_NAME=trackeep
# DragonflyDB Configuration
DRAGONFLY_ADDR=dragonfly:6379
DRAGONFLY_PORT=6379
DRAGONFLY_HOST_PORT=6380
DRAGONFLY_PASSWORD=your_dragonfly_password_here
# JWT Configuration (also used for encryption)
# Generate a secure 64-character hex string using: openssl rand -hex 32
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
# Token expiration time (e.g., 24h, 1h, 30m, 7d)
JWT_EXPIRES_IN=24h
# GitHub backup storage
# Self-hosted Trackeep instances use the unified control service for GitHub sign-in
# and GitHub App installation. No per-instance GitHub App credentials are required.
GITHUB_BACKUP_ROOT=./data/github-backups
GITHUB_BACKUP_TIMEOUT=10m
# File Upload Configuration
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
# CORS Configuration
CORS_ALLOWED_ORIGINS=*
# Auto Update Configuration
AUTO_UPDATE_CHECK=false
UPDATE_CHECK_INTERVAL=24h
PRERELEASE_UPDATES=false
# JWT Secret (auto-generated and persisted in /data if left empty)
# JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
+12 -27
View File
@@ -36,7 +36,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
go-version: '1.25'
cache: true
cache-dependency-path: backend/go.sum
@@ -92,7 +92,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
go-version: '1.25'
cache: true
cache-dependency-path: backend/go.sum
@@ -128,43 +128,28 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta-backend
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Extract metadata
id: meta-frontend
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push backend image
uses: docker/build-push-action@v4
with:
context: ./backend
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
- name: Build and push frontend image
- name: Build and push unified image
uses: docker/build-push-action@v4
with:
context: .
file: ./frontend/Dockerfile
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
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:
# name: Deploy to Production
+14 -41
View File
@@ -37,9 +37,6 @@ jobs:
build-and-push:
needs: extract-version
runs-on: ubuntu-latest
strategy:
matrix:
service: [backend, frontend]
steps:
- uses: actions/checkout@v4
@@ -57,7 +54,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ matrix.service }}
images: ${{ env.REGISTRY }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
@@ -66,18 +63,12 @@ jobs:
version=${{ needs.extract-version.outputs.version }}
build-date=${{ github.event.head_commit.timestamp }}
commit=${{ github.sha }}
service=${{ matrix.service }}
prerelease=${{ needs.extract-version.outputs.is-prerelease }}
- name: Build and push ${{ matrix.service }}
- name: Build and push unified image
uses: docker/build-push-action@v5
with:
context: |
backend=./backend
frontend=.
file: |
backend=./backend/Dockerfile
frontend=./frontend/Dockerfile
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -87,15 +78,15 @@ jobs:
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ${{ env.REGISTRY }}/${{ matrix.service }}:${{ needs.extract-version.outputs.version }}
image: ${{ env.REGISTRY }}:${{ needs.extract-version.outputs.version }}
format: spdx-json
output-file: ./sbom-${{ matrix.service }}.spdx.json
output-file: ./sbom.spdx.json
- name: Upload SBOM
uses: actions/upload-artifact@v4
with:
name: sbom-${{ matrix.service }}
path: ./sbom-${{ matrix.service }}.spdx.json
name: sbom
path: ./sbom.spdx.json
create-github-release:
needs: [extract-version, build-and-push]
@@ -107,26 +98,21 @@ jobs:
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag: v${{ needs.extract-version.outputs.version }}
name: Trackeep v${{ needs.extract-version.outputs.version }}
body: |
## 🚀 Trackeep v${{ needs.extract-version.outputs.version }}
### 🐳 Docker Images
- **Backend**: `ghcr.io/dvorinka/trackeep/backend:${{ needs.extract-version.outputs.version }}`
- **Frontend**: `ghcr.io/dvorinka/trackeep/frontend:${{ needs.extract-version.outputs.version }}`
- **Latest**: `ghcr.io/dvorinka/trackeep/backend:latest` and `ghcr.io/dvorinka/trackeep/frontend:latest`
### 🐳 Docker Image
- **Unified**: `ghcr.io/dvorinka/trackeep:${{ needs.extract-version.outputs.version }}`
- **Latest**: `ghcr.io/dvorinka/trackeep:latest`
### 📋 Changes
${{ github.event.head_commit.message }}
### 🔧 Installation
```bash
# Set version
export APP_VERSION=${{ needs.extract-version.outputs.version }}
# Deploy with production compose
docker compose -f docker-compose.prod.yml up -d
# Deploy with docker compose
docker compose up -d
```
### ⚡ Auto-Updates
@@ -138,12 +124,10 @@ jobs:
draft: false
prerelease: ${{ needs.extract-version.outputs.is-prerelease }}
files: |
sbom-backend.spdx.json
sbom-frontend.spdx.json
files: sbom.spdx.json
generate_release_notes: true
update-docker-compose-prod:
update-version-files:
needs: extract-version
runs-on: ubuntu-latest
steps:
@@ -168,17 +152,6 @@ jobs:
echo "✅ Backend updated to $VERSION"
fi
# Update docker-compose files
if [ -f "docker-compose.yml" ]; then
sed -i "s/APP_VERSION=.*/APP_VERSION=$VERSION/" docker-compose.yml
echo "✅ docker-compose.yml updated"
fi
if [ -f "docker-compose.prod.yml" ]; then
sed -i "s/APP_VERSION=.*/APP_VERSION=$VERSION/" docker-compose.prod.yml
echo "✅ docker-compose.prod.yml updated"
fi
echo "🎉 All version files updated to $VERSION"
- name: Commit updated version files
@@ -1,30 +0,0 @@
- generic [ref=e4]:
- generic [ref=e5]:
- img "Trackeep Logo" [ref=e7]
- heading "Trackeep" [level=1] [ref=e8]
- paragraph [ref=e9]: Create your account
- generic [ref=e10]:
- generic [ref=e13]: Create Admin Account
- paragraph [ref=e14]: No accounts exist yet. Create the first administrator account to get started.
- generic [ref=e15]:
- generic [ref=e16]:
- generic [ref=e17]: Email
- textbox "Email" [ref=e18]:
- /placeholder: your@email.com
- generic [ref=e19]:
- generic [ref=e20]: Username
- textbox "Username" [ref=e21]:
- /placeholder: username
- generic [ref=e22]:
- generic [ref=e23]: Full Name
- textbox "Full Name" [ref=e24]:
- /placeholder: Your Name
- generic [ref=e25]:
- generic [ref=e26]: Password
- textbox "Password" [ref=e27]:
- /placeholder: ••••••••
- button "Sign Up" [ref=e28] [cursor=pointer]
- generic [ref=e31]: or
- button "Continue with GitHub App" [ref=e33] [cursor=pointer]:
- img [ref=e34]
- text: Continue with GitHub App
@@ -1,405 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e37]:
- generic [ref=e40]:
- link "Trackeep Logo Trackeep" [ref=e42] [cursor=pointer]:
- /url: /app
- img "Trackeep Logo" [ref=e43]
- generic [ref=e44]: Trackeep
- group [ref=e46]:
- button "Trackeep Workspace" [ref=e47] [cursor=pointer]:
- generic [ref=e48]:
- img [ref=e50]
- generic [ref=e53]: Trackeep Workspace
- img [ref=e55]
- navigation [ref=e57]:
- link "Home" [ref=e58] [cursor=pointer]:
- /url: /app
- generic [ref=e59]:
- img [ref=e60]
- generic [ref=e64]: Home
- link "Bookmarks" [ref=e66] [cursor=pointer]:
- /url: /app/bookmarks
- generic [ref=e67]:
- img [ref=e68]
- generic [ref=e70]: Bookmarks
- link "Tasks" [ref=e72] [cursor=pointer]:
- /url: /app/tasks
- generic [ref=e73]:
- img [ref=e74]
- generic [ref=e77]: Tasks
- link "Time Tracking" [ref=e79] [cursor=pointer]:
- /url: /app/time-tracking
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e84]: Time Tracking
- link "Calendar" [ref=e86] [cursor=pointer]:
- /url: /app/calendar
- generic [ref=e87]:
- img [ref=e88]
- generic [ref=e90]: Calendar
- link "Files" [ref=e92] [cursor=pointer]:
- /url: /app/files
- generic [ref=e93]:
- img [ref=e94]
- generic [ref=e96]: Files
- link "Notes" [ref=e98] [cursor=pointer]:
- /url: /app/notes
- generic [ref=e99]:
- img [ref=e100]
- generic [ref=e102]: Notes
- link "Messages" [ref=e104] [cursor=pointer]:
- /url: /app/messages
- generic [ref=e105]:
- img [ref=e106]
- generic [ref=e108]: Messages
- link "YouTube" [ref=e110] [cursor=pointer]:
- /url: /app/youtube
- generic [ref=e111]:
- img [ref=e112]
- generic [ref=e115]: YouTube
- link "Members" [ref=e117] [cursor=pointer]:
- /url: /app/members
- generic [ref=e118]:
- img [ref=e119]
- generic [ref=e124]: Members
- link "Learning" [ref=e126] [cursor=pointer]:
- /url: /app/learning-paths
- generic [ref=e127]:
- img [ref=e128]
- generic [ref=e131]: Learning
- link "Stats" [ref=e133] [cursor=pointer]:
- /url: /app/stats
- generic [ref=e134]:
- img [ref=e135]
- generic [ref=e137]: Stats
- link "GitHub" [ref=e139] [cursor=pointer]:
- /url: /app/github
- generic [ref=e140]:
- img [ref=e141]
- generic [ref=e143]: GitHub
- link "AI Assistant" [ref=e145] [cursor=pointer]:
- /url: /app/chat
- generic [ref=e146]:
- img [ref=e147]
- generic [ref=e154]: AI Assistant
- navigation [ref=e156]:
- link "Removed stuff" [ref=e157] [cursor=pointer]:
- /url: /app/removed-stuff
- generic [ref=e158]:
- img [ref=e159]
- generic [ref=e162]: Removed stuff
- link "Settings" [ref=e164] [cursor=pointer]:
- /url: /app/settings
- generic [ref=e165]:
- img [ref=e166]
- generic [ref=e169]: Settings
- button "Logout" [ref=e171] [cursor=pointer]:
- generic [ref=e172]:
- img [ref=e173]
- generic [ref=e177]: Logout
- generic [ref=e179]:
- generic [ref=e180]:
- generic [ref=e181]:
- button [ref=e182] [cursor=pointer]:
- img [ref=e183]
- button "Quick search" [ref=e184] [cursor=pointer]:
- img [ref=e185]
- text: Quick search
- generic [ref=e188]:
- button "Import a document" [ref=e189] [cursor=pointer]:
- img [ref=e190]
- text: Import a document
- button [ref=e194] [cursor=pointer]:
- img [ref=e195]
- img [ref=e200]
- button "AU" [ref=e204] [cursor=pointer]:
- generic [ref=e205]: AU
- img [ref=e206]
- main [ref=e208]:
- generic [ref=e210]:
- generic [ref=e211]:
- generic [ref=e213]:
- img [ref=e215]
- generic [ref=e218]:
- paragraph [ref=e219]: "0"
- paragraph [ref=e220]: Documents
- generic [ref=e222]:
- img [ref=e224]
- generic [ref=e226]:
- paragraph [ref=e227]: "0"
- paragraph [ref=e228]: Bookmarks
- generic [ref=e230]:
- img [ref=e232]
- generic [ref=e235]:
- paragraph [ref=e236]: "0"
- paragraph [ref=e237]: Tasks
- generic [ref=e239]:
- img [ref=e241]
- generic [ref=e243]:
- paragraph [ref=e244]: "0"
- paragraph [ref=e245]: Notes
- generic [ref=e246]:
- generic [ref=e248]:
- img [ref=e250]
- generic [ref=e253]:
- paragraph [ref=e254]: "0"
- paragraph [ref=e255]: Videos
- generic [ref=e257]:
- img [ref=e259]
- generic [ref=e262]:
- paragraph [ref=e263]: "0"
- paragraph [ref=e264]: Learning
- generic [ref=e266]:
- img [ref=e268]
- generic [ref=e271]:
- paragraph [ref=e272]: 0m
- paragraph [ref=e273]: Time
- generic [ref=e275]:
- img [ref=e277]
- generic [ref=e280]:
- paragraph [ref=e281]: 0%
- paragraph [ref=e282]: Productivity
- generic [ref=e284]:
- img [ref=e286]
- generic [ref=e288]:
- paragraph [ref=e289]: "0"
- paragraph [ref=e290]: Documents
- generic [ref=e292]:
- img [ref=e294]
- generic [ref=e296]:
- paragraph [ref=e297]: "0"
- paragraph [ref=e298]: Notes
- generic [ref=e299]:
- generic [ref=e300]:
- generic [ref=e301]:
- img [ref=e302]
- heading "Recent Achievements" [level=3] [ref=e305]
- paragraph [ref=e306]: No achievements yet.
- generic [ref=e307]:
- generic [ref=e308]:
- img [ref=e309]
- heading "Upcoming Deadlines" [level=3] [ref=e312]
- paragraph [ref=e313]: No upcoming deadlines.
- generic [ref=e314]:
- generic [ref=e315]:
- generic [ref=e316]:
- img [ref=e317]
- heading "Task Completion" [level=3] [ref=e319]
- generic [ref=e320]:
- generic [ref=e321]:
- generic [ref=e322]: Progress
- generic [ref=e323]: 0/0
- paragraph [ref=e325]: 0% completion rate
- generic [ref=e326]:
- generic [ref=e327]:
- paragraph [ref=e328]: "0"
- paragraph [ref=e329]: Completed
- generic [ref=e330]:
- paragraph [ref=e331]: "0"
- paragraph [ref=e332]: Active
- generic [ref=e333]:
- generic [ref=e334]:
- img [ref=e335]
- heading "Storage Usage" [level=3] [ref=e337]
- generic [ref=e338]:
- generic [ref=e339]:
- generic [ref=e340]: Used Space
- generic [ref=e341]: 0 MB
- paragraph [ref=e343]: 0% of 50 GB used
- generic [ref=e344]:
- generic [ref=e345]:
- img [ref=e346]
- heading "Weekly Activity" [level=3] [ref=e348]
- generic [ref=e349]:
- paragraph [ref=e351]: No activity data yet.
- generic [ref=e352]:
- generic [ref=e353]: "Total: 0 activities"
- generic [ref=e354]: "Avg: 0 per day"
- generic [ref=e355]:
- generic [ref=e356]:
- button "Upload documents Drag and drop or click to browse" [ref=e357] [cursor=pointer]:
- img [ref=e358]
- generic [ref=e361]:
- generic [ref=e362]: Upload documents
- generic [ref=e363]: Drag and drop or click to browse
- generic [ref=e364]:
- button "Save YouTube Video Save a YouTube video link" [ref=e365] [cursor=pointer]:
- img [ref=e366]
- generic [ref=e370]:
- generic [ref=e371]: Save YouTube Video
- generic [ref=e372]: Save a YouTube video link
- button "Add Bookmark Save web links" [ref=e373] [cursor=pointer]:
- img [ref=e374]
- generic [ref=e376]:
- generic [ref=e377]: Add Bookmark
- generic [ref=e378]: Save web links
- generic [ref=e379]:
- generic [ref=e381]:
- img [ref=e382]
- heading "GitHub Activity" [level=3] [ref=e384]
- paragraph [ref=e385]: No GitHub activity yet.
- generic [ref=e386]:
- generic [ref=e387]:
- generic [ref=e388]:
- img [ref=e389]
- heading "Activity Feed" [level=3] [ref=e392]
- button [ref=e393] [cursor=pointer]:
- img [ref=e394]
- generic [ref=e397]:
- generic [ref=e400]: (0 items)
- generic [ref=e401]:
- img [ref=e402]
- paragraph [ref=e405]: No activity yet
- paragraph [ref=e406]: Start using Trackeep to see your activity here
- generic [ref=e408]:
- generic [ref=e409]:
- img [ref=e410]
- heading "Popular Tags" [level=3] [ref=e413]
- paragraph [ref=e414]: No tags yet.
- heading "Latest imported documents" [level=2] [ref=e415]
- table [ref=e418]:
- rowgroup [ref=e419]:
- row "File name Actions" [ref=e420]:
- columnheader "File name" [ref=e421]
- columnheader [ref=e422]
- columnheader [ref=e423]
- columnheader "Actions" [ref=e424]:
- generic [ref=e425]: Actions
- rowgroup [ref=e426]:
- row "No documents yet." [ref=e427]:
- cell "No documents yet." [ref=e428]
- button "AI Assistant" [ref=e429] [cursor=pointer]:
- img [ref=e430]
- generic [ref=e437]:
- generic [ref=e438]:
- generic [ref=e439]:
- img [ref=e441]
- generic [ref=e448]:
- heading "AI Assistant" [level=3] [ref=e449]
- paragraph [ref=e450]: Always here to help
- button [ref=e452] [cursor=pointer]:
- img [ref=e453]
- generic [ref=e457]:
- img [ref=e459]
- generic [ref=e466]:
- paragraph [ref=e467]: Hello! I'm your AI assistant. How can I help you today?
- paragraph [ref=e469]: 01:18 PM
- generic [ref=e470]:
- generic [ref=e471]:
- textbox "Type your message..." [ref=e472]
- button [disabled]:
- img
- generic [ref=e474]:
- button "longcat icon LongCat" [ref=e476] [cursor=pointer]:
- img "longcat icon" [ref=e477]
- generic [ref=e478]: LongCat
- img [ref=e479]
- generic [ref=e481]:
- generic [ref=e482]: longcat
- link "AI settings" [ref=e483] [cursor=pointer]:
- /url: /app/settings#ai
- generic:
- generic:
- generic:
- generic:
- heading [level=3]
- generic: Unknown size
- button:
- img
- generic:
- generic: Unknown file type
- generic:
- button "Download":
- img
- text: Download
- button "Open":
- img
- text: Open
- generic:
- generic:
- generic:
- heading "Import Documents" [level=3]
- button:
- img
- generic:
- generic:
- img
- heading "Drop files here" [level=4]
- paragraph: or click to browse
- button "Browse Files"
- generic:
- button "Cancel"
- button "Upload 0 Files" [disabled]
- generic:
- generic:
- generic:
- heading "Add New Bookmark" [level=3]
- button:
- img
- generic:
- generic:
- textbox "URL *"
- textbox "Title (optional)"
- textbox "Description (optional)"
- generic:
- text: Tags
- generic:
- generic:
- textbox "Add tags..."
- generic:
- button "Cancel"
- button "Save Bookmark" [disabled]
- generic:
- generic:
- generic:
- heading "Add YouTube Video" [level=3]
- button:
- img
- generic:
- generic:
- text: YouTube URL
- textbox "https://www.youtube.com/watch?v=..."
- generic:
- text: Title (optional)
- textbox "Video title"
- generic:
- text: Description (optional)
- textbox "Video description"
- generic:
- text: Tags (comma-separated)
- textbox "tutorial, learning, tech"
- generic:
- button "Cancel"
- button "Add Video" [disabled]
- generic:
- generic:
- generic:
- generic:
- heading [level=3]
- generic: Unknown size
- button:
- img
- generic:
- generic: Unknown file type
- generic:
- button "Download":
- img
- text: Download
- button "Open":
- img
- text: Open
- generic:
- generic:
- generic:
- heading "Import Documents" [level=3]
- button:
- img
- generic:
- generic:
- img
- heading "Drop files here" [level=4]
- paragraph: or click to browse
- button "Browse Files"
- generic:
- button "Cancel"
- button "Upload 0 Files" [disabled]
@@ -1,202 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- generic [ref=e7]:
- link "Trackeep Logo Trackeep" [ref=e9] [cursor=pointer]:
- /url: /app
- img "Trackeep Logo" [ref=e10]
- generic [ref=e11]: Trackeep
- group [ref=e13]:
- button "Trackeep Workspace" [ref=e14] [cursor=pointer]:
- generic [ref=e15]:
- img [ref=e17]
- generic [ref=e20]: Trackeep Workspace
- img [ref=e22]
- navigation [ref=e24]:
- link "Home" [ref=e25] [cursor=pointer]:
- /url: /app
- generic [ref=e26]:
- img [ref=e27]
- generic [ref=e31]: Home
- link "Bookmarks" [ref=e33] [cursor=pointer]:
- /url: /app/bookmarks
- generic [ref=e34]:
- img [ref=e35]
- generic [ref=e37]: Bookmarks
- link "Tasks" [ref=e39] [cursor=pointer]:
- /url: /app/tasks
- generic [ref=e40]:
- img [ref=e41]
- generic [ref=e44]: Tasks
- link "Time Tracking" [ref=e46] [cursor=pointer]:
- /url: /app/time-tracking
- generic [ref=e47]:
- img [ref=e48]
- generic [ref=e51]: Time Tracking
- link "Calendar" [ref=e53] [cursor=pointer]:
- /url: /app/calendar
- generic [ref=e54]:
- img [ref=e55]
- generic [ref=e57]: Calendar
- link "Files" [ref=e59] [cursor=pointer]:
- /url: /app/files
- generic [ref=e60]:
- img [ref=e61]
- generic [ref=e63]: Files
- link "Notes" [ref=e65] [cursor=pointer]:
- /url: /app/notes
- generic [ref=e66]:
- img [ref=e67]
- generic [ref=e69]: Notes
- link "Messages" [ref=e71] [cursor=pointer]:
- /url: /app/messages
- generic [ref=e72]:
- img [ref=e73]
- generic [ref=e75]: Messages
- link "YouTube" [ref=e77] [cursor=pointer]:
- /url: /app/youtube
- generic [ref=e78]:
- img [ref=e79]
- generic [ref=e82]: YouTube
- link "Members" [ref=e84] [cursor=pointer]:
- /url: /app/members
- generic [ref=e85]:
- img [ref=e86]
- generic [ref=e91]: Members
- link "Learning" [ref=e93] [cursor=pointer]:
- /url: /app/learning-paths
- generic [ref=e94]:
- img [ref=e95]
- generic [ref=e98]: Learning
- link "Stats" [ref=e100] [cursor=pointer]:
- /url: /app/stats
- generic [ref=e101]:
- img [ref=e102]
- generic [ref=e104]: Stats
- link "GitHub" [ref=e106] [cursor=pointer]:
- /url: /app/github
- generic [ref=e107]:
- img [ref=e108]
- generic [ref=e110]: GitHub
- link "AI Assistant" [ref=e112] [cursor=pointer]:
- /url: /app/chat
- generic [ref=e113]:
- img [ref=e114]
- generic [ref=e121]: AI Assistant
- navigation [ref=e123]:
- link "Removed stuff" [ref=e124] [cursor=pointer]:
- /url: /app/removed-stuff
- generic [ref=e125]:
- img [ref=e126]
- generic [ref=e129]: Removed stuff
- link "Settings" [ref=e131] [cursor=pointer]:
- /url: /app/settings
- generic [ref=e132]:
- img [ref=e133]
- generic [ref=e136]: Settings
- button "Logout" [ref=e138] [cursor=pointer]:
- generic [ref=e139]:
- img [ref=e140]
- generic [ref=e144]: Logout
- generic [ref=e146]:
- generic [ref=e147]:
- generic [ref=e148]:
- button [ref=e149] [cursor=pointer]:
- img [ref=e150]
- button "Quick search" [ref=e151] [cursor=pointer]:
- img [ref=e152]
- text: Quick search
- generic [ref=e155]:
- button "Import a document" [ref=e156] [cursor=pointer]:
- img [ref=e157]
- text: Import a document
- button [ref=e161] [cursor=pointer]:
- img [ref=e162]
- img [ref=e167]
- button "AU" [ref=e171] [cursor=pointer]:
- generic [ref=e172]: AU
- img [ref=e173]
- main [ref=e175]:
- generic [ref=e177]:
- generic [ref=e178]:
- heading "Browser Extension Settings" [level=1] [ref=e179]
- paragraph [ref=e180]: Manage API keys and browser extensions for secure access to your Trackeep account.
- navigation [ref=e182]:
- button "Overview" [ref=e183] [cursor=pointer]:
- img [ref=e184]
- text: Overview
- button "API Keys" [ref=e187] [cursor=pointer]:
- img [ref=e188]
- text: API Keys
- button "Extensions" [ref=e192] [cursor=pointer]:
- img [ref=e193]
- text: Extensions
- button "Examples" [ref=e198] [cursor=pointer]:
- img [ref=e199]
- text: Examples
- generic [ref=e202]:
- heading "API Keys" [level=2] [ref=e203]
- button "Generate New Key" [ref=e204] [cursor=pointer]
- generic [ref=e206]:
- heading "Registered Extensions" [level=2] [ref=e207]
- paragraph [ref=e208]: Manage browser extensions that have access to your account.
- button "AI Assistant" [ref=e209] [cursor=pointer]:
- img [ref=e210]
- generic [ref=e217]:
- generic [ref=e218]:
- generic [ref=e219]:
- img [ref=e221]
- generic [ref=e228]:
- heading "AI Assistant" [level=3] [ref=e229]
- paragraph [ref=e230]: Always here to help
- button [ref=e232] [cursor=pointer]:
- img [ref=e233]
- generic [ref=e237]:
- img [ref=e239]
- generic [ref=e246]:
- paragraph [ref=e247]: Hello! I'm your AI assistant. How can I help you today?
- paragraph [ref=e249]: 01:23 PM
- generic [ref=e250]:
- generic [ref=e251]:
- textbox "Type your message..." [ref=e252]
- button [disabled]:
- img
- generic [ref=e254]:
- button "longcat icon LongCat" [ref=e256] [cursor=pointer]:
- img "longcat icon" [ref=e257]
- generic [ref=e258]: LongCat
- img [ref=e259]
- generic [ref=e261]:
- generic [ref=e262]: longcat
- link "AI settings" [ref=e263] [cursor=pointer]:
- /url: /app/settings#ai
- generic:
- alert [ref=e266]:
- generic [ref=e267]:
- img [ref=e269]
- paragraph [ref=e272]: Failed to load API keys
- button "Close" [ref=e274] [cursor=pointer]:
- generic [ref=e275]: Close
- img
- alert [ref=e280]:
- generic [ref=e281]:
- img [ref=e283]
- paragraph [ref=e286]: Failed to load extensions
- button "Close" [ref=e288] [cursor=pointer]:
- generic [ref=e289]: Close
- img
- generic:
- generic:
- generic:
- heading "Import Documents" [level=3]
- button:
- img
- generic:
- generic:
- img
- heading "Drop files here" [level=4]
- paragraph: or click to browse
- button "Browse Files"
- generic:
- button "Cancel"
- button "Upload 0 Files" [disabled]
@@ -1,186 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e4]:
- generic [ref=e7]:
- link "Trackeep Logo Trackeep" [ref=e9] [cursor=pointer]:
- /url: /app
- img "Trackeep Logo" [ref=e10]
- generic [ref=e11]: Trackeep
- group [ref=e13]:
- button "Trackeep Workspace" [ref=e14] [cursor=pointer]:
- generic [ref=e15]:
- img [ref=e17]
- generic [ref=e20]: Trackeep Workspace
- img [ref=e22]
- navigation [ref=e24]:
- link "Home" [ref=e25] [cursor=pointer]:
- /url: /app
- generic [ref=e26]:
- img [ref=e27]
- generic [ref=e31]: Home
- link "Bookmarks" [ref=e33] [cursor=pointer]:
- /url: /app/bookmarks
- generic [ref=e34]:
- img [ref=e35]
- generic [ref=e37]: Bookmarks
- link "Tasks" [ref=e39] [cursor=pointer]:
- /url: /app/tasks
- generic [ref=e40]:
- img [ref=e41]
- generic [ref=e44]: Tasks
- link "Time Tracking" [ref=e46] [cursor=pointer]:
- /url: /app/time-tracking
- generic [ref=e47]:
- img [ref=e48]
- generic [ref=e51]: Time Tracking
- link "Calendar" [ref=e53] [cursor=pointer]:
- /url: /app/calendar
- generic [ref=e54]:
- img [ref=e55]
- generic [ref=e57]: Calendar
- link "Files" [ref=e59] [cursor=pointer]:
- /url: /app/files
- generic [ref=e60]:
- img [ref=e61]
- generic [ref=e63]: Files
- link "Notes" [ref=e65] [cursor=pointer]:
- /url: /app/notes
- generic [ref=e66]:
- img [ref=e67]
- generic [ref=e69]: Notes
- link "Messages" [ref=e71] [cursor=pointer]:
- /url: /app/messages
- generic [ref=e72]:
- img [ref=e73]
- generic [ref=e75]: Messages
- link "YouTube" [ref=e77] [cursor=pointer]:
- /url: /app/youtube
- generic [ref=e78]:
- img [ref=e79]
- generic [ref=e82]: YouTube
- link "Members" [ref=e84] [cursor=pointer]:
- /url: /app/members
- generic [ref=e85]:
- img [ref=e86]
- generic [ref=e91]: Members
- link "Learning" [ref=e93] [cursor=pointer]:
- /url: /app/learning-paths
- generic [ref=e94]:
- img [ref=e95]
- generic [ref=e98]: Learning
- link "Stats" [ref=e100] [cursor=pointer]:
- /url: /app/stats
- generic [ref=e101]:
- img [ref=e102]
- generic [ref=e104]: Stats
- link "GitHub" [ref=e106] [cursor=pointer]:
- /url: /app/github
- generic [ref=e107]:
- img [ref=e108]
- generic [ref=e110]: GitHub
- link "AI Assistant" [ref=e112] [cursor=pointer]:
- /url: /app/chat
- generic [ref=e113]:
- img [ref=e114]
- generic [ref=e121]: AI Assistant
- navigation [ref=e123]:
- link "Removed stuff" [ref=e124] [cursor=pointer]:
- /url: /app/removed-stuff
- generic [ref=e125]:
- img [ref=e126]
- generic [ref=e129]: Removed stuff
- link "Settings" [ref=e131] [cursor=pointer]:
- /url: /app/settings
- generic [ref=e132]:
- img [ref=e133]
- generic [ref=e136]: Settings
- button "Logout" [ref=e138] [cursor=pointer]:
- generic [ref=e139]:
- img [ref=e140]
- generic [ref=e144]: Logout
- generic [ref=e146]:
- generic [ref=e147]:
- generic [ref=e148]:
- button [ref=e149] [cursor=pointer]:
- img [ref=e150]
- button "Quick search" [ref=e151] [cursor=pointer]:
- img [ref=e152]
- text: Quick search
- generic [ref=e155]:
- button "Import a document" [ref=e156] [cursor=pointer]:
- img [ref=e157]
- text: Import a document
- button [ref=e161] [cursor=pointer]:
- img [ref=e162]
- img [ref=e167]
- button "AU" [ref=e171] [cursor=pointer]:
- generic [ref=e172]: AU
- img [ref=e173]
- main [ref=e175]:
- generic [ref=e177]:
- generic [ref=e178]:
- heading "Browser Extension Settings" [level=1] [ref=e179]
- paragraph [ref=e180]: Manage API keys and browser extensions for secure access to your Trackeep account.
- navigation [ref=e182]:
- button "Overview" [ref=e183] [cursor=pointer]:
- img [ref=e184]
- text: Overview
- button "API Keys" [ref=e187] [cursor=pointer]:
- img [ref=e188]
- text: API Keys
- button "Extensions" [ref=e192] [cursor=pointer]:
- img [ref=e193]
- text: Extensions
- button "Examples" [ref=e198] [cursor=pointer]:
- img [ref=e199]
- text: Examples
- generic [ref=e202]:
- heading "API Keys" [level=2] [ref=e203]
- button "Generate New Key" [ref=e204] [cursor=pointer]
- generic [ref=e206]:
- heading "Registered Extensions" [level=2] [ref=e207]
- paragraph [ref=e208]: Manage browser extensions that have access to your account.
- button "AI Assistant" [ref=e209] [cursor=pointer]:
- img [ref=e210]
- generic [ref=e217]:
- generic [ref=e218]:
- generic [ref=e219]:
- img [ref=e221]
- generic [ref=e228]:
- heading "AI Assistant" [level=3] [ref=e229]
- paragraph [ref=e230]: Always here to help
- button [ref=e232] [cursor=pointer]:
- img [ref=e233]
- generic [ref=e237]:
- img [ref=e239]
- generic [ref=e246]:
- paragraph [ref=e247]: Hello! I'm your AI assistant. How can I help you today?
- paragraph [ref=e249]: 01:26 PM
- generic [ref=e250]:
- generic [ref=e251]:
- textbox "Type your message..." [ref=e252]
- button [disabled]:
- img
- generic [ref=e254]:
- button "longcat icon LongCat" [ref=e256] [cursor=pointer]:
- img "longcat icon" [ref=e257]
- generic [ref=e258]: LongCat
- img [ref=e259]
- generic [ref=e261]:
- generic [ref=e262]: longcat
- link "AI settings" [ref=e263] [cursor=pointer]:
- /url: /app/settings#ai
- generic:
- generic:
- generic:
- heading "Import Documents" [level=3]
- button:
- img
- generic:
- generic:
- img
- heading "Drop files here" [level=4]
- paragraph: or click to browse
- button "Browse Files"
- generic:
- button "Cancel"
- button "Upload 0 Files" [disabled]
@@ -1,256 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e4]:
- generic [ref=e7]:
- link "Trackeep Logo Trackeep" [ref=e9] [cursor=pointer]:
- /url: /app
- img "Trackeep Logo" [ref=e10]
- generic [ref=e11]: Trackeep
- group [ref=e13]:
- button "Trackeep Workspace" [ref=e14] [cursor=pointer]:
- generic [ref=e15]:
- img [ref=e17]
- generic [ref=e20]: Trackeep Workspace
- img [ref=e22]
- navigation [ref=e24]:
- link "Home" [ref=e25] [cursor=pointer]:
- /url: /app
- generic [ref=e26]:
- img [ref=e27]
- generic [ref=e31]: Home
- link "Bookmarks" [ref=e33] [cursor=pointer]:
- /url: /app/bookmarks
- generic [ref=e34]:
- img [ref=e35]
- generic [ref=e37]: Bookmarks
- link "Tasks" [ref=e39] [cursor=pointer]:
- /url: /app/tasks
- generic [ref=e40]:
- img [ref=e41]
- generic [ref=e44]: Tasks
- link "Time Tracking" [ref=e46] [cursor=pointer]:
- /url: /app/time-tracking
- generic [ref=e47]:
- img [ref=e48]
- generic [ref=e51]: Time Tracking
- link "Calendar" [ref=e53] [cursor=pointer]:
- /url: /app/calendar
- generic [ref=e54]:
- img [ref=e55]
- generic [ref=e57]: Calendar
- link "Files" [ref=e59] [cursor=pointer]:
- /url: /app/files
- generic [ref=e60]:
- img [ref=e61]
- generic [ref=e63]: Files
- link "Notes" [ref=e65] [cursor=pointer]:
- /url: /app/notes
- generic [ref=e66]:
- img [ref=e67]
- generic [ref=e69]: Notes
- link "Messages" [ref=e71] [cursor=pointer]:
- /url: /app/messages
- generic [ref=e72]:
- img [ref=e73]
- generic [ref=e75]: Messages
- link "YouTube" [ref=e77] [cursor=pointer]:
- /url: /app/youtube
- generic [ref=e78]:
- img [ref=e79]
- generic [ref=e82]: YouTube
- link "Members" [ref=e84] [cursor=pointer]:
- /url: /app/members
- generic [ref=e85]:
- img [ref=e86]
- generic [ref=e91]: Members
- link "Learning" [ref=e93] [cursor=pointer]:
- /url: /app/learning-paths
- generic [ref=e94]:
- img [ref=e95]
- generic [ref=e98]: Learning
- link "Stats" [ref=e100] [cursor=pointer]:
- /url: /app/stats
- generic [ref=e101]:
- img [ref=e102]
- generic [ref=e104]: Stats
- link "GitHub" [ref=e106] [cursor=pointer]:
- /url: /app/github
- generic [ref=e107]:
- img [ref=e108]
- generic [ref=e110]: GitHub
- link "AI Assistant" [ref=e112] [cursor=pointer]:
- /url: /app/chat
- generic [ref=e113]:
- img [ref=e114]
- generic [ref=e121]: AI Assistant
- navigation [ref=e123]:
- link "Removed stuff" [ref=e124] [cursor=pointer]:
- /url: /app/removed-stuff
- generic [ref=e125]:
- img [ref=e126]
- generic [ref=e129]: Removed stuff
- link "Settings" [ref=e131] [cursor=pointer]:
- /url: /app/settings
- generic [ref=e132]:
- img [ref=e133]
- generic [ref=e136]: Settings
- button "Logout" [ref=e138] [cursor=pointer]:
- generic [ref=e139]:
- img [ref=e140]
- generic [ref=e144]: Logout
- generic [ref=e146]:
- generic [ref=e147]:
- generic [ref=e148]:
- button [ref=e149] [cursor=pointer]:
- img [ref=e150]
- button "Quick search" [ref=e151] [cursor=pointer]:
- img [ref=e152]
- text: Quick search
- generic [ref=e155]:
- button "Import a document" [ref=e156] [cursor=pointer]:
- img [ref=e157]
- text: Import a document
- button [ref=e161] [cursor=pointer]:
- img [ref=e162]
- img [ref=e167]
- button "AU" [ref=e171] [cursor=pointer]:
- generic [ref=e172]: AU
- img [ref=e173]
- main [ref=e175]:
- generic [ref=e177]:
- generic [ref=e178]:
- heading "Bookmarks" [level=1] [ref=e180]
- button "Add Bookmark" [ref=e181] [cursor=pointer]:
- img [ref=e182]
- text: Add Bookmark
- navigation [ref=e185]:
- button "Web Bookmarks" [ref=e186] [cursor=pointer]:
- img [ref=e187]
- text: Web Bookmarks
- button "Video Bookmarks" [ref=e189] [cursor=pointer]:
- img [ref=e190]
- text: Video Bookmarks
- generic [ref=e194]:
- textbox "Search bookmarks..." [ref=e195]
- combobox [ref=e196]:
- option "All Tags" [selected]
- generic [ref=e199]:
- generic [ref=e200]:
- generic [ref=e203]:
- heading "Trackeep Updated Bookmark" [level=3] [ref=e204]:
- link "Trackeep Updated Bookmark" [ref=e205] [cursor=pointer]:
- /url: https://example.com
- text: Trackeep Updated Bookmark
- img [ref=e206]
- paragraph [ref=e210]: https://example.com
- paragraph [ref=e211]: updated
- generic [ref=e212]:
- generic [ref=e213]: 4/9/2026
- generic [ref=e214]:
- button "Mark as favorite" [ref=e215] [cursor=pointer]:
- img [ref=e216]
- button [ref=e220] [cursor=pointer]:
- img [ref=e221]
- button "AI Assistant" [ref=e225] [cursor=pointer]:
- img [ref=e226]
- generic [ref=e233]:
- generic [ref=e234]:
- generic [ref=e235]:
- img [ref=e237]
- generic [ref=e244]:
- heading "AI Assistant" [level=3] [ref=e245]
- paragraph [ref=e246]: Always here to help
- button [ref=e248] [cursor=pointer]:
- img [ref=e249]
- generic [ref=e253]:
- img [ref=e255]
- generic [ref=e262]:
- paragraph [ref=e263]: Hello! I'm your AI assistant. How can I help you today?
- paragraph [ref=e265]: 01:30 PM
- generic [ref=e266]:
- generic [ref=e267]:
- textbox "Type your message..." [ref=e268]
- button [disabled]:
- img
- generic [ref=e270]:
- button "longcat icon LongCat" [ref=e272] [cursor=pointer]:
- img "longcat icon" [ref=e273]
- generic [ref=e274]: LongCat
- img [ref=e275]
- generic [ref=e277]:
- generic [ref=e278]: longcat
- link "AI settings" [ref=e279] [cursor=pointer]:
- /url: /app/settings#ai
- generic:
- generic:
- generic:
- heading "Add New Bookmark" [level=3]
- button:
- img
- generic:
- generic:
- textbox "URL *"
- textbox "Title (optional)"
- textbox "Description (optional)"
- generic:
- text: Tags
- generic:
- generic:
- textbox "Add tags..."
- generic:
- button "Cancel"
- button "Save Bookmark" [disabled]
- generic:
- generic:
- generic:
- heading "Edit Bookmark" [level=3]
- button:
- img
- generic:
- textbox "URL *"
- textbox "Title"
- textbox "Description"
- generic:
- text: Tags
- generic:
- generic:
- textbox "Add tags..."
- generic:
- button "Cancel"
- button "Save Changes" [disabled]
- generic:
- generic:
- generic:
- heading "Add YouTube Video" [level=3]
- button:
- img
- generic:
- generic:
- text: YouTube URL
- textbox "https://www.youtube.com/watch?v=..."
- generic:
- text: Title (optional)
- textbox "Video title"
- generic:
- text: Description (optional)
- textbox "Video description"
- generic:
- text: Tags (comma-separated)
- textbox "tutorial, learning, tech"
- generic:
- button "Cancel"
- button "Add Video" [disabled]
- generic:
- generic:
- generic:
- heading "Import Documents" [level=3]
- button:
- img
- generic:
- generic:
- img
- heading "Drop files here" [level=4]
- paragraph: or click to browse
- button "Browse Files"
- generic:
- button "Cancel"
- button "Upload 0 Files" [disabled]
@@ -1,186 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e4]:
- generic [ref=e7]:
- link "Trackeep Logo Trackeep" [ref=e9] [cursor=pointer]:
- /url: /app
- img "Trackeep Logo" [ref=e10]
- generic [ref=e11]: Trackeep
- group [ref=e13]:
- button "Trackeep Workspace" [ref=e14] [cursor=pointer]:
- generic [ref=e15]:
- img [ref=e17]
- generic [ref=e20]: Trackeep Workspace
- img [ref=e22]
- navigation [ref=e24]:
- link "Home" [ref=e25] [cursor=pointer]:
- /url: /app
- generic [ref=e26]:
- img [ref=e27]
- generic [ref=e31]: Home
- link "Bookmarks" [ref=e33] [cursor=pointer]:
- /url: /app/bookmarks
- generic [ref=e34]:
- img [ref=e35]
- generic [ref=e37]: Bookmarks
- link "Tasks" [ref=e39] [cursor=pointer]:
- /url: /app/tasks
- generic [ref=e40]:
- img [ref=e41]
- generic [ref=e44]: Tasks
- link "Time Tracking" [ref=e46] [cursor=pointer]:
- /url: /app/time-tracking
- generic [ref=e47]:
- img [ref=e48]
- generic [ref=e51]: Time Tracking
- link "Calendar" [ref=e53] [cursor=pointer]:
- /url: /app/calendar
- generic [ref=e54]:
- img [ref=e55]
- generic [ref=e57]: Calendar
- link "Files" [ref=e59] [cursor=pointer]:
- /url: /app/files
- generic [ref=e60]:
- img [ref=e61]
- generic [ref=e63]: Files
- link "Notes" [ref=e65] [cursor=pointer]:
- /url: /app/notes
- generic [ref=e66]:
- img [ref=e67]
- generic [ref=e69]: Notes
- link "Messages" [ref=e71] [cursor=pointer]:
- /url: /app/messages
- generic [ref=e72]:
- img [ref=e73]
- generic [ref=e75]: Messages
- link "YouTube" [ref=e77] [cursor=pointer]:
- /url: /app/youtube
- generic [ref=e78]:
- img [ref=e79]
- generic [ref=e82]: YouTube
- link "Members" [ref=e84] [cursor=pointer]:
- /url: /app/members
- generic [ref=e85]:
- img [ref=e86]
- generic [ref=e91]: Members
- link "Learning" [ref=e93] [cursor=pointer]:
- /url: /app/learning-paths
- generic [ref=e94]:
- img [ref=e95]
- generic [ref=e98]: Learning
- link "Stats" [ref=e100] [cursor=pointer]:
- /url: /app/stats
- generic [ref=e101]:
- img [ref=e102]
- generic [ref=e104]: Stats
- link "GitHub" [ref=e106] [cursor=pointer]:
- /url: /app/github
- generic [ref=e107]:
- img [ref=e108]
- generic [ref=e110]: GitHub
- link "AI Assistant" [ref=e112] [cursor=pointer]:
- /url: /app/chat
- generic [ref=e113]:
- img [ref=e114]
- generic [ref=e121]: AI Assistant
- navigation [ref=e123]:
- link "Removed stuff" [ref=e124] [cursor=pointer]:
- /url: /app/removed-stuff
- generic [ref=e125]:
- img [ref=e126]
- generic [ref=e129]: Removed stuff
- link "Settings" [ref=e131] [cursor=pointer]:
- /url: /app/settings
- generic [ref=e132]:
- img [ref=e133]
- generic [ref=e136]: Settings
- button "Logout" [ref=e138] [cursor=pointer]:
- generic [ref=e139]:
- img [ref=e140]
- generic [ref=e144]: Logout
- generic [ref=e146]:
- generic [ref=e147]:
- generic [ref=e148]:
- button [ref=e149] [cursor=pointer]:
- img [ref=e150]
- button "Quick search" [ref=e151] [cursor=pointer]:
- img [ref=e152]
- text: Quick search
- generic [ref=e155]:
- button "Import a document" [ref=e156] [cursor=pointer]:
- img [ref=e157]
- text: Import a document
- button [ref=e161] [cursor=pointer]:
- img [ref=e162]
- img [ref=e167]
- button "AU" [ref=e171] [cursor=pointer]:
- generic [ref=e172]: AU
- img [ref=e173]
- main [ref=e175]:
- generic [ref=e177]:
- generic [ref=e178]:
- heading "Browser Extension Settings" [level=1] [ref=e179]
- paragraph [ref=e180]: Manage API keys and browser extensions for secure access to your Trackeep account.
- navigation [ref=e182]:
- button "Overview" [ref=e183] [cursor=pointer]:
- img [ref=e184]
- text: Overview
- button "API Keys" [ref=e187] [cursor=pointer]:
- img [ref=e188]
- text: API Keys
- button "Extensions" [ref=e192] [cursor=pointer]:
- img [ref=e193]
- text: Extensions
- button "Examples" [ref=e198] [cursor=pointer]:
- img [ref=e199]
- text: Examples
- generic [ref=e202]:
- heading "API Keys" [level=2] [ref=e203]
- button "Generate New Key" [ref=e204] [cursor=pointer]
- generic [ref=e206]:
- heading "Registered Extensions" [level=2] [ref=e207]
- paragraph [ref=e208]: Manage browser extensions that have access to your account.
- button "AI Assistant" [ref=e209] [cursor=pointer]:
- img [ref=e210]
- generic [ref=e217]:
- generic [ref=e218]:
- generic [ref=e219]:
- img [ref=e221]
- generic [ref=e228]:
- heading "AI Assistant" [level=3] [ref=e229]
- paragraph [ref=e230]: Always here to help
- button [ref=e232] [cursor=pointer]:
- img [ref=e233]
- generic [ref=e237]:
- img [ref=e239]
- generic [ref=e246]:
- paragraph [ref=e247]: Hello! I'm your AI assistant. How can I help you today?
- paragraph [ref=e249]: 01:30 PM
- generic [ref=e250]:
- generic [ref=e251]:
- textbox "Type your message..." [ref=e252]
- button [disabled]:
- img
- generic [ref=e254]:
- button "longcat icon LongCat" [ref=e256] [cursor=pointer]:
- img "longcat icon" [ref=e257]
- generic [ref=e258]: LongCat
- img [ref=e259]
- generic [ref=e261]:
- generic [ref=e262]: longcat
- link "AI settings" [ref=e263] [cursor=pointer]:
- /url: /app/settings#ai
- generic:
- generic:
- generic:
- heading "Import Documents" [level=3]
- button:
- img
- generic:
- generic:
- img
- heading "Drop files here" [level=4]
- paragraph: or click to browse
- button "Browse Files"
- generic:
- button "Cancel"
- button "Upload 0 Files" [disabled]
+67
View File
@@ -0,0 +1,67 @@
# Multi-stage build for unified Trackeep image
# Builds both frontend and backend in one package
# Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder
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 ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: Build Backend
FROM golang:1.25-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 3: Final unified image
FROM alpine:latest
# Install dependencies including PostgreSQL
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 --from=backend-builder /app/backend/main /app/main
COPY --from=backend-builder /app/backend/migrations /app/migrations
# Copy frontend build
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
# Copy branding assets
COPY trackeep.svg /usr/share/nginx/html/
COPY trackeepfavi.png /usr/share/nginx/html/
COPY trackeepfavi_bg.png /usr/share/nginx/html/
# Copy nginx configuration
COPY frontend/nginx.conf /etc/nginx/nginx.conf
# Create directories
RUN mkdir -p /app/uploads /data /var/log/nginx
# Expose single port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# Start script to run PostgreSQL, backend and nginx
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
-19
View File
@@ -8,7 +8,6 @@ This guide provides comprehensive instructions for deploying Trackeep to product
### System Requirements
- Docker 24.0+ and Docker Compose 2.20+
- PostgreSQL 15+
- DragonflyDB (Redis-compatible cache)
- 2GB+ RAM minimum (4GB+ recommended)
- 20GB+ disk space
@@ -22,11 +21,6 @@ DB_PASSWORD=<strong-password>
DB_NAME=trackeep
DB_SSL_MODE=disable
# DragonflyDB (Cache)
DRAGONFLY_ADDR=dragonfly:6379
DRAGONFLY_PASSWORD=<strong-password>
DRAGONFLY_PORT=6379
# Security
JWT_SECRET=<generate-with-openssl-rand-base64-32>
ENCRYPTION_KEY=<generate-with-openssl-rand-base64-32>
@@ -250,19 +244,6 @@ sqlDB.SetConnMaxLifetime(time.Hour)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
```
### DragonflyDB Configuration
```yaml
# docker-compose.prod.yml
dragonfly:
command: >
dragonfly
--requirepass=${DRAGONFLY_PASSWORD}
--proactor_threads=4
--maxmemory=2gb
--maxmemory-policy=allkeys-lru
```
### Frontend Optimization
```bash
-11
View File
@@ -45,7 +45,6 @@ cp .env.example .env
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
echo "DRAGONFLY_PASSWORD=$(openssl rand -base64 24)" >> .env
# Edit .env if needed
nano .env
@@ -332,16 +331,6 @@ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
### Cache Optimization
```bash
# Check DragonflyDB stats
docker-compose -f docker-compose.prod.yml exec dragonfly redis-cli -a $DRAGONFLY_PASSWORD INFO
# Clear cache if needed
docker-compose -f docker-compose.prod.yml exec dragonfly redis-cli -a $DRAGONFLY_PASSWORD FLUSHALL
```
## Monitoring
### Check Service Health
+60 -209
View File
@@ -33,184 +33,72 @@
## 🚀 Quick Start
### Production Deployment with Docker Compose
### One-Command Deployment (Docker Run)
PostgreSQL is bundled inside the image. Zero external dependencies.
```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
docker run -d \
--name trackeep \
-p 8080:8080 \
-e DB_PASSWORD=your_secure_password \
-e JWT_SECRET=$(openssl rand -hex 32) \
-v trackeep_postgres:/var/lib/postgresql/data \
-v trackeep_uploads:/app/uploads \
-v trackeep_data:/data \
ghcr.io/dvorinka/trackeep:latest
```
The `docker-compose.prod.yml` file uses environment variables with sensible defaults:
### CasaOS / Docker Compose (Copy-Paste Ready)
```yaml
icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
services:
trackeep-frontend:
image: 'ghcr.io/dvorinka/trackeep/frontend:latest'
trackeep:
image: ghcr.io/dvorinka/trackeep:latest
container_name: trackeep
ports:
- "${FRONTEND_PORT:-80}:${FRONTEND_PORT:-80}"
- "${HTTPS_PORT:-443}:443"
- "${HOST_PORT:-8080}:8080"
environment:
- NODE_ENV=production
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
- VITE_API_URL=${VITE_API_URL:-http://localhost:8080}
- FRONTEND_PORT=${FRONTEND_PORT:-80}
- BACKEND_PORT=${BACKEND_PORT:-8080}
depends_on:
- trackeep-backend
restart: unless-stopped
networks:
- trackeep-network
healthcheck:
test: ["CMD-SHELL", "pgrep nginx > /dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
trackeep-backend:
image: 'ghcr.io/dvorinka/trackeep/backend:latest'
ports:
- "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}"
environment:
- BACKEND_PORT=${BACKEND_PORT:-8080}
- FRONTEND_PORT=${FRONTEND_PORT:-80}
- GIN_MODE=${GIN_MODE:-release}
- DB_TYPE=${DB_TYPE:-postgres}
- DB_HOST=${DB_HOST:-postgres}
- DB_PORT=${DB_PORT:-5432}
- DB_USER=${DB_USER:-trackeep}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-trackeep}
- DB_SSL_MODE=${DB_SSL_MODE:-disable}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- UPLOAD_DIR=${UPLOAD_DIR:-./uploads}
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760}
- 'CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-*}'
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
- AUTO_UPDATE_CHECK=${AUTO_UPDATE_CHECK:-false}
- UPDATE_CHECK_INTERVAL=${UPDATE_CHECK_INTERVAL:-24h}
- PRERELEASE_UPDATES=${PRERELEASE_UPDATES:-false}
- DRAGONFLY_ADDR=${DRAGONFLY_ADDR:-dragonfly:6379}
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
DB_PASSWORD: ${DB_PASSWORD:-}
DB_USER: ${DB_USER:-trackeep}
DB_NAME: ${DB_NAME:-trackeep}
JWT_SECRET: ${JWT_SECRET:-}
GIN_MODE: release
volumes:
- './data:/data'
- './uploads:/app/uploads'
- './logs:/app/logs'
- '/var/run/docker.sock:/var/run/docker.sock'
- trackeep_postgres:/var/lib/postgresql/data
- trackeep_uploads:/app/uploads
- trackeep_data:/data
restart: unless-stopped
networks:
- trackeep-network
healthcheck:
test:
- CMD
- wget
- '--no-verbose'
- '--tries=1'
- '--spider'
- "http://localhost:${BACKEND_PORT:-8080}/health"
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
postgres:
image: 'postgres:15-alpine'
ports:
- "${DB_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${DB_NAME:-trackeep}
POSTGRES_USER: ${DB_USER:-trackeep}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- 'postgres_data:/var/lib/postgres/data'
restart: unless-stopped
networks:
- trackeep-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest
container_name: dragonfly
ports:
- "${DRAGONFLY_PORT:-6379}:6379"
volumes:
- dragonfly_data:/data
command: dragonfly --requirepass=${DRAGONFLY_PASSWORD} --proactor_threads=2
environment:
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
restart: unless-stopped
networks:
- trackeep-network
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${DRAGONFLY_PASSWORD} ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
postgres_data: null
dragonfly_data: null
networks:
trackeep-network:
driver: bridge
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 production deployment consists of **4 essential services**:
### Optional Environment Variables
#### **🎯 Frontend Service**
- **Image**: `ghcr.io/dvorinka/trackeep/frontend:latest`
- **Ports**: `${FRONTEND_PORT:-80}:${FRONTEND_PORT:-80}`, `${HTTPS_PORT:-443}:443`
- **Purpose**: Web interface and user experience
- **Health**: nginx process check
#### **🔧 Backend Service**
- **Image**: `ghcr.io/dvorinka/trackeep/backend:latest`
- **Ports**: `${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}`
- **Purpose**: API server and business logic
- **Health**: HTTP health check endpoint
#### **🗄️ Database Service**
- **Image**: `postgres:15-alpine`
- **Ports**: `${DB_PORT:-5432}:5432`
- **Purpose**: Data persistence and storage
- **Health**: PostgreSQL readiness check
- **Storage**: Persistent volume for data
#### **🐉 DragonflyDB Service**
- **Image**: `ghcr.io/dragonflydb/dragonfly:latest`
- **Ports**: `${DRAGONFLY_PORT:-6379}:6379`
- **Purpose**: In-memory caching and session storage
- **Health**: Redis-cli ping check
- **Storage**: Persistent volume for cache data
### Required Environment Variables
Create a `.env` file from the provided `.env.example` and configure these required variables:
All variables have sensible defaults. Only override what you need:
```env
# Database Configuration
DB_PASSWORD=your_secure_password
# Security Configuration
JWT_SECRET=your_jwt_secret_key
# DragonflyDB Configuration
DRAGONFLY_PASSWORD=your_dragonfly_password
HOST_PORT=8080
DB_PASSWORD=your_secure_password_here # auto-generated if empty
DB_USER=trackeep
DB_NAME=trackeep
JWT_SECRET=your_jwt_secret_here # auto-generated & persisted if empty
```
**Note:** The frontend automatically connects to the backend via nginx proxy — no `VITE_API_URL` or additional configuration needed.
### AI Services Configuration
AI services are now configured **only within the Trackeep application**. No environment variables are needed for AI configuration. Simply:
@@ -474,25 +362,15 @@ DISABLE_CHINESE_AI=true
cd Trackeep
```
2. **Configure environment**
2. **Start the container**
```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
```
4. **Access the application**
- Frontend: http://localhost:${FRONTEND_PORT:-80}
- Backend API: http://localhost:${BACKEND_PORT:-8080}
- Health Check: http://localhost:${BACKEND_PORT:-8080}/health
3. **Access the application**
- Application: http://localhost:8080
- Health Check: http://localhost:8080/health
- API: http://localhost:8080/api/
### Demo Login
- Email: `demo@trackeep.com`
@@ -516,8 +394,8 @@ trackeep/
├── scripts/ # Utility scripts
├── data/ # Data storage directory
├── uploads/ # File upload directory
├── docker-compose.yml # Multi-service orchestration
├── docker-compose.prod.yml # Production configuration
├── docker-compose.yml # Unified service orchestration
├── Dockerfile # Unified frontend + backend build
├── start.sh # Startup script
└── README.md
```
@@ -559,50 +437,23 @@ Additional documentation files:
### Environment Variables
Key environment variables to configure:
Only override what you need — everything else auto-configures:
```bash
# Server Configuration
FRONTEND_PORT=80
BACKEND_PORT=8080
VITE_API_URL=http://localhost:8080
GIN_MODE=release
# Host port for the application
HOST_PORT=8080
# Database Configuration
DB_TYPE=postgres
DB_HOST=postgres
DB_PORT=5432
# Database credentials (auto-generated if omitted)
DB_PASSWORD=your_secure_password_here
DB_USER=trackeep
DB_PASSWORD=your_password_here
DB_NAME=trackeep
DB_SSL_MODE=disable
# DragonflyDB Configuration
DRAGONFLY_ADDR=dragonfly:6379
DRAGONFLY_PORT=6379
DRAGONFLY_PASSWORD=your_dragonfly_password
# JWT Configuration
# Generate a secure 64-character hex string using: openssl rand -hex 32
# JWT Secret (auto-generated & persisted if omitted)
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
JWT_EXPIRES_IN=24h
# File Upload Configuration
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
# CORS Configuration
CORS_ALLOWED_ORIGINS=*
# Demo Mode Configuration
VITE_DEMO_MODE=false
# Auto Update Configuration
AUTO_UPDATE_CHECK=false
UPDATE_CHECK_INTERVAL=24h
PRERELEASE_UPDATES=false
```
**Note:** All other configuration has sensible defaults. The frontend automatically connects to the backend via nginx proxy — no additional API URL configuration needed.
## Contributing
Building Trackeep as a solo developer has been an incredible journey, but it's always better when we build together! Whether you're fixing a typo, adding a feature, or just sharing ideas your contribution matters.
+5 -5
View File
@@ -42,10 +42,10 @@ func Load() *Config {
return &Config{
Server: ServerConfig{
Port: getEnvWithDefault("PORT", getEnvWithDefault("BACKEND_PORT", "8080")),
ReadTimeout: getDurationEnv("READ_TIMEOUT", 15*time.Second),
WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 15*time.Second),
IdleTimeout: getDurationEnv("IDLE_TIMEOUT", 60*time.Second),
ShutdownTimeout: getDurationEnv("SHUTDOWN_TIMEOUT", 30*time.Second),
ReadTimeout: GetDurationEnv("READ_TIMEOUT", 15*time.Second),
WriteTimeout: GetDurationEnv("WRITE_TIMEOUT", 15*time.Second),
IdleTimeout: GetDurationEnv("IDLE_TIMEOUT", 60*time.Second),
ShutdownTimeout: GetDurationEnv("SHUTDOWN_TIMEOUT", 30*time.Second),
},
Database: DatabaseConfig{
Host: getEnvWithDefault("DB_HOST", "localhost"),
@@ -99,7 +99,7 @@ func getEnvWithDefault(key, defaultValue string) string {
return defaultValue
}
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return defaultValue
+1 -21
View File
@@ -11,7 +11,6 @@ import (
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
@@ -68,25 +67,6 @@ type Claims struct {
jwt.RegisteredClaims
}
// getDurationEnv parses duration from environment variable with fallback
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
seconds, err := strconv.Atoi(value)
if err != nil {
duration, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return duration
}
return time.Duration(seconds) * time.Second
}
// GenerateJWT creates a new JWT token for a user
func GenerateJWT(user models.User) (string, error) {
return generateJWT(user)
@@ -103,7 +83,7 @@ func generateJWT(user models.User) (string, error) {
Username: user.Username,
GitHubID: user.GitHubID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(getDurationEnv("JWT_EXPIRES_IN", 24*time.Hour))),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.GetDurationEnv("JWT_EXPIRES_IN", 24*time.Hour))),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "trackeep",
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

-52
View File
@@ -1,52 +0,0 @@
services:
trackeep-backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "${BACKEND_HOST_PORT:-9000}:${BACKEND_PORT:-8080}"
env_file:
- .env
environment:
- APP_VERSION=${APP_VERSION:-1.0.0}
- BACKEND_PORT=${BACKEND_PORT:-8080}
- FRONTEND_PORT=${FRONTEND_PORT:-3000}
- VITE_DEMO_MODE=true
volumes:
- ./data:/data
- ./uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for updates
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${BACKEND_PORT:-8080}/health || wget --no-verbose --tries=1 --spider http://localhost:${BACKEND_PORT:-8080}/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
trackeep-frontend:
build:
context: .
dockerfile: ./frontend/Dockerfile
args:
- VITE_DEMO_MODE=true
- VITE_API_URL=${VITE_API_URL:-http://localhost:${BACKEND_HOST_PORT:-9000}}
ports:
- "${FRONTEND_HOST_PORT:-3900}:${FRONTEND_PORT:-3000}"
environment:
- VITE_APP_VERSION=${APP_VERSION:-1.0.0}
- VITE_DEMO_MODE=true
- VITE_API_URL=${VITE_API_URL:-http://localhost:${BACKEND_HOST_PORT:-9000}}
- FRONTEND_PORT=${FRONTEND_PORT:-3000}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for updates
depends_on:
trackeep-backend:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pgrep nginx > /dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
-165
View File
@@ -1,165 +0,0 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: trackeep-postgres
environment:
POSTGRES_DB: ${DB_NAME:-trackeep}
POSTGRES_USER: ${DB_USER:-trackeep}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.UTF-8"
ports:
- "${DB_HOST_PORT:-5433}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups:/backups
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
networks:
- trackeep-network
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest
container_name: trackeep-dragonfly
ports:
- "${DRAGONFLY_HOST_PORT:-6380}:6379"
volumes:
- dragonfly_data:/data
command: >
dragonfly
--requirepass=${DRAGONFLY_PASSWORD:?DRAGONFLY_PASSWORD is required}
--proactor_threads=4
--maxmemory=2gb
--maxmemory-policy=allkeys-lru
--save_schedule=*:30
--dir=/data
environment:
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${DRAGONFLY_PASSWORD} ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- trackeep-network
deploy:
resources:
limits:
cpus: '1'
memory: 2G
reservations:
cpus: '0.25'
memory: 512M
trackeep-backend:
build:
context: ./backend
dockerfile: Dockerfile
args:
- GO_VERSION=1.22
container_name: trackeep-backend
ports:
- "${BACKEND_HOST_PORT:-8080}:${BACKEND_PORT:-8080}"
env_file:
- .env
environment:
- APP_VERSION=${APP_VERSION:-1.2.5}
- BACKEND_PORT=${BACKEND_PORT:-8080}
- FRONTEND_PORT=${FRONTEND_PORT:-80}
- GIN_MODE=release
- DB_HOST=postgres
- DRAGONFLY_ADDR=dragonfly:6379
volumes:
- ./uploads:/app/uploads
- ./logs:/app/logs
depends_on:
postgres:
condition: service_healthy
dragonfly:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${BACKEND_PORT:-8080}/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- trackeep-network
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
trackeep-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- NODE_VERSION=20
- VITE_API_URL=${VITE_API_URL:-http://localhost:${BACKEND_HOST_PORT:-8080}}
container_name: trackeep-frontend
ports:
- "${FRONTEND_HOST_PORT:-80}:80"
environment:
- VITE_API_URL=${VITE_API_URL:-http://localhost:${BACKEND_HOST_PORT:-8080}}
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
depends_on:
- trackeep-backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- trackeep-network
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
postgres_data:
driver: local
dragonfly_data:
driver: local
networks:
trackeep-network:
driver: bridge
+25 -89
View File
@@ -1,99 +1,35 @@
icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: ${DB_NAME:-trackeep}
POSTGRES_USER: ${DB_USER:-trackeep}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
trackeep:
image: ghcr.io/dvorinka/trackeep:latest
container_name: trackeep
ports:
- "${DB_HOST_PORT:-5433}:5432"
- "${HOST_PORT:-8080}:8080"
environment:
DB_PASSWORD: ${DB_PASSWORD:-}
DB_USER: ${DB_USER:-trackeep}
DB_NAME: ${DB_NAME:-trackeep}
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:
- postgres_data:/var/lib/postgres/data
- trackeep_postgres:/var/lib/postgresql/data
- trackeep_uploads:/app/uploads
- trackeep_data:/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
dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest
container_name: dragonfly
ports:
- "${DRAGONFLY_HOST_PORT:-6380}:6379"
volumes:
- dragonfly_data:/data
command: dragonfly --requirepass=${DRAGONFLY_PASSWORD} --proactor_threads=2
environment:
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${DRAGONFLY_PASSWORD} ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
trackeep-backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "${BACKEND_HOST_PORT:-9000}:${BACKEND_PORT:-8080}"
env_file:
- .env
environment:
- APP_VERSION=${APP_VERSION:-1.0.0}
- BACKEND_PORT=${BACKEND_PORT:-8080}
- FRONTEND_PORT=${FRONTEND_PORT:-3000}
- DRAGONFLY_ADDR=${DRAGONFLY_ADDR:-dragonfly:6379}
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
volumes:
- ./data:/data
- ./uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for updates
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
dragonfly:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${BACKEND_PORT:-8080}/health || wget --no-verbose --tries=1 --spider http://localhost:${BACKEND_PORT:-8080}/live"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
trackeep-frontend:
build:
context: .
dockerfile: ./frontend/Dockerfile
args:
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
- VITE_API_URL=${VITE_API_URL:-http://localhost:${BACKEND_HOST_PORT:-9000}}
ports:
- "${FRONTEND_HOST_PORT:-3900}:${FRONTEND_PORT:-3000}"
environment:
- VITE_APP_VERSION=${APP_VERSION:-1.0.0}
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
- VITE_API_URL=${VITE_API_URL:-http://localhost:${BACKEND_HOST_PORT:-9000}}
- FRONTEND_PORT=${FRONTEND_PORT:-3000}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for updates
depends_on:
trackeep-backend:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pgrep nginx > /dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
start_period: 60s
volumes:
postgres_data:
dragonfly_data:
trackeep_postgres:
trackeep_uploads:
trackeep_data:
+114
View File
@@ -0,0 +1,114 @@
#!/bin/sh
# All-in-one entrypoint for Trackeep
# Initializes and starts PostgreSQL, then backend + nginx
set -e
PGDATA=${PGDATA:-/var/lib/postgresql/data}
# Auto-generate DB_PASSWORD if not provided
if [ -z "$DB_PASSWORD" ]; then
DB_PASSWORD=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 32)
echo "========================================"
echo "WARNING: DB_PASSWORD was not set."
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 CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-*}
# Start backend in background
cd /app
echo "Starting Trackeep backend on port ${BACKEND_PORT}..."
./main &
BACKEND_PID=$!
# Wait for backend to be ready
echo "Waiting for backend to be ready..."
for i in $(seq 1 30); do
if wget --no-verbose --tries=1 --spider http://localhost:8081/health 2>/dev/null; then
echo "Backend is ready!"
break
fi
echo "Waiting... ($i/30)"
sleep 2
done
# Runtime environment variable injection for frontend.
# 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 in foreground (keeps container alive)
echo "Starting nginx on port 8080..."
nginx -g "daemon off;"
-112
View File
@@ -1,112 +0,0 @@
# DragonflyDB Configuration for Trackeep
#
# DragonflyDB is a modern Redis-compatible in-memory database
# Optimized for performance and lower memory usage
# =============================================================================
# NETWORK
# =============================================================================
# Accept connections on all interfaces (safe when behind Docker network)
bind 0.0.0.0
# Default port (same as Redis for compatibility)
port 6379
# TCP listen() backlog
tcp-backlog 511
# Close connection after N seconds of idle time (0 = disabled)
timeout 0
# TCP keepalive
tcp-keepalive 300
# =============================================================================
# SECURITY
# =============================================================================
# Require password for connections
# Set via environment variable: requirepass ${DRAGONFLY_PASSWORD}
requirepass dragonfly123
# Disable dangerous commands in production
rename-command FLUSHDB ""
rename-command FLUSHALL ""
rename-command CONFIG "CONFIG_9f8a2b3c"
rename-command DEBUG ""
rename-command SHUTDOWN "SHUTDOWN_7d4e1f9a"
# =============================================================================
# MEMORY MANAGEMENT
# =============================================================================
# Maximum memory limit (256MB suitable for small-medium deployments)
# DragonflyDB is more memory efficient than Redis
maxmemory 256mb
# Eviction policy when maxmemory is reached
# allkeys-lru: Remove less recently used keys first (recommended for caching)
maxmemory-policy allkeys-lru
# =============================================================================
# PERSISTENCE
# =============================================================================
# Enable AOF persistence (recommended for session durability)
appendonly yes
# AOF file name
appendfilename "appendonly.aof"
# Sync strategy: everysec (recommended balance)
appendfsync everysec
# Auto-rewrite AOF when it grows by X%
auto-aof-rewrite-percentage 100
# Minimum size before auto-rewrite
auto-aof-rewrite-min-size 64mb
# Working directory for persistence
dir /data
# =============================================================================
# CLIENTS & PERFORMANCE
# =============================================================================
# Maximum number of client connections
maxclients 10000
# Number of databases (default 16)
databases 16
# Latency monitoring
latency-monitor-threshold 100
# Slow log (log queries taking > N microseconds)
slowlog-log-slower-than 10000
# Slow log max length
slowlog-max-len 128
# =============================================================================
# LOGGING
# =============================================================================
# Log level: debug, verbose, notice, warning
loglevel notice
# Log file (empty = stdout, good for Docker)
logfile ""
# =============================================================================
# DRAGONFLYDB SPECIFIC OPTIMIZATIONS
# =============================================================================
# Enable DragonflyDB-specific optimizations
# These are automatically enabled in DragonflyDB
# Better memory management
# Improved multi-core utilization
# Enhanced performance for caching workloads
-1
View File
@@ -9,7 +9,6 @@
<meta name="theme-color" content="#39b9ff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trackeep - Your Self-Hosted Productivity & Knowledge Hub</title>
<link rel="stylesheet" crossorigin href="/assets/index-LnCEqXC_.css">
<script>
// Runtime environment variable injection
window.ENV = {
+29 -3
View File
@@ -39,7 +39,8 @@ http {
image/svg+xml;
server {
listen 80;
listen 8080;
listen [::]:8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
@@ -50,14 +51,39 @@ http {
add_header X-XSS-Protection "1; mode=block" 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
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend with retry logic
# 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)
location /api/ {
proxy_pass http://trackeep-backend:8080/;
proxy_pass http://localhost:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+26 -26
View File
@@ -3,33 +3,33 @@ import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { Layout } from '@/components/layout/Layout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { ToastContainer } from '@/components/ui/Toast'
import { Dashboard } from '@/pages/Dashboard'
import { Bookmarks } from '@/pages/Bookmarks'
import { Tasks } from '@/pages/Tasks'
import { Files } from '@/pages/Files'
import { Notes } from '@/pages/Notes'
import Chat from '@/pages/Chat'
import { Settings } from '@/pages/Settings'
import { Login } from '@/pages/Login'
import { Youtube } from '@/pages/Youtube'
import { Members } from '@/pages/Members'
import { RemovedStuff } from '@/pages/RemovedStuff'
import { AdminSettings } from '@/pages/AdminSettings'
import { ColorSwitcher } from '@/pages/ColorSwitcher'
import { AdminDashboard } from '@/pages/AdminDashboard'
import { Stats } from '@/pages/Stats'
import { Profile } from '@/pages/Profile'
import { LearningPaths } from '@/pages/LearningPaths'
import { GitHub } from '@/pages/GitHub'
import { TimeTracking } from '@/pages/TimeTracking'
import { Calendar } from '@/pages/Calendar'
import { AuthCallback } from '@/pages/AuthCallback'
import { Dashboard } from '@/pages/misc/Dashboard'
import { Bookmarks } from '@/pages/content/Bookmarks'
import { Tasks } from '@/pages/productivity/Tasks'
import { Files } from '@/pages/content/Files'
import { Notes } from '@/pages/content/Notes'
import Chat from '@/pages/communication/Chat'
import { Settings } from '@/pages/settings/Settings'
import { Login } from '@/pages/auth/Login'
import { Youtube } from '@/pages/content/Youtube'
import { Members } from '@/pages/admin/Members'
import { RemovedStuff } from '@/pages/misc/RemovedStuff'
import { AdminSettings } from '@/pages/admin/AdminSettings'
import { ColorSwitcher } from '@/pages/settings/ColorSwitcher'
import { AdminDashboard } from '@/pages/admin/AdminDashboard'
import { Stats } from '@/pages/productivity/Stats'
import { Profile } from '@/pages/auth/Profile'
import { LearningPaths } from '@/pages/content/LearningPaths'
import { GitHub } from '@/pages/content/GitHub'
import { TimeTracking } from '@/pages/productivity/TimeTracking'
import { Calendar } from '@/pages/productivity/Calendar'
import { AuthCallback } from '@/pages/auth/AuthCallback'
import { AuthProvider, useAuth } from '@/lib/auth'
import { Search } from '@/pages/Search'
import { Analytics } from '@/pages/Analytics'
import { Messages } from '@/pages/Messages'
import { ShareTarget } from '@/pages/ShareTarget'
import BrowserExtensionSettings from '@/pages/BrowserExtensionSettings'
import { Search } from '@/pages/content/Search'
import { Analytics } from '@/pages/admin/Analytics'
import { Messages } from '@/pages/communication/Messages'
import { ShareTarget } from '@/pages/misc/ShareTarget'
import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings'
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
import { onMount, createEffect } from 'solid-js'
import { useNavigate } from '@solidjs/router'
+8 -7
View File
@@ -1,5 +1,6 @@
import { createSignal, onMount, Show, For } from 'solid-js';
import { Button } from './ui/Button';
import { getApiOrigin } from '@/lib/api-url';
interface TOTPSetupResponse {
secret: string;
@@ -42,7 +43,7 @@ export function TwoFactorAuth() {
const fetchTOTPStatus = async () => {
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(),
});
@@ -66,7 +67,7 @@ export function TwoFactorAuth() {
setSuccess(null);
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',
headers: getAuthHeaders(),
body: JSON.stringify({
@@ -102,7 +103,7 @@ export function TwoFactorAuth() {
setError(null);
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',
headers: getAuthHeaders(),
body: JSON.stringify({
@@ -135,7 +136,7 @@ export function TwoFactorAuth() {
setSuccess(null);
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',
headers: getAuthHeaders(),
body: JSON.stringify({
@@ -171,7 +172,7 @@ export function TwoFactorAuth() {
setSuccess(null);
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',
headers: getAuthHeaders(),
body: JSON.stringify({
@@ -206,7 +207,7 @@ export function TwoFactorAuth() {
setError(null);
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',
headers: getAuthHeaders(),
body: JSON.stringify({
@@ -240,7 +241,7 @@ export function TwoFactorAuth() {
setSuccess(null);
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',
headers: getAuthHeaders(),
body: JSON.stringify({
@@ -1,188 +0,0 @@
import { createSignal, Show } from 'solid-js'
import { IconX, IconSend, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
import longcatIcon from '@/assets/longcat-color.svg'
import { ModalPortal } from '@/components/ui/ModalPortal'
interface FloatingAIProps {
onToggleChat: () => void
isChatOpen: boolean
}
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
}
export function FloatingAI(props: FloatingAIProps) {
const [messages, setMessages] = createSignal<Message[]>([
{
id: '1',
role: 'assistant',
content: 'Hello! I\'m your AI assistant. How can I help you today?',
timestamp: new Date()
}
])
const [inputValue, setInputValue] = createSignal('')
const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat')
const [showModelSelector, setShowModelSelector] = createSignal(false)
const aiModels = [
{ id: 'longcat-flash-chat', name: 'LongCat Flash', description: 'Fast and efficient' },
{ id: 'gpt-4', name: 'GPT-4', description: 'Most capable' },
{ id: 'claude-3', name: 'Claude 3', description: 'Balanced performance' }
]
const handleSendMessage = () => {
const value = inputValue().trim()
if (!value) return
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: value,
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setInputValue('')
// Simulate AI response
setTimeout(() => {
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'I understand your question. Let me help you with that...',
timestamp: new Date()
}
setMessages(prev => [...prev, aiMessage])
}, 1000)
}
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
return (
<>
{/* Floating AI Button */}
<button
onClick={props.onToggleChat}
class="fixed bottom-6 right-8 z-40 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-all duration-200 hover:scale-110 w-14 h-14"
title="AI Assistant"
>
<img src={longcatIcon} alt="AI Assistant" class="size-6" />
</button>
{/* AI Chat Modal */}
<Show when={props.isChatOpen}>
<ModalPortal>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full max-h-[600px] flex flex-col" style="width: 420px;">
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-border bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center p-3 rounded-lg bg-primary/20">
<img src={longcatIcon} alt="AI Assistant" class="size-5" />
</div>
<div>
<h3 class="font-semibold text-foreground">AI Assistant</h3>
<div class="flex items-center gap-2">
<p class="text-xs text-muted-foreground">Always here to help</p>
<div class="relative">
<button
onClick={() => setShowModelSelector(!showModelSelector())}
class="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
{aiModels.find(m => m.id === selectedModel())?.name || 'LongCat Flash'}
<IconChevronDown class="size-3" />
</button>
{/* Model Selector Dropdown */}
<Show when={showModelSelector()}>
<div class="absolute bottom-full left-0 mb-2 w-48 bg-popover border border-border rounded-md shadow-lg z-10">
{aiModels.map((model) => (
<button
onClick={() => {
setSelectedModel(model.id)
setShowModelSelector(false)
}}
class="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors first:rounded-t-md last:rounded-b-md"
>
<div class="font-medium">{model.name}</div>
<div class="text-xs text-muted-foreground">{model.description}</div>
</button>
))}
</div>
</Show>
</div>
</div>
</div>
</div>
<button
onClick={props.onToggleChat}
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"
>
<IconX class="size-4 text-foreground" />
</button>
</div>
{/* Messages */}
<div class="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-background to-muted/20" style="max-height: 400px;">
{messages().map((message) => (
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-in slide-in-from-bottom-2 duration-200`}>
{message.role === 'assistant' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10 flex-shrink-0">
<img src={longcatIcon} alt="AI Assistant" class="size-4" />
</div>
)}
<div class={`max-w-[300px] rounded-lg p-3 shadow-sm ${
message.role === 'user'
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted border border-border'
}`}>
<p class="text-sm leading-relaxed">{message.content}</p>
<p class="text-xs opacity-70 mt-2">
{message.timestamp.toLocaleTimeString()}
</p>
</div>
{message.role === 'user' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-primary flex-shrink-0">
<IconUser class="size-4 text-primary-foreground" />
</div>
)}
</div>
))}
</div>
{/* Input */}
<div class="p-4 border-t border-border bg-muted/30">
<div class="flex gap-2">
<input
type="text"
value={inputValue()}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
class="flex-1 h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
onClick={handleSendMessage}
disabled={!inputValue().trim()}
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 hover:shadow-md h-10 px-4"
>
<IconSend class="size-4" />
</button>
</div>
</div>
</div>
</div>
</ModalPortal>
</Show>
</>
)
}
+2 -2
View File
@@ -182,10 +182,10 @@ export function Layout(props: LayoutProps) {
{/* Main Content */}
<div class="flex-1 min-h-0 flex flex-col">
{/* Header */}
<Header title={props.title} onMenuClick={toggleSidebar} />
{!props.fullBleed && <Header title={props.title} onMenuClick={toggleSidebar} />}
{/* 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"}>
{resolved()}
</div>
@@ -43,8 +43,6 @@ export const BrowserSearch = () => {
try {
const isDemoMode = isDemo();
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}`);
// Always use backend API for search to avoid CORS issues
const API_BASE_URL = getApiBaseUrl();
const token = localStorage.getItem('token') ||
@@ -1,4 +1,5 @@
import { createSignal, For, Show, onMount } from 'solid-js';
import { getApiOrigin } from '@/lib/api-url';
import { useSearchParams } from '@solidjs/router';
import {
IconSearch,
@@ -118,7 +119,7 @@ export const EnhancedSearch = () => {
if (currentFilters.search_mode === 'semantic') {
// 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',
headers: {
'Content-Type': 'application/json',
@@ -145,7 +146,7 @@ export const EnhancedSearch = () => {
}
} else {
// 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',
headers: {
'Content-Type': 'application/json',
@@ -1,4 +1,5 @@
import { createSignal, For, Show, onMount } from 'solid-js';
import { getApiOrigin } from '@/lib/api-url';
import {
IconBookmark,
IconSearch,
@@ -61,7 +62,7 @@ export const SavedSearches = () => {
setLoading(true);
try {
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: {
'Authorization': `Bearer ${token}`
}
@@ -82,7 +83,7 @@ export const SavedSearches = () => {
const loadTags = async () => {
try {
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: {
'Authorization': `Bearer ${token}`
}
@@ -141,7 +142,7 @@ export const SavedSearches = () => {
try {
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',
headers: {
'Authorization': `Bearer ${token}`
@@ -160,7 +161,7 @@ export const SavedSearches = () => {
const runSavedSearch = async (id: number) => {
try {
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',
headers: {
'Authorization': `Bearer ${token}`
-248
View File
@@ -1,248 +0,0 @@
import { createSignal, Show } from 'solid-js'
import { Button } from './Button'
import { IconDownload, IconUpload, IconFileText, IconAlertTriangle, IconCheck } from '@tabler/icons-solidjs'
import { exportData as exportDataUtil, importData as importDataUtil, validateImportData, getImportSummary, type ExportData } from '@/lib/export-import'
export interface ExportImportProps {
data?: {
bookmarks?: any[]
tasks?: any[]
notes?: any[]
files?: any[]
}
onImport?: (data: ExportData) => Promise<void>
disabled?: boolean
}
export const ExportImport = (props: ExportImportProps) => {
const [isImporting, setIsImporting] = createSignal(false)
const [importStatus, setImportStatus] = createSignal<'idle' | 'validating' | 'success' | 'error'>('idle')
const [importMessage, setImportMessage] = createSignal('')
const [importData, setImportData] = createSignal<ExportData | null>(null)
const handleExport = async (type?: 'bookmarks' | 'tasks' | 'notes' | 'files' | 'all') => {
try {
let exportDataPayload = {}
let filename = ''
if (type === 'all' || !type) {
exportDataPayload = props.data || {}
filename = `trackeep-full-export-${new Date().toISOString().split('T')[0]}.json`
} else {
exportDataPayload = { [type]: props.data?.[type] || [] }
filename = `trackeep-${type}-export-${new Date().toISOString().split('T')[0]}.json`
}
await exportDataUtil(exportDataPayload, filename)
} catch (error) {
console.error('Export failed:', error)
alert('Export failed. Please try again.')
}
}
const handleFileSelect = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
setIsImporting(true)
setImportStatus('validating')
setImportMessage('Reading and validating file...')
try {
const data = await importDataUtil(file)
const validation = validateImportData(data)
if (!validation.isValid) {
setImportStatus('error')
setImportMessage(`Validation failed: ${validation.errors.join(', ')}`)
return
}
setImportData(data)
setImportStatus('success')
setImportMessage(getImportSummary(data))
} catch (error) {
setImportStatus('error')
setImportMessage((error as Error).message)
} finally {
setIsImporting(false)
}
}
const handleImport = async () => {
const data = importData()
if (!data || !props.onImport) return
try {
await props.onImport(data)
setImportStatus('idle')
setImportMessage('Import completed successfully!')
setImportData(null)
// Reset file input
const fileInput = document.getElementById('import-file-input') as HTMLInputElement
if (fileInput) fileInput.value = ''
} catch (error) {
setImportStatus('error')
setImportMessage(`Import failed: ${(error as Error).message}`)
}
}
const resetImport = () => {
setImportStatus('idle')
setImportMessage('')
setImportData(null)
// Reset file input
const fileInput = document.getElementById('import-file-input') as HTMLInputElement
if (fileInput) fileInput.value = ''
}
return (
<div class="space-y-6">
{/* Export Section */}
<div>
<h3 class="text-lg font-medium text-white mb-4 flex items-center">
<IconDownload class="mr-2 h-5 w-5" />
Export Data
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<Button
variant="outline"
size="sm"
onClick={() => handleExport('all')}
disabled={props.disabled}
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
>
<IconFileText class="mr-2 h-4 w-4" />
Export All
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport('bookmarks')}
disabled={props.disabled}
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
>
Bookmarks
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport('tasks')}
disabled={props.disabled}
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
>
Tasks
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport('notes')}
disabled={props.disabled}
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
>
Notes
</Button>
</div>
</div>
{/* Import Section */}
<div>
<h3 class="text-lg font-medium text-white mb-4 flex items-center">
<IconUpload class="mr-2 h-5 w-5" />
Import Data
</h3>
{/* File Input */}
<div class="mb-4">
<input
id="import-file-input"
type="file"
accept=".json"
onChange={handleFileSelect}
disabled={props.disabled || isImporting()}
class="block w-full text-sm text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* Import Status */}
<Show when={importStatus() !== 'idle'}>
<div class={`p-4 rounded-lg mb-4 ${
importStatus() === 'success'
? 'bg-green-900/20 border border-green-700/50 text-green-300'
: importStatus() === 'error'
? 'bg-red-900/20 border border-red-700/50 text-red-300'
: 'bg-blue-900/20 border border-blue-700/50 text-blue-300'
}`}>
<div class="flex items-start">
<Show
when={importStatus() === 'success'}
fallback={<IconAlertTriangle class="mr-2 h-5 w-5 flex-shrink-0 mt-0.5" />}
>
<IconCheck class="mr-2 h-5 w-5 flex-shrink-0 mt-0.5" />
</Show>
<div class="flex-1">
<p class="font-medium">
{importStatus() === 'validating' ? 'Validating...' :
importStatus() === 'success' ? 'File Valid' :
'Import Error'}
</p>
<p class="text-sm mt-1">{importMessage()}</p>
</div>
</div>
</div>
</Show>
{/* Import Actions */}
<Show when={importStatus() === 'success' && props.onImport}>
<div class="flex space-x-3">
<Button
onClick={handleImport}
disabled={isImporting()}
class="bg-blue-600 hover:bg-blue-700"
>
{isImporting() ? 'Importing...' : 'Import Data'}
</Button>
<Button
variant="outline"
onClick={resetImport}
disabled={isImporting()}
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
>
Cancel
</Button>
</div>
</Show>
{/* Import Preview */}
<Show when={importData() && importStatus() === 'success'}>
<div class="mt-4 p-4 bg-gray-800 border border-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-white mb-2">Import Preview</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-gray-400">Bookmarks:</span>
<span class="ml-2 text-white">{importData()!.bookmarks.length}</span>
</div>
<div>
<span class="text-gray-400">Tasks:</span>
<span class="ml-2 text-white">{importData()!.tasks.length}</span>
</div>
<div>
<span class="text-gray-400">Notes:</span>
<span class="ml-2 text-white">{importData()!.notes.length}</span>
</div>
<div>
<span class="text-gray-400">Files:</span>
<span class="ml-2 text-white">{importData()!.files.length}</span>
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
Export date: {new Date(importData()!.exportDate).toLocaleDateString()}
</div>
</div>
</Show>
</div>
</div>
)
}
@@ -1,392 +0,0 @@
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import { ModalPortal } from '@/components/ui/ModalPortal';
import {
IconX,
IconUpload,
IconLink,
IconTag,
IconFileText,
IconPhoto,
IconVideo,
IconMusic,
IconFolder
} from '@tabler/icons-solidjs';
interface FileUploadModalProps {
isOpen: boolean;
onClose: () => void;
onUpload: (fileData: any) => void;
}
interface Association {
id: string;
type: 'task' | 'bookmark' | 'note' | 'project';
title: string;
}
export const FileUploadModal = (props: FileUploadModalProps) => {
const [selectedFile, setSelectedFile] = createSignal<File | null>(null);
const [description, setDescription] = createSignal('');
const [tags, setTags] = createSignal<string[]>([]);
const [tagInput, setTagInput] = createSignal('');
const [associations, setAssociations] = createSignal<Association[]>([]);
const [linkUrl, setLinkUrl] = createSignal('');
const [isLinkMode, setIsLinkMode] = createSignal(false);
const [dragActive, setDragActive] = createSignal(false);
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.isOpen) {
props.onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
onCleanup(() => {
window.removeEventListener('keydown', handleKeyDown);
});
});
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
setSelectedFile(target.files[0]);
setIsLinkMode(false);
}
};
const handleDrag = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
setSelectedFile(e.dataTransfer.files[0]);
setIsLinkMode(false);
}
};
const addTag = () => {
const tag = tagInput().trim();
if (tag && !tags().includes(tag)) {
setTags([...tags(), tag]);
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags().filter(tag => tag !== tagToRemove));
};
const addAssociation = (type: Association['type']) => {
// Mock association - in real app, this would open a picker
const mockAssociation: Association = {
id: Date.now().toString(),
type,
title: `Sample ${type} ${Date.now()}`
};
setAssociations([...associations(), mockAssociation]);
};
const removeAssociation = (id: string) => {
setAssociations(associations().filter(assoc => assoc.id !== id));
};
const handleUpload = () => {
const fileData = {
file: selectedFile(),
linkUrl: linkUrl(),
description: description(),
tags: tags(),
associations: associations(),
isLinkMode: isLinkMode()
};
props.onUpload(fileData);
props.onClose();
// Reset form
setSelectedFile(null);
setDescription('');
setTags([]);
setTagInput('');
setAssociations([]);
setLinkUrl('');
setIsLinkMode(false);
};
const getFileIcon = (file?: File) => {
if (!file) return IconFolder;
if (file.type.startsWith('image/')) return IconPhoto;
if (file.type.startsWith('video/')) return IconVideo;
if (file.type.startsWith('audio/')) return IconMusic;
return IconFileText;
};
const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
const canUpload = () => {
if (isLinkMode()) {
return linkUrl() && isValidUrl(linkUrl());
}
return selectedFile() !== null;
};
return (
<ModalPortal>
<Show when={props.isOpen}>
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={props.onClose}
>
<div
class="bg-card rounded-lg border border-border p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">Upload File</h2>
<Button variant="ghost" onClick={props.onClose}>
<IconX class="size-4" />
</Button>
</div>
{/* Upload Mode Toggle */}
<div class="flex gap-2 mb-6">
<Button
variant={!isLinkMode() ? "default" : "outline"}
onClick={() => setIsLinkMode(false)}
class="flex-1"
>
<IconUpload class="size-4 mr-2" />
Upload File
</Button>
<Button
variant={isLinkMode() ? "default" : "outline"}
onClick={() => setIsLinkMode(true)}
class="flex-1"
>
<IconLink class="size-4 mr-2" />
Add Link
</Button>
</div>
{/* File Upload Area */}
<Show when={!isLinkMode()}>
<Card class="p-8 mb-6">
<div
class={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragActive()
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
onChange={handleFileSelect}
class="hidden"
id="file-input"
/>
<Show
when={selectedFile()}
fallback={
<div>
<IconUpload class="size-12 mx-auto mb-4 text-muted-foreground" />
<p class="text-lg font-medium mb-2">Drop file here or click to browse</p>
<p class="text-sm text-muted-foreground mb-4">
Supports all file types
</p>
<Button onClick={() => document.getElementById('file-input')?.click()}>
Choose File
</Button>
</div>
}
>
<div class="flex items-center gap-4 justify-center">
<div class="text-4xl text-primary">
{(() => {
const IconComponent = getFileIcon(selectedFile()!);
return <IconComponent size={48} />;
})()}
</div>
<div class="text-left">
<p class="font-medium">{selectedFile()!.name}</p>
<p class="text-sm text-muted-foreground">
{(selectedFile()!.size / 1024 / 1024).toFixed(2)} MB
</p>
<Button
variant="ghost"
size="sm"
onClick={() => document.getElementById('file-input')?.click()}
class="mt-2"
>
Change File
</Button>
</div>
</div>
</Show>
</div>
</Card>
</Show>
{/* Link Input */}
<Show when={isLinkMode()}>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">File URL</label>
<Input
type="url"
placeholder="https://example.com/file.pdf"
value={linkUrl()}
onInput={(e: any) => setLinkUrl(e.currentTarget.value)}
class="w-full"
/>
</div>
</Show>
{/* Description */}
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Description</label>
<textarea
class="w-full px-3 py-2 border border-border rounded-lg bg-background resize-none"
rows={3}
placeholder="Optional description..."
value={description()}
onInput={(e: any) => setDescription(e.currentTarget.value)}
/>
</div>
{/* Tags */}
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Tags</label>
<div class="flex gap-2 mb-3">
<Input
type="text"
placeholder="Add tag..."
value={tagInput()}
onInput={(e: any) => setTagInput(e.currentTarget.value)}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
class="flex-1"
/>
<Button onClick={addTag} disabled={!tagInput().trim()}>
<IconTag class="size-4" />
</Button>
</div>
<div class="flex flex-wrap gap-2">
<For each={tags()}>
{(tag) => (
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm">
{tag}
<Button
variant="ghost"
size="sm"
onClick={() => removeTag(tag)}
class="h-4 w-4 p-0 hover:bg-primary/20"
>
<IconX class="size-3" />
</Button>
</span>
)}
</For>
</div>
</div>
{/* Associations */}
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Link to</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3">
<Button
variant="outline"
size="sm"
onClick={() => addAssociation('task')}
>
Task
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addAssociation('bookmark')}
>
Bookmark
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addAssociation('note')}
>
Note
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addAssociation('project')}
>
Project
</Button>
</div>
<div class="space-y-2">
<For each={associations()}>
{(assoc) => (
<div class="flex items-center justify-between p-2 bg-muted rounded-md">
<span class="text-sm">
<span class="font-medium capitalize">{assoc.type}:</span> {assoc.title}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeAssociation(assoc.id)}
class="h-6 w-6 p-0"
>
<IconX class="size-3" />
</Button>
</div>
)}
</For>
</div>
</div>
{/* Actions */}
<div class="flex gap-3 pt-4 border-t border-border">
<Button variant="outline" onClick={props.onClose} class="flex-1">
Cancel
</Button>
<Button onClick={handleUpload} disabled={!canUpload()} class="flex-1">
Upload
</Button>
</div>
</div>
</div>
</Show>
</ModalPortal>
);
};
@@ -1,273 +0,0 @@
import { createSignal } from 'solid-js';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { ModalPortal } from '@/components/ui/ModalPortal';
import { IconX } from '@tabler/icons-solidjs';
interface LearningPathFormData {
title: string;
description: string;
category: string;
difficulty: 'beginner' | 'intermediate' | 'advanced';
duration: string;
thumbnail?: string;
is_featured?: boolean;
}
interface LearningPathModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (learningPath: LearningPathFormData) => Promise<void>;
learningPath?: LearningPathFormData | null;
isEdit?: boolean;
}
export const LearningPathModal = (props: LearningPathModalProps) => {
const [learningPathData, setLearningPathData] = createSignal<LearningPathFormData>({
title: '',
description: '',
category: '',
difficulty: 'beginner',
duration: '',
thumbnail: '',
is_featured: false
});
const [isSubmitting, setIsSubmitting] = createSignal(false);
// Reset form when modal opens/closes or learningPath changes
const resetForm = () => {
if (props.learningPath && props.isEdit) {
setLearningPathData({
title: props.learningPath.title,
description: props.learningPath.description,
category: props.learningPath.category,
difficulty: props.learningPath.difficulty,
duration: props.learningPath.duration,
thumbnail: props.learningPath.thumbnail || '',
is_featured: props.learningPath.is_featured || false
});
} else {
setLearningPathData({
title: '',
description: '',
category: '',
difficulty: 'beginner',
duration: '',
thumbnail: '',
is_featured: false
});
}
};
// Reset form when modal opens/closes
if (props.isOpen) {
resetForm();
}
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!learningPathData().title.trim() || !learningPathData().description.trim()) {
// Display inline error instead of alert
return;
}
setIsSubmitting(true);
try {
await props.onSubmit(learningPathData());
props.onClose();
resetForm();
} catch (error) {
console.error('Failed to save learning path:', error);
// Let the parent handle the error display
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (field: keyof LearningPathFormData) => {
return (e: Event) => {
const target = e.currentTarget as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
if (target) {
setLearningPathData(prev => ({
...prev,
[field]: target.type === 'checkbox' ? (target as HTMLInputElement).checked : target.value
}));
}
};
};
if (!props.isOpen) return null;
return (
<ModalPortal>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4">
{/* Header */}
<div class="flex items-center justify-between p-6 border-b border-[#404040]">
<h2 class="text-xl font-semibold text-[#fafafa]">
{props.isEdit ? 'Edit Learning Path' : 'Create New Learning Path'}
</h2>
<Button
variant="ghost"
size="sm"
onClick={props.onClose}
class="text-[#a3a3a3] hover:text-[#fafafa]"
>
<IconX class="size-5" />
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} class="p-6 space-y-6">
{/* Title */}
<div>
<label class="block text-sm font-medium text-[#fafafa] mb-2">
Title *
</label>
<Input
type="text"
value={learningPathData().title}
onInput={handleInputChange('title')}
placeholder="Enter learning path title"
required
class="w-full"
/>
</div>
{/* Description */}
<div>
<label class="block text-sm font-medium text-[#fafafa] mb-2">
Description *
</label>
<textarea
value={learningPathData().description}
onInput={handleInputChange('description')}
placeholder="Describe what students will learn in this path"
rows={4}
required
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500 resize-none"
/>
</div>
{/* Category and Difficulty */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-[#fafafa] mb-2">
Category *
</label>
<select
value={learningPathData().category}
onChange={handleInputChange('category')}
required
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500"
>
<option value="">Select a category</option>
<option value="programming">Programming</option>
<option value="web-development">Web Development</option>
<option value="mobile-development">Mobile Development</option>
<option value="data-science">Data Science</option>
<option value="machine-learning">Machine Learning</option>
<option value="cybersecurity">Cybersecurity</option>
<option value="design">Design</option>
<option value="business">Business</option>
<option value="marketing">Marketing</option>
<option value="photography">Photography</option>
<option value="music">Music</option>
<option value="writing">Writing</option>
<option value="languages">Languages</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-[#fafafa] mb-2">
Difficulty *
</label>
<select
value={learningPathData().difficulty}
onChange={handleInputChange('difficulty')}
required
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div>
</div>
{/* Duration */}
<div>
<label class="block text-sm font-medium text-[#fafafa] mb-2">
Duration *
</label>
<Input
type="text"
value={learningPathData().duration}
onInput={handleInputChange('duration')}
placeholder="e.g., 8 weeks, 3 months"
required
class="w-full"
/>
</div>
{/* Thumbnail */}
<div>
<label class="block text-sm font-medium text-[#fafafa] mb-2">
Thumbnail URL (optional)
</label>
<Input
type="text"
value={learningPathData().thumbnail}
onInput={handleInputChange('thumbnail')}
placeholder="https://example.com/image.jpg"
class="w-full"
/>
</div>
{/* Featured */}
<div class="flex items-center gap-2">
<input
type="checkbox"
id="featured"
checked={learningPathData().is_featured}
onChange={handleInputChange('is_featured')}
class="w-4 h-4 text-blue-600 bg-[#262626] border-[#404040] rounded focus:ring-blue-500"
/>
<label for="featured" class="text-sm font-medium text-[#fafafa]">
Featured Learning Path
</label>
</div>
{/* Actions */}
<div class="flex justify-end gap-3 pt-4 border-t border-[#404040]">
<Button
variant="outline"
onClick={props.onClose}
disabled={isSubmitting()}
>
Cancel
</Button>
<Button
onClick={(e) => handleSubmit(e)}
disabled={isSubmitting()}
class="min-w-[100px]"
>
{isSubmitting() ? (
<span class="flex items-center gap-2">
<span class="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin"></span>
{props.isEdit ? 'Updating...' : 'Creating...'}
</span>
) : (
props.isEdit ? 'Update Learning Path' : 'Create Learning Path'
)}
</Button>
</div>
</form>
</div>
</div>
</ModalPortal>
);
};
@@ -1,222 +0,0 @@
import { createSignal, For, Show } from 'solid-js'
import { Button } from './Button'
import { Input } from './Input'
import { IconSearch, IconFilter, IconX, IconCalendar, IconTag, IconFlag } from '@tabler/icons-solidjs'
export interface SearchFiltersProps {
onSearchChange: (query: string) => void
onFiltersChange: (filters: Record<string, any>) => void
placeholder?: string
showFilters?: boolean
filterOptions?: {
tags?: string[]
statuses?: string[]
priorities?: string[]
dateRanges?: string[]
}
}
export const SearchFilters = (props: SearchFiltersProps) => {
const [searchQuery, setSearchQuery] = createSignal('')
const [showAdvancedFilters, setShowAdvancedFilters] = createSignal(props.showFilters || false)
const [activeFilters, setActiveFilters] = createSignal<Record<string, any>>({})
const handleSearchChange = (value: string) => {
setSearchQuery(value)
props.onSearchChange(value)
}
const handleFilterChange = (filterKey: string, value: any) => {
const newFilters = { ...activeFilters(), [filterKey]: value }
if (!value || (Array.isArray(value) && value.length === 0)) {
delete newFilters[filterKey]
}
setActiveFilters(newFilters)
props.onFiltersChange(newFilters)
}
const clearAllFilters = () => {
setActiveFilters({})
setSearchQuery('')
props.onSearchChange('')
props.onFiltersChange({})
}
const activeFilterCount = () => {
const filters = activeFilters()
return Object.keys(filters).length + (searchQuery() ? 1 : 0)
}
return (
<div class="space-y-4">
{/* Search Bar */}
<div class="relative">
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="search"
placeholder={props.placeholder || "Search..."}
value={searchQuery()}
onInput={(e) => e.target && handleSearchChange((e.target as HTMLInputElement).value)}
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
/>
{/* Filter Toggle */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters())}
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
>
<IconFilter class="h-4 w-4" />
<Show when={activeFilterCount() > 0}>
<span class="ml-1 text-xs bg-blue-600 text-white rounded-full px-2 py-0.5">
{activeFilterCount()}
</span>
</Show>
</Button>
</div>
{/* Advanced Filters */}
<Show when={showAdvancedFilters()}>
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4 space-y-4">
{/* Filter Header */}
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-white">Advanced Filters</h3>
<div class="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
class="text-gray-400 hover:text-white"
>
<IconX class="mr-1 h-3 w-3" />
Clear All
</Button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Tags Filter */}
<Show when={props.filterOptions?.tags && props.filterOptions.tags.length > 0}>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<IconTag class="inline h-4 w-4 mr-1" />
Tags
</label>
<select
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
onChange={(e) => handleFilterChange('tag', (e.target as HTMLSelectElement).value)}
>
<option value="">All Tags</option>
<For each={props.filterOptions!.tags}>
{(tag) => (
<option value={tag} selected={activeFilters().tag === tag}>
{tag}
</option>
)}
</For>
</select>
</div>
</Show>
{/* Status Filter */}
<Show when={props.filterOptions?.statuses && props.filterOptions.statuses.length > 0}>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Status</label>
<select
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
onChange={(e) => handleFilterChange('status', (e.target as HTMLSelectElement).value)}
>
<option value="">All Statuses</option>
<For each={props.filterOptions!.statuses}>
{(status) => (
<option value={status} selected={activeFilters().status === status}>
{status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</option>
)}
</For>
</select>
</div>
</Show>
{/* Priority Filter */}
<Show when={props.filterOptions?.priorities && props.filterOptions.priorities.length > 0}>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<IconFlag class="inline h-4 w-4 mr-1" />
Priority
</label>
<select
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
onChange={(e) => handleFilterChange('priority', (e.target as HTMLSelectElement).value)}
>
<option value="">All Priorities</option>
<For each={props.filterOptions!.priorities}>
{(priority) => (
<option value={priority} selected={activeFilters().priority === priority}>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</option>
)}
</For>
</select>
</div>
</Show>
{/* Date Range Filter */}
<Show when={props.filterOptions?.dateRanges && props.filterOptions.dateRanges.length > 0}>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<IconCalendar class="inline h-4 w-4 mr-1" />
Date Range
</label>
<select
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
onChange={(e) => handleFilterChange('dateRange', (e.target as HTMLSelectElement).value)}
>
<option value="">Any Time</option>
<For each={props.filterOptions!.dateRanges}>
{(range) => (
<option value={range} selected={activeFilters().dateRange === range}>
{range}
</option>
)}
</For>
</select>
</div>
</Show>
</div>
{/* Active Filters Display */}
<Show when={activeFilterCount() > 0}>
<div class="flex flex-wrap gap-2 pt-2 border-t border-gray-700">
<For each={Object.entries(activeFilters())}>
{([key, value]) => (
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-600 text-white">
{key}: {value}
<button
onClick={() => handleFilterChange(key, null)}
class="ml-1 hover:text-blue-200"
>
<IconX class="h-3 w-3" />
</button>
</span>
)}
</For>
<Show when={searchQuery()}>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-600 text-white">
Search: {searchQuery()}
<button
onClick={() => handleSearchChange('')}
class="ml-1 hover:text-blue-200"
>
<IconX class="h-3 w-3" />
</button>
</span>
</Show>
</div>
</Show>
</div>
</Show>
</div>
)
}
@@ -1,255 +0,0 @@
import { createSignal, Show, createMemo } from 'solid-js';
import {
IconRefresh,
IconCheck,
IconAlertTriangle,
IconDownload,
IconLoader2
} from '@tabler/icons-solidjs';
import { updateStore } from '../../stores/updateStore';
import { ModalPortal } from './ModalPortal';
interface UpdateCheckerProps {
class?: string;
}
export function UpdateChecker(props: UpdateCheckerProps) {
const [showUpdateModal, setShowUpdateModal] = createSignal(false);
// Initialize update store
updateStore.ensureInitialized().catch(console.error);
const installUpdate = () => {
updateStore.installUpdate();
};
const cancelUpdate = () => {
updateStore.cancelUpdate();
setShowUpdateModal(false);
};
// Create reactive computed values
const buttonClasses = createMemo(() => {
const updateAvailable = updateStore.updateAvailable();
const updateStatus = updateStore.updateStatus();
const error = updateStore.error();
return {
"bg-blue-500/20 text-blue-400": updateAvailable && !updateStatus.downloading && !updateStatus.installing,
"hover:bg-blue-500/30": updateAvailable && !updateStatus.downloading && !updateStatus.installing,
"bg-orange-500/20 text-orange-400": updateStatus.downloading || updateStatus.installing,
"hover:bg-orange-500/30": updateStatus.downloading || updateStatus.installing,
"bg-green-500/20 text-green-400": updateStatus.completed,
"hover:bg-green-500/30": updateStatus.completed,
"bg-red-500/20 text-red-400": !!error,
"hover:bg-red-500/30": !!error,
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !updateAvailable && !updateStatus.downloading && !updateStatus.installing && !updateStatus.completed && !error
};
});
const isDisabled = createMemo(() => {
const isChecking = updateStore.isChecking();
const updateStatus = updateStore.updateStatus();
return isChecking || updateStatus.downloading || updateStatus.installing;
});
const getStatusIcon = () => {
const isChecking = updateStore.isChecking();
const updateStatus = updateStore.updateStatus();
const updateAvailable = updateStore.updateAvailable();
const error = updateStore.error();
if (isChecking) return <IconLoader2 class="size-4 animate-spin" />;
if (updateStatus.downloading || updateStatus.installing) return <IconLoader2 class="size-4 animate-spin" />;
if (updateStatus.completed) return <IconCheck class="size-4 text-green-500" />;
if (updateAvailable) return <IconDownload class="size-4 text-blue-500" />;
if (error) return <IconAlertTriangle class="size-4 text-red-500" />;
return <IconRefresh class="size-4" />;
};
const getStatusText = () => {
const isChecking = updateStore.isChecking();
const updateStatus = updateStore.updateStatus();
const updateAvailable = updateStore.updateAvailable();
const error = updateStore.error();
if (isChecking) return 'Checking...';
if (updateStatus.downloading) return `Downloading... ${Math.round(updateStatus.progress)}%`;
if (updateStatus.installing) return `Installing... ${Math.round(updateStatus.progress)}%`;
if (updateStatus.completed) return 'Update Complete';
if (updateAvailable) return 'Update Available';
if (error) return 'Update Failed';
return 'Check Updates';
};
return (
<>
<div class={`flex flex-col gap-2 ${props.class || ''}`}>
{/* Current Version Display */}
<div class="text-xs text-muted-foreground px-2 text-center">
Version {updateStore.currentVersion()}
</div>
{/* Check Updates Button */}
<button
onClick={() => {
const updateAvailable = updateStore.updateAvailable();
if (updateAvailable) {
setShowUpdateModal(true);
} else {
updateStore.checkForUpdates();
}
}}
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden w-full"
classList={buttonClasses()}
disabled={isDisabled()}
>
<div class="relative z-10 flex items-center gap-2">
{getStatusIcon()}
<div class="transition-colors truncate">
{getStatusText()}
</div>
</div>
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
</button>
</div>
{/* Update Modal */}
<Show when={showUpdateModal() && updateStore.updateInfo()}>
<ModalPortal>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-card border border-border rounded-lg shadow-lg max-w-md w-full max-h-[80vh] overflow-auto">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<IconDownload class="size-6 text-blue-500" />
<h2 class="text-lg font-semibold">Update Available</h2>
</div>
<div class="space-y-4">
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-muted-foreground">Current Version</span>
<span class="text-sm font-medium">{updateStore.currentVersion()}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Latest Version</span>
<span class="text-sm font-medium text-blue-500">{updateStore.updateInfo()!.version}</span>
</div>
</div>
<div>
<h3 class="text-sm font-medium mb-2">Release Notes</h3>
<div class="text-sm text-muted-foreground whitespace-pre-line bg-muted/30 rounded p-3">
{updateStore.updateInfo()!.releaseNotes}
</div>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">Download Size</span>
<span>{updateStore.updateInfo()!.size}</span>
</div>
<Show when={(() => {
const updateStatus = updateStore.updateStatus();
return updateStatus.downloading || updateStatus.installing;
})()}>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">
{(() => {
const updateStatus = updateStore.updateStatus();
return updateStatus.downloading ? 'Downloading' : 'Installing';
})()}
</span>
<span>{(() => Math.round(updateStore.updateStatus().progress))()}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${updateStore.updateStatus().progress}%` }}
></div>
</div>
</div>
</Show>
<Show when={updateStore.error()}>
<div class="bg-red-500/10 border border-red-500/20 rounded p-3">
<div class="flex items-center gap-2 text-red-500 text-sm">
<IconAlertTriangle class="size-4" />
<span>{updateStore.error()}</span>
</div>
</div>
</Show>
<Show when={(() => updateStore.updateStatus().completed)}>
<div class="bg-green-500/10 border border-green-500/20 rounded p-3">
<div class="flex items-center gap-2 text-green-500 text-sm">
<IconCheck class="size-4" />
<span>Update completed successfully! Restarting...</span>
</div>
</div>
</Show>
</div>
<div class="flex gap-3 mt-6">
<Show when={(() => {
const updateStatus = updateStore.updateStatus();
return !updateStatus.downloading && !updateStatus.installing && !updateStatus.completed;
})()}>
<button
onClick={() => setShowUpdateModal(false)}
class="flex-1 px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
>
Later
</button>
<button
onClick={installUpdate}
disabled={(() => {
const updateStatus = updateStore.updateStatus();
return updateStatus.downloading || updateStatus.installing;
})()}
class="flex-1 px-4 py-2 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Show when={(() => {
const updateStatus = updateStore.updateStatus();
return updateStatus.downloading || updateStatus.installing;
})()}>
<IconLoader2 class="size-4 animate-spin" />
</Show>
{(() => {
const updateStatus = updateStore.updateStatus();
return updateStatus.downloading || updateStatus.installing ? 'Installing...' : 'Install Update';
})()}
</button>
</Show>
<Show when={(() => {
const updateStatus = updateStore.updateStatus();
const error = updateStore.error();
return updateStatus.downloading || updateStatus.installing || error;
})()}>
<button
onClick={cancelUpdate}
class="px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
>
Cancel
</button>
</Show>
<Show when={(() => updateStore.updateStatus().completed)}>
<button
onClick={() => window.location.reload()}
class="w-full px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
>
Reload Application
</button>
</Show>
</div>
</div>
</div>
</div>
</ModalPortal>
</Show>
</>
);
}
-117
View File
@@ -1,117 +0,0 @@
import { createSignal, For, Show } from 'solid-js'
import { cn } from '@/lib/utils'
interface VirtualListProps<T> {
items: T[]
itemHeight: number
containerHeight: number
renderItem: (item: T, index: number) => any
overscan?: number
class?: string
}
export function VirtualList<T>(props: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = createSignal(0)
const overscan = props.overscan || 5
const itemHeight = props.itemHeight
const visibleRange = () => {
const start = Math.floor(scrollTop() / itemHeight)
const visibleCount = Math.ceil(props.containerHeight / itemHeight)
const end = start + visibleCount
return {
start: Math.max(0, start - overscan),
end: Math.min(props.items.length, end + overscan)
}
}
const totalHeight = () => props.items.length * itemHeight
const offsetY = () => visibleRange().start * itemHeight
const visibleItems = () => {
const { start, end } = visibleRange()
return props.items.slice(start, end).map((item, index) => ({
item,
index: start + index
}))
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
setScrollTop(target.scrollTop)
}
return (
<div
class={cn('overflow-auto', props.class)}
style={{ height: `${props.containerHeight}px` }}
onScroll={handleScroll}
>
<div style={{ height: `${totalHeight()}px`, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY()}px)` }}>
<For each={visibleItems()}>
{({ item, index }) => (
<div style={{ height: `${itemHeight}px` }}>
{props.renderItem(item, index)}
</div>
)}
</For>
</div>
</div>
</div>
)
}
interface InfiniteScrollProps<T> {
items: T[]
loading: boolean
hasMore: boolean
onLoadMore: () => void
renderItem: (item: T, index: number) => any
loader?: any
class?: string
}
export function InfiniteScroll<T>(props: InfiniteScrollProps<T>) {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
const { scrollTop, scrollHeight, clientHeight } = target
// Load more when user is near the bottom
if (
!props.loading &&
props.hasMore &&
scrollHeight - scrollTop - clientHeight < 200
) {
props.onLoadMore()
}
}
return (
<div
class={cn('overflow-auto', props.class)}
onScroll={handleScroll}
>
<For each={props.items}>
{(item, index) => props.renderItem(item, index())}
</For>
<Show when={props.loading}>
{props.loader || (
<div class="p-4 text-center">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
)}
</Show>
<Show when={!props.hasMore && props.items.length > 0}>
<div class="p-4 text-center text-muted-foreground text-sm">
No more items to load
</div>
</Show>
</div>
)
}
+4
View File
@@ -49,6 +49,10 @@ interface ImportMeta {
}
interface Window {
ENV?: {
VITE_API_URL?: string;
VITE_DEMO_MODE?: string;
};
importMetaEnv?: {
VITE_API_URL?: string;
VITE_DEMO_MODE?: string;
-373
View File
@@ -1,373 +0,0 @@
import { createQuery, useQueryClient, createMutation } from '@tanstack/solid-query';
import { getAuthHeaders } from './auth';
// API base URL
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Retry configuration
const DEFAULT_RETRY_CONFIG = {
retry: 3,
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
networkMode: 'online' as const,
};
// Generic API client with retry logic
const apiClient = {
async get<T>(endpoint: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status} ${response.statusText}`);
}
return response.json();
},
async post<T>(endpoint: string, data?: any): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status}`);
}
return response.json();
},
async put<T>(endpoint: string, data?: any): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status}`);
}
return response.json();
},
async delete<T>(endpoint: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API Error: ${response.status}`);
}
return response.json();
},
};
// Types
export interface Bookmark {
id: number;
user_id: number;
title: string;
url: string;
description?: string;
is_read: boolean;
is_favorite: boolean;
created_at: string;
updated_at: string;
tags: string[];
}
export interface Task {
id: number;
user_id: number;
title: string;
description?: string;
status: 'pending' | 'in_progress' | 'completed';
priority: 'low' | 'medium' | 'high';
progress: number;
created_at: string;
updated_at: string;
tags: string[];
}
export interface Note {
id: number;
user_id: number;
title: string;
content: string;
content_type: string;
is_pinned: boolean;
created_at: string;
updated_at: string;
tags: string[];
}
export interface FileItem {
id: number;
user_id: number;
filename: string;
original_name: string;
file_size: number;
mime_type: string;
file_path: string;
created_at: string;
updated_at: string;
}
// Bookmarks API
export const bookmarksApi = {
useGetAll: () => createQuery(() => ({
queryKey: ['bookmarks'],
queryFn: () => apiClient.get<Bookmark[]>('/bookmarks'),
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['bookmarks', id],
queryFn: () => apiClient.get<Bookmark>(`/bookmarks/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useCreate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (data: Omit<Bookmark, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
apiClient.post<Bookmark>('/bookmarks', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
},
onError: (error) => {
console.error('Failed to create bookmark:', error);
},
}));
},
useUpdate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: ({ id, data }: { id: number; data: Partial<Bookmark> }) =>
apiClient.put<Bookmark>(`/bookmarks/${id}`, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
queryClient.invalidateQueries({ queryKey: ['bookmarks', id] });
},
onError: (error) => {
console.error('Failed to update bookmark:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/bookmarks/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
},
onError: (error) => {
console.error('Failed to delete bookmark:', error);
},
}));
},
};
// Tasks API
export const tasksApi = {
useGetAll: () => createQuery(() => ({
queryKey: ['tasks'],
queryFn: () => apiClient.get<Task[]>('/tasks'),
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['tasks', id],
queryFn: () => apiClient.get<Task>(`/tasks/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useCreate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (data: Omit<Task, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
apiClient.post<Task>('/tasks', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
onError: (error) => {
console.error('Failed to create task:', error);
},
}));
},
useUpdate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: ({ id, data }: { id: number; data: Partial<Task> }) =>
apiClient.put<Task>(`/tasks/${id}`, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', id] });
},
onError: (error) => {
console.error('Failed to update task:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/tasks/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
onError: (error) => {
console.error('Failed to delete task:', error);
},
}));
},
};
// Notes API
export const notesApi = {
useGetAll: (search?: string, tag?: string) => createQuery(() => ({
queryKey: ['notes', search, tag],
queryFn: () => {
const params = new URLSearchParams();
if (search) params.append('search', search);
if (tag) params.append('tag', tag);
const queryString = params.toString();
return apiClient.get<Note[]>(`/notes${queryString ? `?${queryString}` : ''}`);
},
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['notes', id],
queryFn: () => apiClient.get<Note>(`/notes/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useCreate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (data: Omit<Note, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
apiClient.post<Note>('/notes', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
},
onError: (error) => {
console.error('Failed to create note:', error);
},
}));
},
useUpdate: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: ({ id, data }: { id: number; data: Partial<Note> }) =>
apiClient.put<Note>(`/notes/${id}`, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
queryClient.invalidateQueries({ queryKey: ['notes', id] });
},
onError: (error) => {
console.error('Failed to update note:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/notes/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notes'] });
},
onError: (error) => {
console.error('Failed to delete note:', error);
},
}));
},
};
// Files API
export const filesApi = {
useGetAll: () => createQuery(() => ({
queryKey: ['files'],
queryFn: () => apiClient.get<FileItem[]>('/files'),
...DEFAULT_RETRY_CONFIG,
staleTime: 5 * 60 * 1000, // 5 minutes
})),
useGetById: (id: number) => createQuery(() => ({
queryKey: ['files', id],
queryFn: () => apiClient.get<FileItem>(`/files/${id}`),
...DEFAULT_RETRY_CONFIG,
staleTime: 10 * 60 * 1000, // 10 minutes
})),
useUpload: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: async (file: globalThis.File) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/files/upload`, {
method: 'POST',
headers: {
'Authorization': getAuthHeaders().Authorization || '',
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Upload failed');
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
onError: (error) => {
console.error('Failed to upload file:', error);
},
}));
},
useDelete: () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationFn: (id: number) => apiClient.delete(`/files/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
onError: (error) => {
console.error('Failed to delete file:', error);
},
}));
},
};
+33 -5
View File
@@ -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 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$/, '');
export const getApiOrigin = (): string => {
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim();
if (!raw) {
// 1. Runtime injection from index.html (highest priority for Docker deployments)
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;
}
const normalized = trimTrailingSlash(raw);
return trimApiSuffix(normalized);
// 4. Production unified deployment: same-origin relative URLs
return '';
};
export const getApiV1BaseUrl = (): string => {
const origin = getApiOrigin();
return `${origin}/api/v1`;
return origin ? `${origin}/api/v1` : '/api/v1';
};
+5 -2
View File
@@ -67,7 +67,10 @@ export const getSearchProvider = (): string => {
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 => {
return import.meta.env.VITE_API_URL || 'http://localhost:8080';
return getApiOrigin();
};
+3 -2
View File
@@ -12,6 +12,7 @@ import {
getMockStats
} from './mockData';
import { isDemoMode } from './demo-mode';
import { getApiV1BaseUrl } from './api-url';
// Demo mode API client that falls back to mock data
export class DemoModeApiClient {
@@ -280,8 +281,8 @@ export class DemoModeApiClient {
}
}
// Create demo mode API client
const demoApi = new DemoModeApiClient(import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1');
// Uses getApiV1BaseUrl so demo client respects runtime env injection.
const demoApi = new DemoModeApiClient(getApiV1BaseUrl());
// Export demo mode API functions that match the regular API
export const demoBookmarksApi = {
-128
View File
@@ -1,128 +0,0 @@
import type { Bookmark, Task, Note, FileItem } from './api-client'
export interface ExportData {
version: string
exportDate: string
bookmarks: Bookmark[]
tasks: Task[]
notes: Note[]
files: FileItem[]
}
export const exportData = async (data: {
bookmarks?: Bookmark[]
tasks?: Task[]
notes?: Note[]
files?: FileItem[]
}, filename?: string) => {
const exportData: ExportData = {
version: '1.0.0',
exportDate: new Date().toISOString(),
bookmarks: data.bookmarks || [],
tasks: data.tasks || [],
notes: data.notes || [],
files: data.files || []
}
const jsonString = JSON.stringify(exportData, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `trackeep-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export const importData = async (file: File): Promise<ExportData> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content) as ExportData
// Validate the structure
if (!data.version || !data.exportDate) {
throw new Error('Invalid export file format')
}
resolve(data)
} catch (error) {
reject(new Error('Failed to parse export file: ' + (error as Error).message))
}
}
reader.onerror = () => {
reject(new Error('Failed to read file'))
}
reader.readAsText(file)
})
}
export const validateImportData = (data: ExportData): { isValid: boolean; errors: string[] } => {
const errors: string[] = []
// Check version compatibility
if (!data.version) {
errors.push('Missing version information')
}
// Check required fields
if (!data.exportDate) {
errors.push('Missing export date')
}
// Validate data types
if (data.bookmarks && !Array.isArray(data.bookmarks)) {
errors.push('Bookmarks data is not an array')
}
if (data.tasks && !Array.isArray(data.tasks)) {
errors.push('Tasks data is not an array')
}
if (data.notes && !Array.isArray(data.notes)) {
errors.push('Notes data is not an array')
}
if (data.files && !Array.isArray(data.files)) {
errors.push('Files data is not an array')
}
return {
isValid: errors.length === 0,
errors
}
}
export const getImportSummary = (data: ExportData): string => {
const summary = []
if (data.bookmarks.length > 0) {
summary.push(`${data.bookmarks.length} bookmarks`)
}
if (data.tasks.length > 0) {
summary.push(`${data.tasks.length} tasks`)
}
if (data.notes.length > 0) {
summary.push(`${data.notes.length} notes`)
}
if (data.files.length > 0) {
summary.push(`${data.files.length} files`)
}
if (summary.length === 0) {
return 'No data to import'
}
return `Import contains: ${summary.join(', ')}`
}
+4 -1
View File
@@ -140,7 +140,10 @@ export interface WsEvent {
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() {
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
-359
View File
@@ -1,359 +0,0 @@
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { TaskModal } from '@/components/ui/TaskModal';
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { useHaptics } from '@/lib/haptics';
const API_BASE_URL = getApiV1BaseUrl();
interface Task {
id: number;
title: string;
description?: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: string;
dueDate?: string;
}
export const Tasks = () => {
const [tasks, setTasks] = createSignal<Task[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false);
const [editingTask, setEditingTask] = createSignal<Task | null>(null);
const [filter, setFilter] = createSignal<'all' | 'active' | 'completed'>('all');
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedPriority, setSelectedPriority] = createSignal('');
const haptics = useHaptics();
onMount(async () => {
try {
const response = await fetch(`${API_BASE_URL}/tasks`, {
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
throw new Error('Failed to load tasks');
}
const data = await response.json();
setTasks(data);
} catch (error) {
console.error('Failed to load tasks:', error);
setTasks([]);
} finally {
setIsLoading(false);
}
});
const filteredTasks = () => {
const term = searchTerm().toLowerCase();
const filtered = tasks().filter(task => {
const matchesSearch = !term ||
task.title.toLowerCase().includes(term) ||
(task.description && task.description.toLowerCase().includes(term));
const matchesPriority = !selectedPriority() || task.priority === selectedPriority();
const matchesFilter =
(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 };
if (a.completed !== b.completed) return a.completed ? 1 : -1;
return priorityOrder[a.priority] - priorityOrder[b.priority];
});
};
const handleAddTask = async (task: Omit<Task, 'id'>) => {
try {
const response = await fetch(`${API_BASE_URL}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(task),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create task');
}
const newTask = await response.json();
setTasks(prev => [newTask, ...prev]);
setShowAddModal(false);
haptics.success(); // Success feedback for adding task
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to add task');
}
};
const handleEditTask = async (task: Omit<Task, 'id'>) => {
if (!editingTask()) return;
try {
const response = await fetch(`${API_BASE_URL}/tasks/${editingTask()!.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(task),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update task');
}
const updatedTask = await response.json();
setTasks(prev =>
prev.map(task =>
task.id === updatedTask.id ? updatedTask : task
)
);
setShowEditModal(false);
setEditingTask(null);
haptics.success(); // Success feedback for editing task
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to update task');
}
};
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) => {
if (confirm('Are you sure you want to delete this task?')) {
try {
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete task');
}
setTasks(prev => prev.filter(task => task.id !== taskId));
haptics.delete(); // Delete feedback
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to delete task');
}
}
};
const editTask = (task: Task) => {
setEditingTask(task);
setShowEditModal(true);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'text-destructive bg-destructive/10';
case 'medium': return 'text-yellow-400 bg-yellow-400/10';
case 'low': return 'text-muted-foreground bg-muted/10';
default: return 'text-gray-400 bg-gray-400/10';
}
};
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 (
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-[#fafafa]">Tasks</h1>
<Button onClick={() => setShowAddModal(true)} haptic="impact">
Add Task
</Button>
</div>
<TaskModal
isOpen={showAddModal()}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddTask}
/>
<TaskModal
isOpen={showEditModal()}
onClose={() => {
setShowEditModal(false);
setEditingTask(null);
}}
onSubmit={handleEditTask}
task={editingTask()}
isEdit={true}
/>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().total}</p>
<p class="text-[#a3a3a3] text-sm">Total Tasks</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().active}</p>
<p class="text-[#a3a3a3] text-sm">Active</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-blue-400">{taskStats().completed}</p>
<p class="text-[#a3a3a3] text-sm">Completed</p>
</Card>
</div>
<SearchTagFilterBar
searchPlaceholder="Search tasks..."
searchValue={searchTerm()}
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>
{isLoading() ? (
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-[#262626] rounded mb-2"></div>
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
</div>
</Card>
))}
</div>
) : (
<div class="space-y-4">
{filteredTasks().map((task) => (
<div
class={`cursor-pointer transition-all ${task.completed ? 'opacity-60' : ''}`}
onClick={() => toggleTaskComplete(task.id)}
>
<Card class={`p-6 hover:bg-[#141415]`}>
<div class="flex items-start space-x-3">
<input
type="checkbox"
checked={task.completed}
onChange={(e) => {
e.stopPropagation();
toggleTaskComplete(task.id);
}}
class="mt-1 w-4 h-4 text-[#39b9ff] bg-[#141415] border-[#262626] rounded focus:ring-[#39b9ff]"
/>
<div class="flex-1">
<div class="flex items-center justify-between">
<h3 class={`text-lg font-semibold text-[#fafafa] ${task.completed ? 'line-through' : ''}`}>
{task.title}
</h3>
<div class="flex items-center space-x-2">
<span class={`px-2 py-1 text-xs rounded-md ${getPriorityColor(task.priority)}`}>
{task.priority}
</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
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>
{task.description && (
<p class="text-[#a3a3a3] text-sm mt-1">{task.description}</p>
)}
{task.dueDate && (
<p class="text-[#a3a3a3] text-xs mt-2">
Due: {new Date(task.dueDate).toLocaleDateString()}
</p>
)}
</div>
</div>
</Card>
</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>
);
};
@@ -15,7 +15,7 @@ import {
IconClock,
IconChecklist
} from '@tabler/icons-solidjs';
import { ColorSwitcher } from './ColorSwitcher';
import { ColorSwitcher } from '@/pages/settings/ColorSwitcher';
import { useHaptics } from '@/lib/haptics';
interface ProjectStats {
@@ -13,6 +13,7 @@ import {
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useHaptics } from '@/lib/haptics';
import { getApiOrigin } from '@/lib/api-url';
interface AnalyticsData {
period: {
@@ -183,7 +184,7 @@ export const Analytics = () => {
setLoading(true);
setError(null);
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: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
@@ -13,6 +13,7 @@ import {
} from 'lucide-solid'
import { AIProviderIcon } from '@/components/AIProviderIcon'
import { useHaptics } from '@/lib/haptics'
import { getApiOrigin } from '@/lib/api-url'
interface AIModel {
id: string
@@ -133,7 +134,7 @@ export const AIChat = () => {
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
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`, {
method: 'POST',
@@ -1,4 +1,5 @@
import { createEffect, createResource, createSignal, For, Show, onMount } from 'solid-js'
import { getApiOrigin } from '@/lib/api-url'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card } from '@/components/ui/Card'
@@ -9,7 +10,6 @@ import {
FileText as FileTextIcon,
Sparkles,
ChevronDown,
Settings,
Trash,
User
} from 'lucide-solid'
@@ -65,7 +65,7 @@ const Chat = () => {
const loadAIProviders = async () => {
try {
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: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
@@ -84,7 +84,7 @@ const Chat = () => {
const loadAISettings = async () => {
try {
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: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
@@ -176,7 +176,7 @@ const Chat = () => {
const fetchSessions = async () => {
try {
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: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
@@ -240,7 +240,7 @@ const Chat = () => {
const loadSessionMessages = async (sessionId: string) => {
try {
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: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
@@ -341,7 +341,7 @@ const Chat = () => {
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',
headers: {
'Content-Type': 'application/json',
@@ -386,109 +386,8 @@ const Chat = () => {
}
return (
<div class="mt-4 pb-32 max-w-7xl mx-auto">
<div class="bg-background rounded-lg border shadow-sm">
{/* 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>
<div class="mt-4 pb-32 max-w-7xl mx-auto flex flex-col md:flex-row gap-4">
<div class="w-72 flex-shrink-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
<Show when={showSettings()}>
<div class="p-6 border-b bg-muted/30">
<div class="flex items-center justify-between mb-4">
@@ -708,7 +607,7 @@ const Chat = () => {
e.stopPropagation()
try {
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',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -741,7 +640,7 @@ const Chat = () => {
</div>
{/* 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="flex items-center gap-6">
<div class="flex items-center gap-3">
@@ -811,9 +710,9 @@ const Chat = () => {
</div>
{/* Main Content Area */}
<div class="flex flex-col">
<div class="flex-1 flex flex-col min-h-0">
<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">
<For each={messages()}>
{message => (
@@ -1111,7 +1010,6 @@ const Chat = () => {
</div>
</Show>
</div>
<div class="clear-both"></div>
</div>
</div>
)
@@ -606,13 +606,16 @@
@media (max-width: 767px) {
.messages-shell {
display: block;
display: flex;
flex-direction: column;
}
.messages-sidebar,
.messages-main {
width: 100%;
max-width: none;
height: 100%;
min-height: 0;
}
.messages-shell-list .messages-main {
@@ -622,6 +625,10 @@
.messages-shell-conversation .messages-sidebar {
display: none;
}
.messages-composer {
padding: 0.75rem;
}
}
.messages-composer-drag {
@@ -830,9 +837,9 @@
/* Responsive design */
@media (max-width: 980px) {
.messages-sidebar,
.messages-main {
width: 100%;
.messages-sidebar {
width: 16rem;
min-width: 16rem;
border-inline: none;
border-radius: 0;
}
@@ -848,11 +855,11 @@
}
.messages-composer-row {
grid-template-columns: auto auto 1fr;
grid-template-columns: repeat(3, auto) 1fr auto;
}
.messages-composer-row > button:last-child {
grid-column: 3;
grid-column: 5;
justify-self: end;
}
@@ -1,4 +1,5 @@
import { createSignal, For, Show, onCleanup, onMount } from 'solid-js';
import { getApiOrigin } from '@/lib/api-url';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { toast } from '@/components/ui/Toast';
@@ -973,7 +974,7 @@ export const Messages = () => {
kind: 'voice_note',
file_id: uploaded.id,
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();
@@ -1366,7 +1367,7 @@ export const Messages = () => {
const loadMembers = async () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
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}` } : {},
});
if (!res.ok) return;
@@ -1385,7 +1386,7 @@ export const Messages = () => {
const loadTeams = async () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
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}` } : {},
});
if (!res.ok) return;
@@ -1403,7 +1404,7 @@ export const Messages = () => {
const loadAIProviders = async () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
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}` } : {},
});
if (!res.ok) return;
@@ -1424,7 +1425,7 @@ export const Messages = () => {
const loadAISettings = async () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
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}` } : {},
});
if (!res.ok) return;
@@ -1454,7 +1455,7 @@ export const Messages = () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
setAiShareLoadingSessions(true);
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}` } : {},
});
if (!res.ok) {
@@ -1486,7 +1487,7 @@ export const Messages = () => {
if (aiShareMessagesBySession()[sessionId]) return;
setAiShareLoadingMessages(true);
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}` } : {},
});
if (!res.ok) {
@@ -1768,6 +1769,7 @@ export const Messages = () => {
if (!selectedConversationId()) return;
const body = inputText().trim();
if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) return;
if (sendingMessage()) return;
try {
const localFiles = [...selectedFiles()];
@@ -1785,7 +1787,7 @@ export const Messages = () => {
kind: uploaded.mime_type?.startsWith('image/') ? 'image' : 'file',
file_id: uploaded.id,
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 });
}
@@ -1795,7 +1797,7 @@ export const Messages = () => {
kind: file.mime_type?.startsWith('image/') ? 'image' : 'file',
file_id: file.id,
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={
sendingMessage() ||
uploadProgress() !== null ||
(!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0)
}
>
@@ -231,7 +231,6 @@ export const Bookmarks = () => {
const handleAddBookmark = async (bookmarkData: any) => {
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
method: 'POST',
headers: {
@@ -271,7 +270,6 @@ export const Bookmarks = () => {
const deleteBookmark = async (bookmarkId: number) => {
if (confirm('Are you sure you want to delete this bookmark?')) {
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}`, {
method: 'DELETE',
headers: {
@@ -322,7 +320,6 @@ export const Bookmarks = () => {
if (!editingBookmark()) return;
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}`, {
method: 'PUT',
headers: {
@@ -482,111 +479,122 @@ export const Bookmarks = () => {
))}
</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) => {
const faviconUrl = getFaviconUrl(bookmark);
const screenshotUrl = getScreenshotUrl(bookmark);
return (
<Card class="p-6 hover:bg-accent transition-colors group">
<div class="flex justify-between items-start gap-4">
{/* Left side: preview image + favicon + title + URL + tags */}
<div class="flex-1 min-w-0">
<Card class="p-4 hover:bg-accent/50 transition-colors group flex flex-col h-full">
{screenshotUrl && (
<div class="mb-3 rounded-md overflow-hidden border border-border bg-muted/40">
<div class="mb-3 rounded-lg overflow-hidden border border-border/50 bg-muted/30 -mx-4 -mt-4">
<a href={bookmark.url} target="_blank" rel="noopener noreferrer">
<img
src={screenshotUrl}
alt="Website preview"
class="w-full h-32 sm:h-40 object-cover"
class="w-full h-28 object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</a>
</div>
)}
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-8 h-8 bg-muted rounded-md flex items-center justify-center overflow-hidden">
<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-6 h-6 object-contain"
class="w-5 h-5 object-contain"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${getBookmarkInitial(bookmark.title)}</span>`;
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-medium">
<span class="text-xs text-muted-foreground font-bold">
{getBookmarkInitial(bookmark.title)}
</span>
)}
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground truncate">
<h3 class="text-sm font-semibold text-foreground leading-tight">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
class="text-foreground hover:text-primary transition-colors"
>
{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>
<p class="text-muted-foreground text-xs truncate mt-0.5">{bookmark.url}</p>
</div>
</div>
{bookmark.description && (
<p class="text-foreground text-sm mb-3 line-clamp-2">{bookmark.description}</p>
<p class="text-foreground/80 text-xs mb-3 line-clamp-2 flex-grow">{bookmark.description}</p>
)}
<div class="flex flex-wrap gap-2 mt-1">
{(bookmark.tags || []).map((tag) => (
<div class="flex flex-wrap gap-1.5 mt-auto">
{(bookmark.tags || []).slice(0, 4).map((tag) => (
<button
onClick={() => handleTagClick(tag)}
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
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/80 text-muted-foreground border-transparent group-hover:bg-accent group-hover:text-accent-foreground group-hover:border-border'
: 'bg-muted/60 text-muted-foreground border-transparent hover:bg-accent hover:text-accent-foreground'
}`}
title={`Click to filter by ${tag}`}
>
{tag}
</button>
))}
</div>
{(bookmark.tags || []).length > 4 && (
<span class="px-2 py-0.5 text-[10px] text-muted-foreground">+{(bookmark.tags || []).length - 4}</span>
)}
</div>
{/* Right side: optional date above important star + menu */}
<div class="flex flex-col items-end gap-2 ml-2">
{bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) && (
<div class="text-muted-foreground text-xs">
<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()}
</div>
</span>
) : (
<span />
)}
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<button
onClick={() => toggleImportant(bookmark.id)}
class={`flex-shrink-0 p-1 rounded hover:bg-accent/50 transition-colors ${
bookmark.isImportant ? 'order-first' : ''
}`}
class="p-1.5 rounded-md hover:bg-accent transition-colors"
title={bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
>
<IconStar
class={`size-4 ${
class={`size-3.5 ${
bookmark.isImportant
? 'text-primary fill-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
/>
</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
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">
<IconDotsVertical class="size-4" />
<button class="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground">
<IconDotsVertical class="size-3.5" />
</button>
}
>
@@ -609,17 +617,18 @@ export const Bookmarks = () => {
</DropdownMenu>
</div>
</div>
</div>
</Card>
);
})}
{filteredBookmarks().length === 0 && (
<div class="col-span-full">
<Card class="p-12 text-center">
<p class="text-muted-foreground">
{searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'}
</p>
</Card>
</div>
)}
</div>
)}
@@ -335,7 +335,12 @@ export const Files = () => {
throw new Error(errorMessage);
}
window.location.href = data.install_url as string;
const installUrl = data.install_url as string;
if (installUrl && (installUrl.startsWith('https://github.com/') || installUrl.startsWith('https://api.github.com/'))) {
window.location.href = installUrl;
} else {
throw new Error('Invalid install URL received');
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start GitHub App installation';
setGitHubError(message);
@@ -464,7 +464,12 @@ export const GitHub = () => {
throw new Error(message);
}
window.location.href = data.install_url as string;
const installUrl = data.install_url as string;
if (installUrl && (installUrl.startsWith('https://github.com/') || installUrl.startsWith('https://api.github.com/'))) {
window.location.href = installUrl;
} else {
throw new Error('Invalid install URL received');
}
} catch (error) {
console.error('Failed to start GitHub App installation:', error);
setBackupError(error instanceof Error ? error.message : 'Failed to start GitHub App installation');
@@ -5,7 +5,7 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { NoteModal } from '@/components/ui/NoteModal';
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
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 { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
@@ -103,7 +103,6 @@ export const Notes = () => {
const [editingNote, setEditingNote] = createSignal<Note | null>(null);
const [viewingNote, setViewingNote] = createSignal<Note | null>(null);
const [copiedContent, setCopiedContent] = createSignal(false);
const [expandedNotes, setExpandedNotes] = createSignal<Set<number>>(new Set());
onMount(async () => {
try {
@@ -112,12 +111,10 @@ export const Notes = () => {
// Check if we should use demo mode or real API
if (isDemoMode() && !shouldUseRealBackend()) {
console.log('[Notes] Loading demo notes data');
// Load mock notes data for demo mode
const mockNotesData = getMockNotes();
notesData = mockNotesData;
} else {
console.log('[Notes] Loading notes from real API');
// Load from real API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes`, {
@@ -402,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 content = note.isMarkdown ? `# ${note.title}\n\n${note.content}` : note.content;
const blob = new Blob([content], { type: 'text/plain' });
@@ -529,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()}>
<Card class="border-destructive/30 bg-destructive/5 p-4">
<p class="text-sm font-medium text-foreground">Notes could not be loaded</p>
@@ -557,160 +527,106 @@ export const Notes = () => {
</div>
</Show>
<Show when={isLoading()}>
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-muted rounded mb-2"></div>
<div class="h-4 bg-muted rounded w-3/4"></div>
{isLoading() ? (
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map(() => (
<Card class="p-5 h-40">
<div class="animate-pulse space-y-3">
<div class="h-5 bg-muted rounded w-2/3"></div>
<div class="h-3 bg-muted rounded w-full"></div>
<div class="h-3 bg-muted rounded w-4/5"></div>
</div>
</Card>
))}
</div>
</Show>
<Show when={!isLoading()}>
<div class="space-y-4">
) : (
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<For each={filteredNotes()}>
{(note) => (
<Card
data-note-id={note.id}
class={`p-6 cursor-pointer transition-colors hover:bg-accent/50 ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
<div
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' : ''}`}
onClick={() => viewNote(note)}
>
<div class="flex justify-between items-start mb-3 gap-3">
<div class="flex items-center gap-2 min-w-0">
<h3 class="text-lg font-semibold text-foreground truncate">{note.title}</h3>
<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 class="absolute top-3 right-3">
<IconPin class="size-3.5 text-primary" />
</div>
</Show>
<div class="text-muted-foreground text-sm mb-3">
<div class={expandedNotes().has(note.id) ? '' : 'max-h-72 overflow-hidden'}>
<h3 class={`text-base font-semibold text-foreground mb-2 pr-5 ${note.pinned ? 'text-primary' : ''}`}>
{note.title}
</h3>
<div class="text-muted-foreground text-sm line-clamp-3 mb-4">
<NoteContentRenderer
content={note.content}
kind={getNoteKind(note)}
preview={!expandedNotes().has(note.id)}
maxBlocks={4}
preview={true}
maxBlocks={3}
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>
</Show>
<div class="flex flex-wrap gap-2 mb-3">
<For each={note.tags}>
<div class="flex flex-wrap gap-1.5 mb-3">
<For each={note.tags.slice(0, 4)}>
{(tag) => (
<button
<span
onClick={(e) => {
e.stopPropagation();
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}
</button>
</span>
)}
</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>
<p class="text-muted-foreground text-xs">
Updated: {formatDisplayDate(note.updatedAt)}
</p>
</Card>
<div class="flex items-center justify-between">
<span class="text-[10px] text-muted-foreground">
{formatDisplayDate(note.updatedAt)}
</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>
<Show when={filteredNotes().length === 0}>
<div class="col-span-full">
<Card class="p-12 text-center">
<p class="text-muted-foreground">
{searchTerm() || selectedTags().length > 0
@@ -718,9 +634,10 @@ export const Notes = () => {
: 'No notes yet. Add your first note!'}
</p>
</Card>
</Show>
</div>
</Show>
</div>
)}
{/* Add Note Modal */}
<NoteModal
@@ -31,6 +31,7 @@ import {
} from '@tabler/icons-solidjs';
import { BrowserSearch } from '@/components/search/BrowserSearch';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { Card } from '@/components/ui/Card';
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
import { ActivityFeed } from '@/components/ui/ActivityFeed';
import { UploadModal } from '@/components/ui/UploadModal';
@@ -525,129 +526,105 @@ export const Dashboard = () => {
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
{/* Stats Overview */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="border rounded-lg p-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<Card class="p-4">
<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" />
</div>
<div>
<p class="text-2xl font-light">{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-2xl font-bold text-foreground">{stats().totalDocuments}</p>
<p class="text-xs text-muted-foreground font-medium">Documents</p>
</div>
</div>
</div>
</Card>
<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">
<IconActivity class="size-5 text-primary" />
<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">
<IconBookmark class="size-5 text-primary" />
</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>
</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>
{/* Recent Achievements and Deadlines */}
@@ -41,7 +41,11 @@ export const RemovedStuff = () => {
// Load auto-remove settings from localStorage
const savedSettings = localStorage.getItem('autoRemoveSettings');
if (savedSettings) {
try {
setAutoRemoveSettings(JSON.parse(savedSettings));
} catch {
console.warn('Failed to parse autoRemoveSettings');
}
}
// Try to load from API first, then fallback to localStorage
@@ -1,4 +1,5 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
import { getApiOrigin } from '@/lib/api-url'
import { DateRangePicker } from '@/components/ui/DateRangePicker';
import { ModalPortal } from '@/components/ui/ModalPortal';
import {
@@ -149,9 +150,9 @@ export function Calendar() {
// Fetch all calendar data in parallel
const [upcomingRes, todayRes, deadlinesRes] = await Promise.all([
fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/calendar/upcoming`, { headers }),
fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/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/upcoming`, { headers }),
fetch(`${getApiOrigin()}/api/v1/calendar/today`, { headers }),
fetch(`${getApiOrigin()}/api/v1/calendar/deadlines`, { headers })
])
if (upcomingRes.ok) {
@@ -247,7 +248,7 @@ export function Calendar() {
const token = localStorage.getItem('token')
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',
headers: {
'Authorization': `Bearer ${token}`,
@@ -304,7 +305,7 @@ export function Calendar() {
const token = localStorage.getItem('token')
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',
headers: {
'Authorization': `Bearer ${token}`,
@@ -328,22 +329,22 @@ export function Calendar() {
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent': return 'text-primary'
case 'high': return 'text-primary'
case 'medium': return 'text-primary'
case 'low': return 'text-primary'
default: return 'text-primary'
case 'urgent': return 'text-red-500'
case 'high': return 'text-orange-500'
case 'medium': return 'text-yellow-500'
case 'low': return 'text-green-500'
default: return 'text-muted-foreground'
}
}
const getTypeColor = (type: string) => {
switch (type) {
case 'task': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
case 'meeting': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
case 'deadline': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
case 'reminder': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
case 'habit': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
default: 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-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
case 'deadline': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
case 'reminder': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
case 'habit': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
}
}
+347
View File
@@ -0,0 +1,347 @@
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TaskModal } from '@/components/ui/TaskModal';
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { useHaptics } from '@/lib/haptics';
const API_BASE_URL = getApiV1BaseUrl();
interface Task {
id: number;
title: string;
description?: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: string;
dueDate?: string;
}
export const Tasks = () => {
const [tasks, setTasks] = createSignal<Task[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false);
const [editingTask, setEditingTask] = createSignal<Task | null>(null);
const [searchTerm, setSearchTerm] = 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 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 () => {
try {
const response = await fetch(`${API_BASE_URL}/tasks`, {
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
throw new Error('Failed to load tasks');
}
const data = await response.json();
setTasks(data);
} catch (error) {
console.error('Failed to load tasks:', error);
setTasks([]);
} finally {
setIsLoading(false);
}
});
const searchedTasks = () => {
const term = searchTerm().toLowerCase();
return tasks().filter(task => {
const matchesSearch = !term ||
task.title.toLowerCase().includes(term) ||
(task.description && task.description.toLowerCase().includes(term));
const matchesPriority = !selectedPriority() || task.priority === selectedPriority();
return matchesSearch && matchesPriority;
}).sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 };
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'>) => {
try {
const response = await fetch(`${API_BASE_URL}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(task),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create task');
}
const newTask = await response.json();
setTasks(prev => [newTask, ...prev]);
setShowAddModal(false);
haptics.success(); // Success feedback for adding task
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to add task');
}
};
const handleEditTask = async (task: Omit<Task, 'id'>) => {
if (!editingTask()) return;
try {
const response = await fetch(`${API_BASE_URL}/tasks/${editingTask()!.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(task),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update task');
}
const updatedTask = await response.json();
setTasks(prev =>
prev.map(task =>
task.id === updatedTask.id ? updatedTask : task
)
);
setShowEditModal(false);
setEditingTask(null);
haptics.success(); // Success feedback for editing task
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to update task');
}
};
const deleteTask = async (taskId: number) => {
if (confirm('Are you sure you want to delete this task?')) {
try {
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete task');
}
setTasks(prev => prev.filter(task => task.id !== taskId));
haptics.delete(); // Delete feedback
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to delete task');
}
}
};
const editTask = (task: Task) => {
setEditingTask(task);
setShowEditModal(true);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'text-destructive bg-destructive/10';
case 'medium': return 'text-yellow-400 bg-yellow-400/10';
case 'low': return 'text-muted-foreground bg-muted/10';
default: return 'text-gray-400 bg-gray-400/10';
}
};
return (
<div class="p-6 space-y-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<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">
Add Task
</Button>
</div>
<TaskModal
isOpen={showAddModal()}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddTask}
/>
<TaskModal
isOpen={showEditModal()}
onClose={() => {
setShowEditModal(false);
setEditingTask(null);
}}
onSubmit={handleEditTask}
task={editingTask()}
isEdit={true}
/>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Search tasks..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
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"
/>
<select
value={selectedPriority()}
onChange={(e) => setSelectedPriority(e.currentTarget.value)}
class="px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">All priorities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
{isLoading() ? (
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[...Array(3)].map(() => (
<Card class="p-4 h-48">
<div class="animate-pulse space-y-3">
<div class="h-5 bg-muted rounded w-1/2"></div>
<div class="h-20 bg-muted rounded"></div>
</div>
</Card>
))}
</div>
) : (
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
{([
{ key: 'todo' as const, label: 'To Do', color: 'border-t-4 border-t-muted-foreground' },
{ key: 'inProgress' as const, label: 'In Progress', color: 'border-t-4 border-t-primary' },
{ key: 'done' as const, label: 'Done', color: 'border-t-4 border-t-emerald-500' },
]).map((col) => {
const items = columnTasks(col.key);
const isDropTarget = dragOverColumn() === col.key;
return (
<div
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' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOverColumn(col.key); }}
onDragLeave={() => setDragOverColumn(null)}
onDrop={(e) => { e.preventDefault(); setDragOverColumn(null); const id = draggedTaskId(); if (id !== null) setTaskColumn(id, col.key); setDraggedTaskId(null); }}
>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-foreground">{col.label}</h2>
<span class="text-xs font-medium px-2 py-0.5 rounded-full bg-muted text-muted-foreground">{items.length}</span>
</div>
<div class="flex flex-col gap-2">
{items.map((task: Task) => (
<div
draggable={true}
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}
</span>
{task.dueDate && (
<span class="text-[10px] text-muted-foreground">
{new Date(task.dueDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
)}
</div>
</div>
))}
{items.length === 0 && (
<div class="text-center py-8 text-xs text-muted-foreground border-2 border-dashed border-border rounded-lg">
Drop tasks here
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
@@ -1,8 +1,8 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { toast } from '../components/ui/Toast';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { toast } from '@/components/ui/Toast';
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
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>
<Input
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"
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 { TwoFactorAuth } from '@/components/TwoFactorAuth';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { AIProviderIcon } from '@/components/AIProviderIcon';
import { useHaptics } from '@/lib/haptics';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { getApiV1BaseUrl, getApiOrigin } from '@/lib/api-url';
interface BrowserExtensionApiKey {
id: number;
@@ -198,7 +199,7 @@ export const Settings = () => {
const loadAISettings = async () => {
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, {
headers: {
@@ -218,7 +219,7 @@ export const Settings = () => {
const loadAvailableAIProviders = async () => {
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, {
headers: {
@@ -240,7 +241,7 @@ export const Settings = () => {
const loadSearchSettings = async () => {
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, {
headers: {
@@ -292,7 +293,7 @@ export const Settings = () => {
try {
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',
headers: {
'Authorization': `Bearer ${token}`,
@@ -370,7 +371,7 @@ export const Settings = () => {
try {
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',
headers: {
'Authorization': `Bearer ${token}`,
@@ -412,7 +413,7 @@ export const Settings = () => {
{/* Tab Navigation */}
<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}>
{(tab) => (
<button
@@ -420,7 +421,7 @@ export const Settings = () => {
setActiveTab(tab.id);
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
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
@@ -440,9 +441,11 @@ export const Settings = () => {
<Show when={activeTab() === 'account'}>
<div class="space-y-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">
<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
</h2>
<div class="space-y-4">
@@ -546,9 +549,9 @@ export const Settings = () => {
{isLoading() ? 'Updating...' : 'Update Profile'}
</button>
</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">
<IconLock class="size-5" />
Change Password
@@ -611,7 +614,7 @@ export const Settings = () => {
{isLoading() ? 'Changing...' : 'Change Password'}
</button>
</div>
</div>
</Card>
</div>
</div>
</Show>
@@ -1550,7 +1553,7 @@ export const Settings = () => {
// Save email settings
try {
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',
headers: {
'Authorization': `Bearer ${token}`,
@@ -1579,7 +1582,7 @@ export const Settings = () => {
// Test email configuration
try {
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',
headers: {
'Authorization': `Bearer ${token}`,
-245
View File
@@ -1,245 +0,0 @@
// Update service for handling application updates
import { isDemoMode } from '@/lib/demo-mode';
export interface UpdateInfo {
version: string;
releaseNotes: string;
downloadUrl: string;
mandatory: boolean;
size: string;
}
export interface UpdateStatus {
available: boolean;
downloading: boolean;
installing: boolean;
completed: boolean;
error?: string;
progress: number;
}
export interface UpdateCheckResponse {
updateAvailable: boolean;
currentVersion: string;
latestVersion: string;
updateInfo?: UpdateInfo;
}
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
export const updateService = {
// Check for available updates
async checkForUpdates(): Promise<UpdateCheckResponse> {
// If in demo mode, return mock update data
if (isDemoMode()) {
return {
updateAvailable: true,
currentVersion: '1.0.0',
latestVersion: '1.0.1',
updateInfo: {
version: '1.0.1',
releaseNotes: '• New AI features added\n• Performance improvements\n• Bug fixes and security patches\n• Enhanced user interface',
downloadUrl: 'https://github.com/trackeep/trackeep/releases/latest',
mandatory: false,
size: '~25MB'
}
};
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
try {
console.log('[Real Mode] Checking for updates at:', `${API_BASE}/api/updates/check`);
const response = await fetch(`${API_BASE}/api/updates/check`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Update check timed out');
}
console.error('Failed to check for updates:', error);
throw error;
}
},
// Install an update
async installUpdate(version: string): Promise<{ message: string; version: string }> {
// If in demo mode, simulate update installation
if (isDemoMode()) {
return {
message: 'Update started',
version: version
};
}
try {
const response = await fetch(`${API_BASE}/api/updates/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ version }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to install update:', error);
throw error;
}
},
// Get update progress
async getUpdateProgress(): Promise<UpdateStatus> {
// If in demo mode, return mock progress
if (isDemoMode()) {
return {
available: true,
downloading: false,
installing: false,
completed: false,
error: '',
progress: 0
};
}
try {
const response = await fetch(`${API_BASE}/api/updates/progress`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to get update progress:', error);
throw error;
}
},
// Get current app version from package.json
async getCurrentVersion(): Promise<string> {
// Try to get version from package.json first, then fallback
try {
const response = await fetch('/package.json');
if (response.ok) {
const packageJson = await response.json();
if (packageJson.version) {
console.log('Version from package.json:', packageJson.version);
return packageJson.version;
}
}
} catch (error) {
console.warn('Could not read package.json:', error);
}
// Fallback to environment variable or default
return import.meta.env.VITE_APP_VERSION || '1.2.5';
},
// Poll for update progress during installation
pollUpdateProgress(callback: (progress: UpdateStatus) => void, interval: number = 2000): () => void {
let isActive = true;
const poll = async () => {
if (!isActive) return;
try {
const progress = await this.getUpdateProgress();
callback(progress);
// Stop polling if update is completed or failed
if (progress.completed || progress.error) {
isActive = false;
}
} catch (error) {
console.error('Error polling update progress:', error);
isActive = false;
}
};
// Start polling
poll();
const intervalId = setInterval(poll, interval);
// Return cleanup function
return () => {
isActive = false;
clearInterval(intervalId);
};
},
// Simulate update progress for demo mode
simulateUpdateProgress(callback: (progress: UpdateStatus) => void): () => void {
let isActive = true;
let progress = 0;
let phase = 'downloading'; // 'downloading' -> 'installing' -> 'completed'
const simulate = () => {
if (!isActive) return;
progress += Math.random() * 15 + 5; // Random progress increment
if (progress >= 100) {
progress = 100;
if (phase === 'downloading') {
phase = 'installing';
progress = 0;
} else if (phase === 'installing') {
phase = 'completed';
isActive = false;
}
}
const updateStatus: UpdateStatus = {
available: true,
downloading: phase === 'downloading',
installing: phase === 'installing',
completed: phase === 'completed',
error: '',
progress: progress
};
callback(updateStatus);
if (isActive) {
const delay = phase === 'downloading' ? 500 : 1000; // Faster download, slower install
setTimeout(simulate, delay);
}
};
// Start simulation
simulate();
return () => {
isActive = false;
};
}
};
-184
View File
@@ -1,184 +0,0 @@
import { createSignal } from 'solid-js';
import { updateService, type UpdateInfo, type UpdateStatus } from '../services/updateService';
import { isDemoMode } from '@/lib/demo-mode';
// Global update state store
const [updateAvailable, setUpdateAvailable] = createSignal(false);
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
available: false,
downloading: false,
installing: false,
completed: false,
progress: 0
});
const [isChecking, setIsChecking] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [currentVersion, setCurrentVersion] = createSignal('1.0.0');
const [lastCheckTime, setLastCheckTime] = createSignal<number>(0);
let pollCleanup: (() => void) | null = null;
let checkInterval: number | null = null;
let checkInProgress = false;
// Check for updates
const checkForUpdates = async () => {
// Prevent multiple simultaneous checks with both signal and flag
if (isChecking() || checkInProgress) return;
checkInProgress = true;
setIsChecking(true);
setError(null);
try {
const response = await updateService.checkForUpdates();
setUpdateAvailable(response.updateAvailable);
setUpdateInfo(response.updateInfo || null);
setCurrentVersion(response.currentVersion);
setLastCheckTime(Date.now());
// Save last check time to localStorage
localStorage.setItem('lastUpdateCheck', Date.now().toString());
if (response.updateAvailable && response.updateInfo) {
setUpdateStatus(prev => ({ ...prev, available: true }));
}
} catch (err) {
console.error('Failed to check for updates:', err);
setError('Failed to check for updates');
} finally {
setIsChecking(false);
checkInProgress = false;
}
};
// Install update
const installUpdate = async () => {
if (!updateInfo()) return;
try {
setError(null);
await updateService.installUpdate(updateInfo()!.version);
// Start polling for progress or simulation in demo mode
if (isDemoMode()) {
pollCleanup = updateService.simulateUpdateProgress((progress: UpdateStatus) => {
setUpdateStatus(progress);
if (progress.completed) {
// Show success notification or trigger reload
setTimeout(() => {
window.location.reload();
}, 3000);
}
if (progress.error) {
setError(progress.error);
}
});
} else {
pollCleanup = updateService.pollUpdateProgress((progress: UpdateStatus) => {
setUpdateStatus(progress);
if (progress.completed) {
// Show success notification or trigger reload
setTimeout(() => {
window.location.reload();
}, 3000);
}
if (progress.error) {
setError(progress.error);
}
});
}
} catch (err) {
console.error('Failed to install update:', err);
setError('Failed to install update');
}
};
// Cancel update
const cancelUpdate = () => {
if (pollCleanup) {
pollCleanup();
pollCleanup = null;
}
setUpdateStatus({
available: updateAvailable(),
downloading: false,
installing: false,
completed: false,
progress: 0
});
};
// Initialize update checking
const initializeUpdateChecking = async () => {
// Set current version
setCurrentVersion(await updateService.getCurrentVersion());
// Check if last check was more than 24 hours ago
const lastCheckTimeStr = localStorage.getItem('lastUpdateCheck');
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
if (!lastCheckTimeStr || (now - parseInt(lastCheckTimeStr)) > twentyFourHours) {
// Check for updates on initialization if it's been more than 24 hours
checkForUpdates();
} else {
setLastCheckTime(parseInt(lastCheckTimeStr));
}
// Set up periodic checking (every 24 hours)
checkInterval = setInterval(checkForUpdates, twentyFourHours);
};
// Cleanup
const cleanup = () => {
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
if (pollCleanup) {
pollCleanup();
pollCleanup = null;
}
};
// Auto-initialize when store is imported
let initialized = false;
const ensureInitialized = async () => {
if (!initialized) {
await initializeUpdateChecking();
initialized = true;
}
};
// Export store functions and signals
export const updateStore = {
// Signals
updateAvailable,
updateInfo,
updateStatus,
isChecking,
error,
currentVersion,
lastCheckTime,
// Actions
checkForUpdates,
installUpdate,
cancelUpdate,
// Lifecycle
ensureInitialized,
cleanup
};
// Auto-cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', cleanup);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/usr/bin/python3
+1
View File
@@ -0,0 +1 @@
/home/tdvorak/Desktop/PROG+HTML/Trackeep
+619
View File
@@ -0,0 +1,619 @@
# Graph Report - Trackeep (2026-04-30)
## Corpus Check
- 330 files · ~395,125 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 2549 nodes · 3694 edges · 99 communities detected
- Extraction: 85% EXTRACTED · 15% INFERRED · 0% AMBIGUOUS · INFERRED: 562 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- [[_COMMUNITY_Community 0|Community 0]]
- [[_COMMUNITY_Community 1|Community 1]]
- [[_COMMUNITY_Community 2|Community 2]]
- [[_COMMUNITY_Community 3|Community 3]]
- [[_COMMUNITY_Community 4|Community 4]]
- [[_COMMUNITY_Community 5|Community 5]]
- [[_COMMUNITY_Community 6|Community 6]]
- [[_COMMUNITY_Community 7|Community 7]]
- [[_COMMUNITY_Community 8|Community 8]]
- [[_COMMUNITY_Community 9|Community 9]]
- [[_COMMUNITY_Community 10|Community 10]]
- [[_COMMUNITY_Community 11|Community 11]]
- [[_COMMUNITY_Community 12|Community 12]]
- [[_COMMUNITY_Community 13|Community 13]]
- [[_COMMUNITY_Community 14|Community 14]]
- [[_COMMUNITY_Community 15|Community 15]]
- [[_COMMUNITY_Community 16|Community 16]]
- [[_COMMUNITY_Community 17|Community 17]]
- [[_COMMUNITY_Community 18|Community 18]]
- [[_COMMUNITY_Community 19|Community 19]]
- [[_COMMUNITY_Community 20|Community 20]]
- [[_COMMUNITY_Community 21|Community 21]]
- [[_COMMUNITY_Community 22|Community 22]]
- [[_COMMUNITY_Community 23|Community 23]]
- [[_COMMUNITY_Community 24|Community 24]]
- [[_COMMUNITY_Community 25|Community 25]]
- [[_COMMUNITY_Community 26|Community 26]]
- [[_COMMUNITY_Community 27|Community 27]]
- [[_COMMUNITY_Community 28|Community 28]]
- [[_COMMUNITY_Community 29|Community 29]]
- [[_COMMUNITY_Community 30|Community 30]]
- [[_COMMUNITY_Community 31|Community 31]]
- [[_COMMUNITY_Community 32|Community 32]]
- [[_COMMUNITY_Community 33|Community 33]]
- [[_COMMUNITY_Community 34|Community 34]]
- [[_COMMUNITY_Community 35|Community 35]]
- [[_COMMUNITY_Community 36|Community 36]]
- [[_COMMUNITY_Community 37|Community 37]]
- [[_COMMUNITY_Community 38|Community 38]]
- [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 41|Community 41]]
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
- [[_COMMUNITY_Community 44|Community 44]]
- [[_COMMUNITY_Community 45|Community 45]]
- [[_COMMUNITY_Community 46|Community 46]]
- [[_COMMUNITY_Community 47|Community 47]]
- [[_COMMUNITY_Community 48|Community 48]]
- [[_COMMUNITY_Community 49|Community 49]]
- [[_COMMUNITY_Community 50|Community 50]]
- [[_COMMUNITY_Community 51|Community 51]]
- [[_COMMUNITY_Community 52|Community 52]]
- [[_COMMUNITY_Community 53|Community 53]]
- [[_COMMUNITY_Community 54|Community 54]]
- [[_COMMUNITY_Community 55|Community 55]]
- [[_COMMUNITY_Community 56|Community 56]]
- [[_COMMUNITY_Community 57|Community 57]]
- [[_COMMUNITY_Community 58|Community 58]]
- [[_COMMUNITY_Community 60|Community 60]]
- [[_COMMUNITY_Community 61|Community 61]]
- [[_COMMUNITY_Community 62|Community 62]]
- [[_COMMUNITY_Community 63|Community 63]]
- [[_COMMUNITY_Community 64|Community 64]]
- [[_COMMUNITY_Community 66|Community 66]]
- [[_COMMUNITY_Community 67|Community 67]]
- [[_COMMUNITY_Community 68|Community 68]]
- [[_COMMUNITY_Community 69|Community 69]]
- [[_COMMUNITY_Community 70|Community 70]]
- [[_COMMUNITY_Community 71|Community 71]]
- [[_COMMUNITY_Community 74|Community 74]]
- [[_COMMUNITY_Community 75|Community 75]]
- [[_COMMUNITY_Community 77|Community 77]]
- [[_COMMUNITY_Community 79|Community 79]]
- [[_COMMUNITY_Community 81|Community 81]]
- [[_COMMUNITY_Community 83|Community 83]]
- [[_COMMUNITY_Community 86|Community 86]]
- [[_COMMUNITY_Community 87|Community 87]]
- [[_COMMUNITY_Community 95|Community 95]]
- [[_COMMUNITY_Community 96|Community 96]]
- [[_COMMUNITY_Community 97|Community 97]]
- [[_COMMUNITY_Community 99|Community 99]]
- [[_COMMUNITY_Community 101|Community 101]]
- [[_COMMUNITY_Community 102|Community 102]]
- [[_COMMUNITY_Community 103|Community 103]]
- [[_COMMUNITY_Community 104|Community 104]]
- [[_COMMUNITY_Community 105|Community 105]]
- [[_COMMUNITY_Community 106|Community 106]]
- [[_COMMUNITY_Community 112|Community 112]]
- [[_COMMUNITY_Community 119|Community 119]]
- [[_COMMUNITY_Community 120|Community 120]]
- [[_COMMUNITY_Community 130|Community 130]]
- [[_COMMUNITY_Community 131|Community 131]]
- [[_COMMUNITY_Community 132|Community 132]]
- [[_COMMUNITY_Community 133|Community 133]]
- [[_COMMUNITY_Community 134|Community 134]]
- [[_COMMUNITY_Community 135|Community 135]]
- [[_COMMUNITY_Community 136|Community 136]]
- [[_COMMUNITY_Community 138|Community 138]]
## God Nodes (most connected - your core abstractions)
1. `GetDB()` - 95 edges
2. `New()` - 50 edges
3. `contains()` - 42 edges
4. `main()` - 41 edges
5. `toLower()` - 34 edges
6. `demoFetch()` - 32 edges
7. `Queries` - 28 edges
8. `IntegrationHandler` - 26 edges
9. `setError()` - 25 edges
10. `getAuthUserID()` - 24 edges
## Surprising Connections (you probably didn't know these)
- `createTask()` --calls--> `setError()` [INFERRED]
mobile/src/screens/TasksScreen.tsx → desktop/src/main.js
- `toggleTaskStatus()` --calls--> `setError()` [INFERRED]
mobile/src/screens/TasksScreen.tsx → desktop/src/main.js
- `deleteTask()` --calls--> `setError()` [INFERRED]
mobile/src/screens/TasksScreen.tsx → desktop/src/main.js
- `startEntry()` --calls--> `setError()` [INFERRED]
mobile/src/screens/TimeEntriesScreen.tsx → desktop/src/main.js
- `stopEntry()` --calls--> `setError()` [INFERRED]
mobile/src/screens/TimeEntriesScreen.tsx → desktop/src/main.js
## Communities
### Community 0 - "Community 0"
Cohesion: 0.03
Nodes (147): DB, NewDB(), centralizedOAuthUser, buildControlServiceCallbackURL(), buildControlServiceGitHubStartURL(), buildGitHubAppInstallCallbackURL(), clearControlServiceAuthFlowState(), controlServiceClient() (+139 more)
### Community 1 - "Community 1"
Cohesion: 0.02
Nodes (74): applySmartSuggestions(), detectAndApplySmartData(), handleQuickSave(), hideMessage(), saveBookmark(), setButtonLoading(), showMessage(), uploadFile() (+66 more)
### Community 2 - "Community 2"
Cohesion: 0.03
Nodes (62): handleDemoMode(), ProtectedRoute(), DemoStatus(), Header(), getAuthHeaders(), isDemoMode(), useAuth(), DemoModeApiClient (+54 more)
### Community 3 - "Community 3"
Cohesion: 0.02
Nodes (53): initializeDragonflyDB(), main(), AppConfig, Config, getDurationEnv(), getEnvWithDefault(), Load(), DatabaseConfig (+45 more)
### Community 4 - "Community 4"
Cohesion: 0.03
Nodes (84): SeedData(), GetDB(), AdminDeleteLearningPath(), AdminGetAllLearningPaths(), AdminGetStats(), AdminGetUsers(), AdminMiddleware(), AdminReviewLearningPath() (+76 more)
### Community 5 - "Community 5"
Cohesion: 0.05
Nodes (78): AddConversationMemberRequest, AdminCreateUser(), AttachmentInput, conversationListItem, CreateConversationRequest, CreateMessageRequest, CreateReactionRequest, CreateVaultItemRequest (+70 more)
### Community 6 - "Community 6"
Cohesion: 0.04
Nodes (46): AcceptTaskSuggestion(), buildTaskContext(), generateAIContent(), generateAISummary(), GenerateContent(), GenerateTagSuggestions(), generateTaskSuggestions(), GetAIProviders() (+38 more)
### Community 7 - "Community 7"
Cohesion: 0.05
Nodes (45): disableTOTP(), enableTOTP(), fetchTOTPStatus(), getAuthHeaders(), regenerateBackupCodes(), setupTOTP(), verifyBackupCode(), verifyTOTPCode() (+37 more)
### Community 8 - "Community 8"
Cohesion: 0.07
Nodes (60): Ae(), ar(), at(), Bt(), children(), cr(), Ct(), de() (+52 more)
### Community 9 - "Community 9"
Cohesion: 0.04
Nodes (50): GetAISettings(), getDefaultAISettings(), isMasked(), UpdateAISettings(), AISettings, AuthMiddleware(), ChangePassword(), CheckUsers() (+42 more)
### Community 10 - "Community 10"
Cohesion: 0.05
Nodes (33): GetChannelVideos(), GetFireshipVideos(), GetNetworkChuckVideos(), GetYouTubeChannelVideosFromURL(), GetYouTubeTrending(), SearchYouTube(), YouTubeSearchTest(), YouTubeChannelRequest (+25 more)
### Community 11 - "Community 11"
Cohesion: 0.05
Nodes (27): downloadUpdate(), containsMaliciousContent(), InputValidationMiddleware(), sanitizeInput(), ValidateRequestBody(), generateMemoryCacheKey(), InvalidateMemoryCache(), MemoryCacheInvalidationMiddleware() (+19 more)
### Community 12 - "Community 12"
Cohesion: 0.06
Nodes (28): initializeSecuritySecrets(), GenerateAPIKey(), generateRandomString(), createFileShareRequest, buildPublicShareURL(), CreateFileShare(), determineFileType(), generateSecureShareToken() (+20 more)
### Community 13 - "Community 13"
Cohesion: 0.08
Nodes (41): getControlServiceTokenForUser(), CreateEncryptedNote(), DecryptNoteContent(), determineFileTypeForEncryption(), DownloadEncryptedFile(), EncryptNoteContent(), generateRandomStringForFile(), GetEncryptedNote() (+33 more)
### Community 14 - "Community 14"
Cohesion: 0.06
Nodes (18): InitDatabase(), shouldRunLegacySQLMigrations(), InitLogger(), Logger(), logPerformanceEvent(), logRequestBody(), logSecurityEvent(), PerformanceLogger() (+10 more)
### Community 15 - "Community 15"
Cohesion: 0.1
Nodes (24): getApiOrigin(), getApiV1BaseUrl(), trimApiSuffix(), trimTrailingSlash(), getAuthCallbackUrl(), startGitHubSignIn(), backupSelectedRepositories(), checkGitHubConnection() (+16 more)
### Community 16 - "Community 16"
Cohesion: 0.07
Nodes (12): calculateFocusScore(), NewAnalyticsHandler(), AnalyticsHandler, getIntValue(), getStringValue(), logSearchAnalytics(), min(), performEnhancedSearch() (+4 more)
### Community 17 - "Community 17"
Cohesion: 0.1
Nodes (23): progressWriter, UpdateRequest, backupUserData(), broadcastProgress(), CheckForUpdates(), checkForUpdatesWithDocker(), checkForUpdatesWithDockerRegistry(), copyDirectory() (+15 more)
### Community 18 - "Community 18"
Cohesion: 0.1
Nodes (25): BraveNewsResponse, BraveSearchResponse, BraveSearchResult, SearchNews(), SearchWeb(), getBoolEnvWithDefault(), getDefaultSearchSettings(), getEnvWithDefault() (+17 more)
### Community 19 - "Community 19"
Cohesion: 0.07
Nodes (2): DBTX, Queries
### Community 20 - "Community 20"
Cohesion: 0.11
Nodes (6): NewAIRecommendationHandler(), AIRecommendationHandler, NewAIRecommendationService(), AIRecommendationService, RecommendationRequest, RecommendationScore
### Community 21 - "Community 21"
Cohesion: 0.11
Nodes (10): BoundingBox, ComputerVisionService, DocumentAnalysis, DocumentSection, DocumentTable, FaceDetection, ImageAnalysisRequest, ImageAnalysisResponse (+2 more)
### Community 22 - "Community 22"
Cohesion: 0.09
Nodes (6): NewVideoBookmarkHandler(), VideoBookmarkHandler, SaveVideoRequest, NewVideoBookmarkService(), VideoBookmarkService, VideoInfo
### Community 23 - "Community 23"
Cohesion: 0.13
Nodes (1): IntegrationHandler
### Community 24 - "Community 24"
Cohesion: 0.09
Nodes (5): adaptBookmarkFromApi(), editBookmark(), handleAddBookmark(), handleEditBookmark(), handleSubmit()
### Community 25 - "Community 25"
Cohesion: 0.11
Nodes (10): handlePageChange(), stats(), storagePercentage(), taskCompletionRate(), totalPages(), weeklyActivityTotal(), handleRefresh(), loadStats() (+2 more)
### Community 26 - "Community 26"
Cohesion: 0.16
Nodes (11): containsString(), GetAllFavicons(), GetFavicon(), NewFaviconFetcher(), BenchmarkFaviconFetch(), TestExtractHeadSection(), TestFaviconFetcher(), TestMakeAbsoluteURL() (+3 more)
### Community 27 - "Community 27"
Cohesion: 0.13
Nodes (7): fetchWithAuth(), getAuthToken(), handleBackupSelectedRepos(), handleInstallGitHubApp(), loadFiles(), loadGitHubBackupWorkspace(), parseRepoPayload()
### Community 28 - "Community 28"
Cohesion: 0.1
Nodes (12): Challenge, ChallengeMilestone, ChallengeMilestoneCompletion, ChallengeParticipant, ChallengeResource, ChallengeTag, ChallengeTeam, Mentorship (+4 more)
### Community 29 - "Community 29"
Cohesion: 0.16
Nodes (14): getAuthHeaders(), getQuickSearchSuggestions(), searchBrave(), searchNews(), searchWeb(), extractVideoId(), getVideoInfo(), handleKeyPress() (+6 more)
### Community 30 - "Community 30"
Cohesion: 0.11
Nodes (3): looksLikeYouTube(), saveBookmark(), submitManualShare()
### Community 31 - "Community 31"
Cohesion: 0.13
Nodes (10): hexToHsl(), isChatOpen(), toggleChat(), applyCustomColors(), applyScheme(), resetColors(), toggleDarkMode(), updateSchemesForTheme() (+2 more)
### Community 32 - "Community 32"
Cohesion: 0.11
Nodes (9): Analytics, AnalyticsReport, ContentAnalytics, GitHubAnalytics, Goal, HabitAnalytics, LearningAnalytics, Milestone (+1 more)
### Community 33 - "Community 33"
Cohesion: 0.18
Nodes (17): GenerateEmbeddingRequest, GenerateEmbeddingResponse, buildSemanticSearchResult(), compactSemanticText(), cosineSimilarity(), findSimilarContent(), GenerateEmbedding(), generateHighlights() (+9 more)
### Community 34 - "Community 34"
Cohesion: 0.12
Nodes (16): DiscordConfig, GitHubConfig, GoogleConfig, Integration, IntegrationConfig, IntegrationStatus, IntegrationType, NotionConfig (+8 more)
### Community 35 - "Community 35"
Cohesion: 0.12
Nodes (3): calculateMatchScore(), NewCommunityHandler(), CommunityHandler
### Community 36 - "Community 36"
Cohesion: 0.13
Nodes (4): Metrics, copyDurationMap(), copyMap(), GetMetrics()
### Community 37 - "Community 37"
Cohesion: 0.12
Nodes (6): isSupported(), triggerHaptic(), useHaptics(), Messages(), Profile(), Button()
### Community 38 - "Community 38"
Cohesion: 0.2
Nodes (10): completeSetup(), generateApiKey(), saveSettings(), setButtonLoading(), showConnectionStatus(), showMessage(), showSetupConnectionStatus(), testConnection() (+2 more)
### Community 39 - "Community 39"
Cohesion: 0.12
Nodes (2): NewGoalsHabitsHandler(), GoalsHabitsHandler
### Community 40 - "Community 40"
Cohesion: 0.12
Nodes (2): NewMarketplaceHandler(), MarketplaceHandler
### Community 41 - "Community 41"
Cohesion: 0.19
Nodes (8): generateCalendarDays(), getCellClass(), getDateClass(), getDaysInMonth(), getFirstDayOfMonth(), isDateEnd(), isDateInRange(), isDateStart()
### Community 42 - "Community 42"
Cohesion: 0.23
Nodes (11): closeCreateWorkspaceModal(), createDefaultWorkspace(), getAuthToken(), getWorkspaceIcon(), handleCreateWorkspace(), handleWorkspaceSelect(), loadWorkspaces(), normalizeWorkspace() (+3 more)
### Community 43 - "Community 43"
Cohesion: 0.27
Nodes (14): cleanupHistory(), detectContentType(), getTrackeepConfig(), getYouTubeHistory(), openPopupWithContext(), parseResponseError(), parseYouTubeVideoMeta(), saveYouTubeBookmark() (+6 more)
### Community 44 - "Community 44"
Cohesion: 0.13
Nodes (1): Validator
### Community 45 - "Community 45"
Cohesion: 0.2
Nodes (9): Category, estimateReadingTime(), generateSlug(), Template, TemplateVariable, WikiAttachment, WikiBacklink, WikiPage (+1 more)
### Community 46 - "Community 46"
Cohesion: 0.19
Nodes (6): generateId(), getFileExtension(), handleDrop(), handleFileSelect(), handleUrlImport(), simulateUpload()
### Community 47 - "Community 47"
Cohesion: 0.17
Nodes (6): GoalTag, GoalTemplate, GoalTemplateMilestone, Habit, HabitEntry, HabitTag
### Community 48 - "Community 48"
Cohesion: 0.17
Nodes (2): canUpload(), isValidUrl()
### Community 49 - "Community 49"
Cohesion: 0.24
Nodes (8): handleHexChange(), handleMouseMove(), handleSavedColorClick(), handleSliderMouseDown(), hexToHSL(), hslToHex(), updateColorFromHue(), updateHueFromPosition()
### Community 50 - "Community 50"
Cohesion: 0.22
Nodes (6): fetchSessions(), getProviderFromModel(), getToken(), handleSendMessage(), loadSessionMessages(), scrollToHighlightedMessage()
### Community 51 - "Community 51"
Cohesion: 0.17
Nodes (11): Team, TeamActivity, TeamBookmark, TeamFile, TeamInvitation, TeamMember, TeamNote, TeamProject (+3 more)
### Community 52 - "Community 52"
Cohesion: 0.17
Nodes (11): AuditLog, Bookmark, BookmarkTag, File, FileTag, Note, NoteTag, Tag (+3 more)
### Community 53 - "Community 53"
Cohesion: 0.2
Nodes (6): ContentShare, generateShareToken(), MarketplaceItem, MarketplacePurchase, MarketplaceReview, MarketplaceTag
### Community 54 - "Community 54"
Cohesion: 0.18
Nodes (10): AddTaskTagParams, CreateTaskParams, DeleteTaskParams, GetTaskByIDParams, GetTasksByStatusParams, GetTasksByTagParams, GetTasksByUserParams, RemoveTaskTagParams (+2 more)
### Community 55 - "Community 55"
Cohesion: 0.22
Nodes (4): getMockCalendarEvents(), createEvent(), fetchCalendarData(), toggleEventCompletion()
### Community 56 - "Community 56"
Cohesion: 0.2
Nodes (3): CalendarEvent, CalendarSettings, RecurrenceRule
### Community 57 - "Community 57"
Cohesion: 0.2
Nodes (9): AddBookmarkTagParams, CreateBookmarkParams, DeleteBookmarkParams, GetBookmarkByIDParams, GetBookmarksByTagParams, GetBookmarksByUserParams, RemoveBookmarkTagParams, SearchBookmarksParams (+1 more)
### Community 58 - "Community 58"
Cohesion: 0.33
Nodes (6): getToken(), handleAddMember(), handleDeleteMember(), loadMembers(), onWorkspaceChanged(), resolveWorkspaceId()
### Community 60 - "Community 60"
Cohesion: 0.31
Nodes (4): apiRequest(), getToken(), MessagesRealtimeClient, uploadChatFile()
### Community 61 - "Community 61"
Cohesion: 0.22
Nodes (3): AIRecommendation, RecommendationInteraction, UserPreference
### Community 62 - "Community 62"
Cohesion: 0.22
Nodes (8): CreateUserParams, CreateUserRow, GetUserByEmailRow, GetUserByIDRow, ListUsersParams, ListUsersRow, UpdateUserParams, UpdateUserRow
### Community 63 - "Community 63"
Cohesion: 0.46
Nodes (7): closePrompt(), detectAndNotify(), escapeHtml(), initDetection(), parseYouTubeVideo(), renderPrompt(), sendMessage()
### Community 64 - "Community 64"
Cohesion: 0.25
Nodes (5): ScrapedContent, ScrapedImage, ScrapedLink, ScrapedVideo, ScrapingJob
### Community 66 - "Community 66"
Cohesion: 0.48
Nodes (5): buildShareDraft(), firstCandidateTitleFromText(), firstUrlFromText(), normalizeUrl(), titleFromUrl()
### Community 67 - "Community 67"
Cohesion: 0.29
Nodes (1): UserServiceExample
### Community 68 - "Community 68"
Cohesion: 0.29
Nodes (6): AICodeReview, AIContentGeneration, AILearningRecommendation, AISummary, AITagSuggestion, AITaskSuggestion
### Community 69 - "Community 69"
Cohesion: 0.29
Nodes (6): Follow, Project, ProjectTag, Skill, SocialLink, UserProfileStats
### Community 70 - "Community 70"
Cohesion: 0.33
Nodes (1): TimeEntry
### Community 71 - "Community 71"
Cohesion: 0.29
Nodes (5): ContentEmbedding, SavedSearch, SavedSearchTag, SearchAnalytics, SearchSuggestion
### Community 74 - "Community 74"
Cohesion: 0.29
Nodes (2): importData(), ExportImport()
### Community 75 - "Community 75"
Cohesion: 0.33
Nodes (2): callAIAPI(), handleSendMessage()
### Community 77 - "Community 77"
Cohesion: 0.33
Nodes (5): Enrollment, LearningModule, LearningPath, ModuleResource, Progress
### Community 79 - "Community 79"
Cohesion: 0.53
Nodes (4): addTag(), filteredTags(), handleInputKeyDown(), removeTag()
### Community 81 - "Community 81"
Cohesion: 0.47
Nodes (3): CodeBlock(), inferCodeLanguage(), normalizeCodeLanguage()
### Community 83 - "Community 83"
Cohesion: 0.6
Nodes (5): breakDownDuration(), formatDuration(), formatDurationDetailed(), formatDurationShort(), getLargestTimeUnit()
### Community 86 - "Community 86"
Cohesion: 0.4
Nodes (1): GracefulShutdown
### Community 87 - "Community 87"
Cohesion: 0.4
Nodes (1): ErrorResponse
### Community 95 - "Community 95"
Cohesion: 0.5
Nodes (1): YouTubeChannelCache
### Community 96 - "Community 96"
Cohesion: 0.5
Nodes (1): FileAnalysis
### Community 97 - "Community 97"
Cohesion: 0.5
Nodes (3): GitHubAppInstallation, GitHubAppInstallState, GitHubRepoBackup
### Community 99 - "Community 99"
Cohesion: 0.67
Nodes (2): handleSubmit(), resetForm()
### Community 101 - "Community 101"
Cohesion: 0.67
Nodes (2): File, FileType
### Community 102 - "Community 102"
Cohesion: 0.67
Nodes (2): APIKey, BrowserExtension
### Community 103 - "Community 103"
Cohesion: 0.67
Nodes (2): ChatMessage, ChatSession
### Community 104 - "Community 104"
Cohesion: 0.67
Nodes (1): VideoBookmark
### Community 105 - "Community 105"
Cohesion: 0.67
Nodes (2): Course, LearningPathCourse
### Community 106 - "Community 106"
Cohesion: 0.67
Nodes (1): ProductionConfig
### Community 112 - "Community 112"
Cohesion: 1.0
Nodes (2): extractVideoId(), handleSubmit()
### Community 119 - "Community 119"
Cohesion: 1.0
Nodes (2): handleKeyPress(), handleSendMessage()
### Community 120 - "Community 120"
Cohesion: 1.0
Nodes (2): handleKeyPress(), handleSendMessage()
### Community 130 - "Community 130"
Cohesion: 1.0
Nodes (1): Note
### Community 131 - "Community 131"
Cohesion: 1.0
Nodes (1): User
### Community 132 - "Community 132"
Cohesion: 1.0
Nodes (1): Bookmark
### Community 133 - "Community 133"
Cohesion: 1.0
Nodes (1): Tag
### Community 134 - "Community 134"
Cohesion: 1.0
Nodes (1): GitHubUserAuth
### Community 135 - "Community 135"
Cohesion: 1.0
Nodes (1): ControlServiceSession
### Community 136 - "Community 136"
Cohesion: 1.0
Nodes (1): UserAISettings
### Community 138 - "Community 138"
Cohesion: 1.0
Nodes (1): Querier
## Knowledge Gaps
- **290 isolated node(s):** `Channel`, `ChannelInfo`, `DetectedSuggestion`, `DetectedAttachment`, `CacheEntry` (+285 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `Community 19`** (30 nodes): `db.go`, `DBTX`, `Queries`, `.AddBookmarkTag()`, `.AddTaskTag()`, `.CreateBookmark()`, `.CreateTask()`, `.CreateUser()`, `.DeleteBookmark()`, `.DeleteTask()`, `.DeleteUser()`, `.GetBookmarkByID()`, `.GetBookmarksByTag()`, `.GetBookmarksByUser()`, `.GetTaskByID()`, `.GetTasksByStatus()`, `.GetTasksByTag()`, `.GetTasksByUser()`, `.GetUserByEmail()`, `.GetUserByID()`, `.ListUsers()`, `.RemoveBookmarkTag()`, `.RemoveTaskTag()`, `.SearchBookmarks()`, `.SearchTasks()`, `.UpdateBookmark()`, `.UpdateLastLogin()`, `.UpdateTask()`, `.UpdateUser()`, `.WithTx()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 23`** (26 nodes): `IntegrationHandler`, `.AuthorizeIntegration()`, `.CreateIntegration()`, `.DeleteIntegration()`, `.exchangeDiscordCode()`, `.exchangeGitHubCode()`, `.exchangeGoogleCode()`, `.exchangeNotionCode()`, `.exchangeSlackCode()`, `.getDiscordAuthURL()`, `.getGitHubAuthURL()`, `.getGoogleAuthURL()`, `.GetIntegration()`, `.GetIntegrations()`, `.getNotionAuthURL()`, `.getSlackAuthURL()`, `.GetSyncLogs()`, `.OAuthCallback()`, `.performSync()`, `.syncDiscord()`, `.syncGitHub()`, `.syncGoogle()`, `.SyncIntegration()`, `.syncNotion()`, `.syncSlack()`, `.UpdateIntegration()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 39`** (16 nodes): `goals_habits.go`, `NewGoalsHabitsHandler()`, `GoalsHabitsHandler`, `.CreateGoal()`, `.CreateHabit()`, `.CreateHabitEntry()`, `.DeleteGoal()`, `.DeleteHabit()`, `.GetDashboardStats()`, `.GetGoal()`, `.GetGoals()`, `.GetHabit()`, `.GetHabitEntries()`, `.GetHabits()`, `.UpdateGoal()`, `.UpdateHabit()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 40`** (16 nodes): `marketplace.go`, `NewMarketplaceHandler()`, `MarketplaceHandler`, `.CreateContentShare()`, `.CreateMarketplaceItem()`, `.CreateMarketplaceReview()`, `.DeleteContentShare()`, `.DeleteMarketplaceItem()`, `.GetContentShare()`, `.GetMarketplaceItem()`, `.GetMarketplaceItems()`, `.GetMarketplaceReviews()`, `.GetMarketplaceStats()`, `.GetMyContentShares()`, `.GetMyMarketplaceItems()`, `.UpdateMarketplaceItem()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 44`** (15 nodes): `validator.go`, `Validator`, `.Clear()`, `.Email()`, `.GetError()`, `.GetErrors()`, `.HasErrors()`, `.In()`, `.Match()`, `.MaxLength()`, `.MinLength()`, `NewValidator()`, `.Required()`, `.URL()`, `.ValidatePassword()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 48`** (13 nodes): `FileUploadModal.tsx`, `addAssociation()`, `addTag()`, `canUpload()`, `getFileIcon()`, `handleDrag()`, `handleDrop()`, `handleFileSelect()`, `handleKeyDown()`, `handleUpload()`, `isValidUrl()`, `removeAssociation()`, `removeTag()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 67`** (7 nodes): `user_service_example.go`, `NewUserServiceExample()`, `UserServiceExample`, `.CreateUserExample()`, `.GetUserExample()`, `.SearchUsersExample()`, `.TransactionExample()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 70`** (7 nodes): `time_entry.go`, `TimeEntry`, `.BeforeCreate()`, `.BeforeUpdate()`, `.GetDuration()`, `.GetFormattedDuration()`, `.Stop()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 74`** (7 nodes): `ExportImport.tsx`, `export-import.ts`, `exportData()`, `getImportSummary()`, `importData()`, `validateImportData()`, `ExportImport()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 75`** (7 nodes): `AIChat.tsx`, `callAIAPI()`, `checkMobile()`, `handleClickOutside()`, `handleSendMessage()`, `initializeAIModels()`, `startNewChat()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 86`** (5 nodes): `graceful_shutdown.go`, `NewGracefulShutdown()`, `GracefulShutdown`, `.AddCleanupFunc()`, `.Wait()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 87`** (5 nodes): `error_handler.go`, `ErrorHandlerMiddleware()`, `MethodNotAllowedHandler()`, `NotFoundHandler()`, `ErrorResponse`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 95`** (4 nodes): `youtube_cache.go`, `YouTubeChannelCache`, `.IsExpired()`, `.TableName()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 96`** (4 nodes): `file_analysis.go`, `FileAnalysis`, `.BeforeCreate()`, `.TableName()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 99`** (4 nodes): `LearningPathModal.tsx`, `handleInputChange()`, `handleSubmit()`, `resetForm()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 101`** (3 nodes): `file.go`, `File`, `FileType`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 102`** (3 nodes): `browser_extension.go`, `APIKey`, `BrowserExtension`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 103`** (3 nodes): `chat.go`, `ChatMessage`, `ChatSession`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 104`** (3 nodes): `video_bookmark.go`, `VideoBookmark`, `.TableName()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 105`** (3 nodes): `course.go`, `Course`, `LearningPathCourse`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 106`** (3 nodes): `production.go`, `DefaultProductionConfig()`, `ProductionConfig`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 112`** (3 nodes): `VideoUploadModal.tsx`, `extractVideoId()`, `handleSubmit()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 119`** (3 nodes): `FloatingAI.tsx`, `handleKeyPress()`, `handleSendMessage()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 120`** (3 nodes): `AIChatPanel.tsx`, `handleKeyPress()`, `handleSendMessage()`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 130`** (2 nodes): `note.go`, `Note`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 131`** (2 nodes): `user.go`, `User`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 132`** (2 nodes): `bookmark.go`, `Bookmark`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 133`** (2 nodes): `tag.go`, `Tag`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 134`** (2 nodes): `github_user_auth.go`, `GitHubUserAuth`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 135`** (2 nodes): `control_service_auth.go`, `ControlServiceSession`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 136`** (2 nodes): `ai_settings.go`, `UserAISettings`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 138`** (2 nodes): `querier.go`, `Querier`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `GetDB()` connect `Community 4` to `Community 0`, `Community 33`, `Community 1`, `Community 3`, `Community 5`, `Community 9`, `Community 10`, `Community 12`, `Community 13`, `Community 14`, `Community 22`?**
_High betweenness centrality (0.165) - this node is a cross-community bridge._
- **Why does `main()` connect `Community 3` to `Community 1`, `Community 35`, `Community 4`, `Community 39`, `Community 40`, `Community 9`, `Community 11`, `Community 12`, `Community 14`, `Community 16`, `Community 20`, `Community 22`?**
_High betweenness centrality (0.149) - this node is a cross-community bridge._
- **Why does `contains()` connect `Community 1` to `Community 0`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 17`, `Community 21`, `Community 26`?**
_High betweenness centrality (0.117) - this node is a cross-community bridge._
- **Are the 94 inferred relationships involving `GetDB()` (e.g. with `main()` and `SeedData()`) actually correct?**
_`GetDB()` has 94 INFERRED edges - model-reasoned connections that need verification._
- **Are the 49 inferred relationships involving `New()` (e.g. with `InitLogger()` and `applySuggestionAction()`) actually correct?**
_`New()` has 49 INFERRED edges - model-reasoned connections that need verification._
- **Are the 40 inferred relationships involving `contains()` (e.g. with `DetectMessageContent()` and `getDefaultFavicon()`) actually correct?**
_`contains()` has 40 INFERRED edges - model-reasoned connections that need verification._
- **Are the 38 inferred relationships involving `main()` (e.g. with `Load()` and `InitDatabase()`) actually correct?**
_`main()` has 38 INFERRED edges - model-reasoned connections that need verification._
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,7 @@
"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",
"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": {
"concurrently": "^8.2.2",
+1 -1
View File
@@ -43,7 +43,7 @@ if [ -f ".env" ]; then
pass "Environment file exists"
# Check required variables
required_vars=("DB_PASSWORD" "DRAGONFLY_PASSWORD" "JWT_SECRET" "ENCRYPTION_KEY")
required_vars=("DB_PASSWORD" "JWT_SECRET" "ENCRYPTION_KEY")
for var in "${required_vars[@]}"; do
if grep -q "^${var}=" .env && ! grep -q "^${var}=$" .env && ! grep -q "^${var}=<" .env; then
pass "$var is set"