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.
This commit is contained in:
Tomas Dvorak
2026-05-10 10:48:41 +02:00
parent c6a99c7e21
commit 6c448b336a
71 changed files with 135367 additions and 4481 deletions
+6 -44
View File
@@ -1,54 +1,16 @@
# Server Configuration # Trackeep Configuration for Casa OS
FRONTEND_PORT=3000 # Only required variables - everything else is auto-configured
FRONTEND_HOST_PORT=3900
BACKEND_PORT=8080
BACKEND_HOST_PORT=9000
GIN_MODE=debug
# Demo Mode Configuration # Host port for the application (default: 8080)
# Set to true for demo mode (read-only with demo data) HOST_PORT=8080
# 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
# Database Configuration # Database Configuration
DB_TYPE=postgres DB_PASSWORD=your_secure_password_here
DB_HOST=localhost
DB_PORT=5432
DB_HOST_PORT=5433
DB_USER=trackeep DB_USER=trackeep
DB_PASSWORD=your_password_here
DB_NAME=trackeep DB_NAME=trackeep
DB_SSL_MODE=disable
# DragonflyDB Configuration # DragonflyDB Configuration
DRAGONFLY_ADDR=dragonfly:6379
DRAGONFLY_PORT=6379
DRAGONFLY_HOST_PORT=6380
DRAGONFLY_PASSWORD=your_dragonfly_password_here DRAGONFLY_PASSWORD=your_dragonfly_password_here
# JWT Configuration (also used for encryption) # JWT Secret (generate with: openssl rand -hex 32)
# Generate a secure 64-character hex string using: openssl rand -hex 32
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly 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
+5 -25
View File
@@ -128,43 +128,23 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata - name: Extract metadata
id: meta-backend id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=sha,prefix={{branch}}- type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Extract metadata - name: Build and push unified image
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
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: . context: .
file: ./frontend/Dockerfile
push: true push: true
tags: ${{ steps.meta-frontend.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
# deploy: # deploy:
# name: Deploy to Production # name: Deploy to Production
+14 -41
View File
@@ -37,9 +37,6 @@ jobs:
build-and-push: build-and-push:
needs: extract-version needs: extract-version
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
service: [backend, frontend]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -57,7 +54,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ matrix.service }} images: ${{ env.REGISTRY }}
tags: | tags: |
type=ref,event=tag type=ref,event=tag
type=semver,pattern={{version}} type=semver,pattern={{version}}
@@ -66,18 +63,12 @@ jobs:
version=${{ needs.extract-version.outputs.version }} version=${{ needs.extract-version.outputs.version }}
build-date=${{ github.event.head_commit.timestamp }} build-date=${{ github.event.head_commit.timestamp }}
commit=${{ github.sha }} commit=${{ github.sha }}
service=${{ matrix.service }}
prerelease=${{ needs.extract-version.outputs.is-prerelease }} 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 uses: docker/build-push-action@v5
with: with:
context: | context: .
backend=./backend
frontend=.
file: |
backend=./backend/Dockerfile
frontend=./frontend/Dockerfile
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -87,15 +78,15 @@ jobs:
- name: Generate SBOM - name: Generate SBOM
uses: anchore/sbom-action@v0 uses: anchore/sbom-action@v0
with: with:
image: ${{ env.REGISTRY }}/${{ matrix.service }}:${{ needs.extract-version.outputs.version }} image: ${{ env.REGISTRY }}:${{ needs.extract-version.outputs.version }}
format: spdx-json format: spdx-json
output-file: ./sbom-${{ matrix.service }}.spdx.json output-file: ./sbom.spdx.json
- name: Upload SBOM - name: Upload SBOM
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: sbom-${{ matrix.service }} name: sbom
path: ./sbom-${{ matrix.service }}.spdx.json path: ./sbom.spdx.json
create-github-release: create-github-release:
needs: [extract-version, build-and-push] needs: [extract-version, build-and-push]
@@ -107,26 +98,21 @@ jobs:
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag: v${{ needs.extract-version.outputs.version }}
name: Trackeep v${{ needs.extract-version.outputs.version }} name: Trackeep v${{ needs.extract-version.outputs.version }}
body: | body: |
## 🚀 Trackeep v${{ needs.extract-version.outputs.version }} ## 🚀 Trackeep v${{ needs.extract-version.outputs.version }}
### 🐳 Docker Images ### 🐳 Docker Image
- **Backend**: `ghcr.io/dvorinka/trackeep/backend:${{ needs.extract-version.outputs.version }}` - **Unified**: `ghcr.io/dvorinka/trackeep:${{ needs.extract-version.outputs.version }}`
- **Frontend**: `ghcr.io/dvorinka/trackeep/frontend:${{ needs.extract-version.outputs.version }}` - **Latest**: `ghcr.io/dvorinka/trackeep:latest`
- **Latest**: `ghcr.io/dvorinka/trackeep/backend:latest` and `ghcr.io/dvorinka/trackeep/frontend:latest`
### 📋 Changes ### 📋 Changes
${{ github.event.head_commit.message }} ${{ github.event.head_commit.message }}
### 🔧 Installation ### 🔧 Installation
```bash ```bash
# Set version # Deploy with docker compose
export APP_VERSION=${{ needs.extract-version.outputs.version }} docker compose up -d
# Deploy with production compose
docker compose -f docker-compose.prod.yml up -d
``` ```
### ⚡ Auto-Updates ### ⚡ Auto-Updates
@@ -138,12 +124,10 @@ jobs:
draft: false draft: false
prerelease: ${{ needs.extract-version.outputs.is-prerelease }} prerelease: ${{ needs.extract-version.outputs.is-prerelease }}
files: | files: sbom.spdx.json
sbom-backend.spdx.json
sbom-frontend.spdx.json
generate_release_notes: true generate_release_notes: true
update-docker-compose-prod: update-version-files:
needs: extract-version needs: extract-version
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -168,17 +152,6 @@ jobs:
echo "✅ Backend updated to $VERSION" echo "✅ Backend updated to $VERSION"
fi 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" echo "🎉 All version files updated to $VERSION"
- name: Commit updated version files - 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]
+55
View File
@@ -0,0 +1,55 @@
# 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
COPY frontend/package*.json ./
RUN npm ci --only=production
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
RUN apk --no-cache add ca-certificates tzdata nginx
# 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=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# Start script to run both backend and nginx
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
+78 -137
View File
@@ -33,6 +33,22 @@
## 🚀 Quick Start ## 🚀 Quick Start
### One-Command Deployment (GHCR Image - Recommended for Casa OS)
```bash
docker run -d \
--name trackeep \
-p 8080:8080 \
-e DB_PASSWORD=your_password \
-e DB_USER=trackeep \
-e DB_NAME=trackeep \
-e DRAGONFLY_PASSWORD=your_dragonfly_password \
-e JWT_SECRET=your_jwt_secret \
ghcr.io/dvorinka/trackeep:latest
```
**Note**: This requires external PostgreSQL and DragonflyDB services. For full deployment with included databases, use Docker Compose below.
### Production Deployment with Docker Compose ### Production Deployment with Docker Compose
```bash ```bash
@@ -40,96 +56,50 @@ git clone https://github.com/dvorinka/trackeep.git
cd trackeep cd trackeep
cp .env.example .env cp .env.example .env
# Edit .env file with your configuration # Edit .env file with your configuration
docker-compose up -d docker compose up -d
``` ```
The `docker-compose.prod.yml` file uses environment variables with sensible defaults: The setup uses a unified Docker image with frontend and backend in a single container.
**Complete docker-compose.yml**:
```yaml ```yaml
services: icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
trackeep-frontend:
image: 'ghcr.io/dvorinka/trackeep/frontend:latest'
ports:
- "${FRONTEND_PORT:-80}:${FRONTEND_PORT:-80}"
- "${HTTPS_PORT:-443}:443"
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: services:
image: 'ghcr.io/dvorinka/trackeep/backend:latest' trackeep:
image: ghcr.io/dvorinka/trackeep:latest
ports: ports:
- "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}" - "${HOST_PORT:-8080}:8080"
env_file:
- .env
environment: environment:
- BACKEND_PORT=${BACKEND_PORT:-8080} - BACKEND_PORT=8080
- FRONTEND_PORT=${FRONTEND_PORT:-80} - DB_HOST=postgres
- GIN_MODE=${GIN_MODE:-release} - DB_PORT=5432
- DB_TYPE=${DB_TYPE:-postgres} - DRAGONFLY_ADDR=dragonfly:6379
- DB_HOST=${DB_HOST:-postgres} - GIN_MODE=release
- 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}
volumes: volumes:
- './data:/data' - ./uploads:/app/uploads
- './uploads:/app/uploads' - ./data:/data
- './logs:/app/logs'
- '/var/run/docker.sock:/var/run/docker.sock'
restart: unless-stopped restart: unless-stopped
networks: depends_on:
- trackeep-network postgres:
healthcheck: condition: service_healthy
test: dragonfly:
- CMD condition: service_healthy
- wget
- '--no-verbose'
- '--tries=1'
- '--spider'
- "http://localhost:${BACKEND_PORT:-8080}/health"
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
postgres: postgres:
image: 'postgres:15-alpine' image: postgres:15-alpine
ports:
- "${DB_PORT:-5432}:5432"
environment: environment:
POSTGRES_DB: ${DB_NAME:-trackeep} POSTGRES_DB: ${DB_NAME:-trackeep}
POSTGRES_USER: ${DB_USER:-trackeep} POSTGRES_USER: ${DB_USER:-trackeep}
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "${DB_HOST_PORT:-5433}:5432"
volumes: volumes:
- 'postgres_data:/var/lib/postgres/data' - postgres_data:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
networks:
- trackeep-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"]
interval: 10s interval: 10s
@@ -139,17 +109,14 @@ services:
dragonfly: dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest image: ghcr.io/dragonflydb/dragonfly:latest
container_name: dragonfly
ports: ports:
- "${DRAGONFLY_PORT:-6379}:6379" - "${DRAGONFLY_HOST_PORT:-6380}:6379"
volumes: volumes:
- dragonfly_data:/data - dragonfly_data:/data
command: dragonfly --requirepass=${DRAGONFLY_PASSWORD} --proactor_threads=2 command: dragonfly --requirepass=${DRAGONFLY_PASSWORD} --proactor_threads=2
environment: environment:
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD} - DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
restart: unless-stopped restart: unless-stopped
networks:
- trackeep-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${DRAGONFLY_PASSWORD} ping"] test: ["CMD-SHELL", "redis-cli -a ${DRAGONFLY_PASSWORD} ping"]
interval: 10s interval: 10s
@@ -158,40 +125,31 @@ services:
start_period: 30s start_period: 30s
volumes: volumes:
postgres_data: null postgres_data:
dragonfly_data: null dragonfly_data:
networks:
trackeep-network:
driver: bridge
``` ```
### Service Architecture ### Service Architecture
Trackeep production deployment consists of **4 essential services**: Trackeep deployment consists of **3 services**:
#### **🎯 Frontend Service** #### **🎯 Trackeep Service (Unified)**
- **Image**: `ghcr.io/dvorinka/trackeep/frontend:latest` - **Image**: Built from unified Dockerfile (frontend + backend in one)
- **Ports**: `${FRONTEND_PORT:-80}:${FRONTEND_PORT:-80}`, `${HTTPS_PORT:-443}:443` - **Ports**: `${HOST_PORT:-8080}:8080`
- **Purpose**: Web interface and user experience - **Purpose**: Web interface, API server, and business logic combined
- **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 - **Health**: HTTP health check endpoint
- **Auto-configuration**: Frontend automatically connects to backend via nginx proxy
#### **🗄️ Database Service** #### **🗄️ Database Service**
- **Image**: `postgres:15-alpine` - **Image**: `postgres:15-alpine`
- **Ports**: `${DB_PORT:-5432}:5432` - **Ports**: `${DB_HOST_PORT:-5433}:5432`
- **Purpose**: Data persistence and storage - **Purpose**: Data persistence and storage
- **Health**: PostgreSQL readiness check - **Health**: PostgreSQL readiness check
- **Storage**: Persistent volume for data - **Storage**: Persistent volume for data
#### **🐉 DragonflyDB Service** #### **🐉 DragonflyDB Service**
- **Image**: `ghcr.io/dragonflydb/dragonfly:latest` - **Image**: `ghcr.io/dragonflydb/dragonfly:latest`
- **Ports**: `${DRAGONFLY_PORT:-6379}:6379` - **Ports**: `${DRAGONFLY_HOST_PORT:-6380}:6379`
- **Purpose**: In-memory caching and session storage - **Purpose**: In-memory caching and session storage
- **Health**: Redis-cli ping check - **Health**: Redis-cli ping check
- **Storage**: Persistent volume for cache data - **Storage**: Persistent volume for cache data
@@ -201,16 +159,23 @@ Trackeep production deployment consists of **4 essential services**:
Create a `.env` file from the provided `.env.example` and configure these required variables: Create a `.env` file from the provided `.env.example` and configure these required variables:
```env ```env
# Database Configuration # Host port for the application (default: 8080)
DB_PASSWORD=your_secure_password HOST_PORT=8080
# Security Configuration # Database Configuration
JWT_SECRET=your_jwt_secret_key DB_PASSWORD=your_secure_password_here
DB_USER=trackeep
DB_NAME=trackeep
# DragonflyDB Configuration # DragonflyDB Configuration
DRAGONFLY_PASSWORD=your_dragonfly_password DRAGONFLY_PASSWORD=your_dragonfly_password_here
# JWT Secret (generate with: openssl rand -hex 32)
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
``` ```
**Note**: The frontend automatically connects to the backend via nginx proxy - no VITE_API_URL or additional configuration needed.
### AI Services Configuration ### AI Services Configuration
AI services are now configured **only within the Trackeep application**. No environment variables are needed for AI configuration. Simply: AI services are now configured **only within the Trackeep application**. No environment variables are needed for AI configuration. Simply:
@@ -490,9 +455,9 @@ DISABLE_CHINESE_AI=true
``` ```
4. **Access the application** 4. **Access the application**
- Frontend: http://localhost:${FRONTEND_PORT:-80} - Application: http://localhost:${HOST_PORT:-8080}
- Backend API: http://localhost:${BACKEND_PORT:-8080} - Health Check: http://localhost:${HOST_PORT:-8080}/health
- Health Check: http://localhost:${BACKEND_PORT:-8080}/health - API: http://localhost:${HOST_PORT:-8080}/api/
### Demo Login ### Demo Login
- Email: `demo@trackeep.com` - Email: `demo@trackeep.com`
@@ -516,8 +481,8 @@ trackeep/
├── scripts/ # Utility scripts ├── scripts/ # Utility scripts
├── data/ # Data storage directory ├── data/ # Data storage directory
├── uploads/ # File upload directory ├── uploads/ # File upload directory
├── docker-compose.yml # Multi-service orchestration ├── docker-compose.yml # Unified service orchestration
├── docker-compose.prod.yml # Production configuration ├── Dockerfile # Unified frontend + backend build
├── start.sh # Startup script ├── start.sh # Startup script
└── README.md └── README.md
``` ```
@@ -562,47 +527,23 @@ Additional documentation files:
Key environment variables to configure: Key environment variables to configure:
```bash ```bash
# Server Configuration # Host port for the application
FRONTEND_PORT=80 HOST_PORT=8080
BACKEND_PORT=8080
VITE_API_URL=http://localhost:8080
GIN_MODE=release
# Database Configuration # Database Configuration
DB_TYPE=postgres DB_PASSWORD=your_secure_password_here
DB_HOST=postgres
DB_PORT=5432
DB_USER=trackeep DB_USER=trackeep
DB_PASSWORD=your_password_here
DB_NAME=trackeep DB_NAME=trackeep
DB_SSL_MODE=disable
# DragonflyDB Configuration # DragonflyDB Configuration
DRAGONFLY_ADDR=dragonfly:6379 DRAGONFLY_PASSWORD=your_dragonfly_password_here
DRAGONFLY_PORT=6379
DRAGONFLY_PASSWORD=your_dragonfly_password
# JWT Configuration # JWT Configuration (generate with: openssl rand -hex 32)
# Generate a secure 64-character hex string using: openssl rand -hex 32
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly 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 ## 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. 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{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnvWithDefault("PORT", getEnvWithDefault("BACKEND_PORT", "8080")), Port: getEnvWithDefault("PORT", getEnvWithDefault("BACKEND_PORT", "8080")),
ReadTimeout: getDurationEnv("READ_TIMEOUT", 15*time.Second), ReadTimeout: GetDurationEnv("READ_TIMEOUT", 15*time.Second),
WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 15*time.Second), WriteTimeout: GetDurationEnv("WRITE_TIMEOUT", 15*time.Second),
IdleTimeout: getDurationEnv("IDLE_TIMEOUT", 60*time.Second), IdleTimeout: GetDurationEnv("IDLE_TIMEOUT", 60*time.Second),
ShutdownTimeout: getDurationEnv("SHUTDOWN_TIMEOUT", 30*time.Second), ShutdownTimeout: GetDurationEnv("SHUTDOWN_TIMEOUT", 30*time.Second),
}, },
Database: DatabaseConfig{ Database: DatabaseConfig{
Host: getEnvWithDefault("DB_HOST", "localhost"), Host: getEnvWithDefault("DB_HOST", "localhost"),
@@ -99,7 +99,7 @@ func getEnvWithDefault(key, defaultValue string) string {
return defaultValue return defaultValue
} }
func getDurationEnv(key string, defaultValue time.Duration) time.Duration { func GetDurationEnv(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
return defaultValue return defaultValue
+1 -21
View File
@@ -11,7 +11,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"strconv"
"strings" "strings"
"time" "time"
@@ -68,25 +67,6 @@ type Claims struct {
jwt.RegisteredClaims 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 // GenerateJWT creates a new JWT token for a user
func GenerateJWT(user models.User) (string, error) { func GenerateJWT(user models.User) (string, error) {
return generateJWT(user) return generateJWT(user)
@@ -103,7 +83,7 @@ func generateJWT(user models.User) (string, error) {
Username: user.Username, Username: user.Username,
GitHubID: user.GitHubID, GitHubID: user.GitHubID,
RegisteredClaims: jwt.RegisteredClaims{ 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()), IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "trackeep", 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
+13 -47
View File
@@ -1,14 +1,16 @@
icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
environment: environment:
POSTGRES_DB: ${DB_NAME:-trackeep} POSTGRES_DB: ${DB_NAME:-trackeep}
POSTGRES_USER: ${DB_USER:-trackeep} POSTGRES_USER: ${DB_USER:-trackeep}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} POSTGRES_PASSWORD: ${DB_PASSWORD}
ports: ports:
- "${DB_HOST_PORT:-5433}:5432" - "${DB_HOST_PORT:-5433}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgres/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"]
@@ -19,7 +21,6 @@ services:
dragonfly: dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest image: ghcr.io/dragonflydb/dragonfly:latest
container_name: dragonfly
ports: ports:
- "${DRAGONFLY_HOST_PORT:-6380}:6379" - "${DRAGONFLY_HOST_PORT:-6380}:6379"
volumes: volumes:
@@ -35,64 +36,29 @@ services:
retries: 5 retries: 5
start_period: 30s start_period: 30s
trackeep-backend: trackeep:
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "${BACKEND_HOST_PORT:-9000}:${BACKEND_PORT:-8080}" - "${HOST_PORT:-8080}:8080"
env_file: env_file:
- .env - .env
environment: environment:
- APP_VERSION=${APP_VERSION:-1.0.0} - BACKEND_PORT=8080
- BACKEND_PORT=${BACKEND_PORT:-8080} - DB_HOST=postgres
- FRONTEND_PORT=${FRONTEND_PORT:-3000} - DB_PORT=5432
- DRAGONFLY_ADDR=${DRAGONFLY_ADDR:-dragonfly:6379} - DRAGONFLY_ADDR=dragonfly:6379
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD} - GIN_MODE=release
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
volumes: volumes:
- ./data:/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for updates - ./data:/data
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
dragonfly: dragonfly:
condition: service_healthy 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"]
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
volumes: volumes:
postgres_data: postgres_data:
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# Unified entrypoint for Trackeep
# Starts both backend and nginx in one container
set -e
# Backend configuration
export BACKEND_PORT=${BACKEND_PORT:-8080}
export DB_HOST=${DB_HOST:-postgres}
export DB_PORT=${DB_PORT:-5432}
export DB_NAME=${DB_NAME:-trackeep}
export DB_USER=${DB_USER:-trackeep}
export DB_PASSWORD=${DB_PASSWORD}
export DRAGONFLY_ADDR=${DRAGONFLY_ADDR:-dragonfly:6379}
export DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
export JWT_SECRET=${JWT_SECRET}
export GIN_MODE=${GIN_MODE:-release}
# Start backend in background
cd /app
echo "Starting Trackeep backend on port ${BACKEND_PORT}..."
./main &
# 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:${BACKEND_PORT}/health 2>/dev/null; then
echo "Backend is ready!"
break
fi
echo "Waiting... ($i/30)"
sleep 2
done
# Update nginx config to proxy to localhost backend
sed -i "s|http://trackeep-backend:8080/|http://localhost:${BACKEND_PORT}/|g" /etc/nginx/nginx.conf
# Start nginx
echo "Starting nginx..."
nginx -g "daemon off;"
+2 -2
View File
@@ -55,9 +55,9 @@ http {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# API proxy to backend with retry logic # API proxy to backend (internal localhost)
location /api/ { location /api/ {
proxy_pass http://trackeep-backend:8080/; proxy_pass http://localhost:8080/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
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 { Layout } from '@/components/layout/Layout'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { ToastContainer } from '@/components/ui/Toast' import { ToastContainer } from '@/components/ui/Toast'
import { Dashboard } from '@/pages/Dashboard' import { Dashboard } from '@/pages/misc/Dashboard'
import { Bookmarks } from '@/pages/Bookmarks' import { Bookmarks } from '@/pages/content/Bookmarks'
import { Tasks } from '@/pages/Tasks' import { Tasks } from '@/pages/productivity/Tasks'
import { Files } from '@/pages/Files' import { Files } from '@/pages/content/Files'
import { Notes } from '@/pages/Notes' import { Notes } from '@/pages/content/Notes'
import Chat from '@/pages/Chat' import Chat from '@/pages/communication/Chat'
import { Settings } from '@/pages/Settings' import { Settings } from '@/pages/settings/Settings'
import { Login } from '@/pages/Login' import { Login } from '@/pages/auth/Login'
import { Youtube } from '@/pages/Youtube' import { Youtube } from '@/pages/content/Youtube'
import { Members } from '@/pages/Members' import { Members } from '@/pages/admin/Members'
import { RemovedStuff } from '@/pages/RemovedStuff' import { RemovedStuff } from '@/pages/misc/RemovedStuff'
import { AdminSettings } from '@/pages/AdminSettings' import { AdminSettings } from '@/pages/admin/AdminSettings'
import { ColorSwitcher } from '@/pages/ColorSwitcher' import { ColorSwitcher } from '@/pages/settings/ColorSwitcher'
import { AdminDashboard } from '@/pages/AdminDashboard' import { AdminDashboard } from '@/pages/admin/AdminDashboard'
import { Stats } from '@/pages/Stats' import { Stats } from '@/pages/productivity/Stats'
import { Profile } from '@/pages/Profile' import { Profile } from '@/pages/auth/Profile'
import { LearningPaths } from '@/pages/LearningPaths' import { LearningPaths } from '@/pages/content/LearningPaths'
import { GitHub } from '@/pages/GitHub' import { GitHub } from '@/pages/content/GitHub'
import { TimeTracking } from '@/pages/TimeTracking' import { TimeTracking } from '@/pages/productivity/TimeTracking'
import { Calendar } from '@/pages/Calendar' import { Calendar } from '@/pages/productivity/Calendar'
import { AuthCallback } from '@/pages/AuthCallback' import { AuthCallback } from '@/pages/auth/AuthCallback'
import { AuthProvider, useAuth } from '@/lib/auth' import { AuthProvider, useAuth } from '@/lib/auth'
import { Search } from '@/pages/Search' import { Search } from '@/pages/content/Search'
import { Analytics } from '@/pages/Analytics' import { Analytics } from '@/pages/admin/Analytics'
import { Messages } from '@/pages/Messages' import { Messages } from '@/pages/communication/Messages'
import { ShareTarget } from '@/pages/ShareTarget' import { ShareTarget } from '@/pages/misc/ShareTarget'
import BrowserExtensionSettings from '@/pages/BrowserExtensionSettings' import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings'
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode' import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
import { onMount, createEffect } from 'solid-js' import { onMount, createEffect } from 'solid-js'
import { useNavigate } from '@solidjs/router' import { useNavigate } from '@solidjs/router'
@@ -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>
</>
)
}
@@ -43,8 +43,6 @@ export const BrowserSearch = () => {
try { try {
const isDemoMode = isDemo(); const isDemoMode = isDemo();
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}`);
// Always use backend API for search to avoid CORS issues // Always use backend API for search to avoid CORS issues
const API_BASE_URL = getApiBaseUrl(); const API_BASE_URL = getApiBaseUrl();
const token = localStorage.getItem('token') || const token = localStorage.getItem('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>
)
}
-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);
},
}));
},
};
-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(', ')}`
}
@@ -514,7 +514,10 @@ export const Bookmarks = () => {
onError={(e) => { onError={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
img.style.display = 'none'; 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-medium';
span.textContent = getBookmarkInitial(bookmark.title);
img.parentElement!.appendChild(span);
}} }}
/> />
) : ( ) : (
@@ -335,7 +335,12 @@ export const Files = () => {
throw new Error(errorMessage); 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) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start GitHub App installation'; const message = error instanceof Error ? error.message : 'Failed to start GitHub App installation';
setGitHubError(message); setGitHubError(message);
@@ -464,7 +464,12 @@ export const GitHub = () => {
throw new Error(message); 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) { } catch (error) {
console.error('Failed to start GitHub App installation:', error); console.error('Failed to start GitHub App installation:', error);
setBackupError(error instanceof Error ? error.message : 'Failed to start GitHub App installation'); setBackupError(error instanceof Error ? error.message : 'Failed to start GitHub App installation');
@@ -112,12 +112,10 @@ export const Notes = () => {
// Check if we should use demo mode or real API // Check if we should use demo mode or real API
if (isDemoMode() && !shouldUseRealBackend()) { if (isDemoMode() && !shouldUseRealBackend()) {
console.log('[Notes] Loading demo notes data');
// Load mock notes data for demo mode // Load mock notes data for demo mode
const mockNotesData = getMockNotes(); const mockNotesData = getMockNotes();
notesData = mockNotesData; notesData = mockNotesData;
} else { } else {
console.log('[Notes] Loading notes from real API');
// Load from real API // Load from real API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token'); const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes`, { const response = await fetch(`${API_BASE_URL}/notes`, {
@@ -41,7 +41,11 @@ export const RemovedStuff = () => {
// Load auto-remove settings from localStorage // Load auto-remove settings from localStorage
const savedSettings = localStorage.getItem('autoRemoveSettings'); const savedSettings = localStorage.getItem('autoRemoveSettings');
if (savedSettings) { if (savedSettings) {
try {
setAutoRemoveSettings(JSON.parse(savedSettings)); setAutoRemoveSettings(JSON.parse(savedSettings));
} catch {
console.warn('Failed to parse autoRemoveSettings');
}
} }
// Try to load from API first, then fallback to localStorage // Try to load from API first, then fallback to localStorage
-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