Compare commits
10 commits
2436d8fe4e
...
d054732bf5
| Author | SHA1 | Date | |
|---|---|---|---|
| d054732bf5 | |||
| 761b1156c1 | |||
| 70a7776da5 | |||
| 98084de7d0 | |||
| c62234e1ca | |||
|
|
f01a0a967f | ||
|
|
9fe2bcade3 | ||
|
|
d598751598 | ||
|
|
f1d1d3f96d | ||
|
|
8a9719ab9f |
733 changed files with 174472 additions and 2273 deletions
207
.devcontainer/README.md
Normal file
207
.devcontainer/README.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# DevContainer Setup für Thats-Me
|
||||
|
||||
## Was ist ein DevContainer?
|
||||
|
||||
Ein DevContainer ist eine vollständige Entwicklungsumgebung in Docker. Cursor/VSCode läuft direkt im Container und Sie entwickeln mit allen Tools, die bereits vorinstalliert sind.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
1. **Docker Desktop** muss laufen
|
||||
2. **Traefik Proxy** als externes Netzwerk:
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
|
||||
## DevContainer starten
|
||||
|
||||
1. **In Cursor/VSCode:**
|
||||
|
||||
- Drücken Sie `Cmd+Shift+P` (macOS) oder `Ctrl+Shift+P` (Windows/Linux)
|
||||
- Wählen Sie: **"Dev Containers: Reopen in Container"**
|
||||
- Warten Sie, bis alle Container gebaut und gestartet sind
|
||||
|
||||
2. **Beim ersten Start:**
|
||||
- Der Laravel Container wird gebaut (kann einige Minuten dauern)
|
||||
- Alle Services werden gestartet (MySQL, Redis, Mailpit, Quasar)
|
||||
- Sie werden automatisch im Laravel Container eingeloggt
|
||||
|
||||
## Was passiert beim Start?
|
||||
|
||||
Der DevContainer startet folgende Services:
|
||||
|
||||
- **laravel.test** - Laravel Backend (Sie arbeiten in diesem Container)
|
||||
- **mysql** - Datenbank
|
||||
- **redis** - Cache/Queue
|
||||
- **mailpit** - E-Mail Testing
|
||||
- **quasar.app** - Frontend App (läuft automatisch)
|
||||
|
||||
## Arbeiten im Container
|
||||
|
||||
### Terminal
|
||||
|
||||
Das Terminal in Cursor/VSCode ist bereits im Container. Sie können direkt arbeiten:
|
||||
|
||||
```bash
|
||||
# Sie sind im /var/www/html Verzeichnis (= ./backend vom Host)
|
||||
php artisan migrate
|
||||
php artisan serve
|
||||
composer install
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Verfügbare Services
|
||||
|
||||
Im DevContainer können Sie direkt auf die anderen Services zugreifen:
|
||||
|
||||
```bash
|
||||
# MySQL Verbindung
|
||||
mysql -h mysql -u sail -p
|
||||
# Passwort: password
|
||||
|
||||
# Redis
|
||||
redis-cli -h redis
|
||||
|
||||
# Composer
|
||||
composer install
|
||||
composer update
|
||||
|
||||
# Artisan
|
||||
php artisan migrate
|
||||
php artisan tinker
|
||||
```
|
||||
|
||||
## Zugriff von außerhalb
|
||||
|
||||
### Von Ihrem Browser (Host)
|
||||
|
||||
Die Ports werden automatisch weitergeleitet:
|
||||
|
||||
- **Laravel App:** http://localhost (Port 80)
|
||||
- **Vite Dev Server:** http://localhost:5173
|
||||
- **Mailpit Dashboard:** http://localhost:8025
|
||||
- **Quasar App:** http://localhost:9000
|
||||
|
||||
### Mit Traefik
|
||||
|
||||
Wenn Traefik läuft, können Sie auch die Domains verwenden:
|
||||
|
||||
- https://thats-me.test
|
||||
- https://portal.thats-me.test
|
||||
- https://api.thats-me.test
|
||||
- https://app.thats-me.test
|
||||
- https://assets.thats-me.test
|
||||
|
||||
## Backend .env Konfiguration
|
||||
|
||||
Stellen Sie sicher, dass `backend/.env` folgende Werte hat:
|
||||
|
||||
```env
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=thats-me
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
## Vite Dev Server starten
|
||||
|
||||
```bash
|
||||
# Im DevContainer Terminal
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite läuft dann auf Port 5173 und ist verfügbar unter:
|
||||
|
||||
- http://localhost:5173 (vom Host)
|
||||
- https://assets.thats-me.test (mit Traefik)
|
||||
|
||||
## Quasar App
|
||||
|
||||
Die Quasar App läuft automatisch in einem separaten Container und ist verfügbar unter:
|
||||
|
||||
- http://localhost:9000 (vom Host)
|
||||
- https://app.thats-me.test (mit Traefik)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container startet nicht
|
||||
|
||||
```bash
|
||||
# Schließen Sie den DevContainer
|
||||
# Öffnen Sie ein normales Terminal auf dem Host
|
||||
docker-compose down -v
|
||||
docker-compose build --no-cache
|
||||
# Dann neu starten: "Dev Containers: Reopen in Container"
|
||||
```
|
||||
|
||||
### "Dockerfile not found" Fehler
|
||||
|
||||
Stellen Sie sicher, dass Laravel Sail installiert ist:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
composer require laravel/sail --dev
|
||||
```
|
||||
|
||||
### Permission-Probleme
|
||||
|
||||
Die User-IDs in `devcontainer.json` anpassen:
|
||||
|
||||
```json
|
||||
"containerEnv": {
|
||||
"WWWUSER": "1000", // Ihre User-ID
|
||||
"WWWGROUP": "1000" // Ihre Group-ID
|
||||
}
|
||||
```
|
||||
|
||||
Finden Sie Ihre IDs mit:
|
||||
|
||||
```bash
|
||||
id -u # User ID
|
||||
id -g # Group ID
|
||||
```
|
||||
|
||||
### Logs ansehen
|
||||
|
||||
```bash
|
||||
# Im DevContainer Terminal
|
||||
docker-compose logs -f
|
||||
|
||||
# Nur MySQL
|
||||
docker-compose logs -f mysql
|
||||
|
||||
# Nur Quasar
|
||||
docker-compose logs -f quasar.app
|
||||
```
|
||||
|
||||
## DevContainer verlassen
|
||||
|
||||
1. Drücken Sie `Cmd+Shift+P` / `Ctrl+Shift+P`
|
||||
2. Wählen Sie: **"Dev Containers: Reopen Folder Locally"**
|
||||
|
||||
## Container stoppen
|
||||
|
||||
Nach dem Verlassen des DevContainers:
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Vorteile des DevContainers
|
||||
|
||||
✅ Alle arbeiten mit der gleichen Umgebung
|
||||
✅ Keine lokale PHP/MySQL Installation nötig
|
||||
✅ Automatische Service-Verwaltung
|
||||
✅ Isolierte Entwicklungsumgebung
|
||||
✅ Einfaches Onboarding für neue Entwickler
|
||||
|
||||
48
.devcontainer/devcontainer.json
Normal file
48
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "Thats-Me (Dev Container)",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.yml"
|
||||
],
|
||||
"service": "laravel.test",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "sail",
|
||||
"features": {},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"bmewburn.vscode-intelephense-client",
|
||||
"onecentlin.laravel-blade",
|
||||
"shufo.vscode-blade-formatter",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"Anthropic.claude-code",
|
||||
"adrianwilczynski.alpine-js-intellisense",
|
||||
"onecentlin.laravel-extension-pack",
|
||||
"cierra.livewire-vscode",
|
||||
"Vue.volar"
|
||||
]
|
||||
}
|
||||
},
|
||||
"runServices": [
|
||||
"laravel.test",
|
||||
"quasar.app"
|
||||
],
|
||||
"containerEnv": {
|
||||
"WWWUSER": "501",
|
||||
"WWWGROUP": "20",
|
||||
"LARAVEL_SAIL": "1"
|
||||
},
|
||||
"forwardPorts": [
|
||||
5173,
|
||||
9000
|
||||
],
|
||||
"portsAttributes": {
|
||||
"5173": {
|
||||
"label": "Vite Dev Server (Backend)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"9000": {
|
||||
"label": "Quasar App (Frontend)",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
}
|
||||
76
.gitignore
vendored
76
.gitignore
vendored
|
|
@ -1,24 +1,58 @@
|
|||
# Laravel
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/storage/*.key
|
||||
/storage/app
|
||||
/storage/framework
|
||||
/storage/language
|
||||
/storage/logs
|
||||
/storage/pail
|
||||
/vendor
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
auth.json
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs & Editors
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
Icon
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/.history
|
||||
# Project specific
|
||||
_static/
|
||||
_work/
|
||||
_storage/
|
||||
|
|
|
|||
27
.mcp.json
Normal file
27
.mcp.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "sh",
|
||||
"args": [
|
||||
"-c",
|
||||
"cd /workspace/backend && php artisan boost:mcp"
|
||||
]
|
||||
},
|
||||
"context7": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@upstash/context7-mcp",
|
||||
"--api-key",
|
||||
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9"
|
||||
]
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-sequential-thinking"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
90
CLAUDE.md
Normal file
90
CLAUDE.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
"That's Me" is a full-stack offline-first web application with a Quasar/Vue.js 3 frontend and a Laravel 12 backend, orchestrated via Docker Compose with Traefik reverse proxy.
|
||||
|
||||
## Architecture
|
||||
|
||||
The project is a monorepo with two independent applications:
|
||||
|
||||
- **`frontend/`** — Quasar v2 SPA (Vue.js 3, Pinia, Vite). Serves the user-facing app at `app.thats-me.test`. Designed as offline-first with IndexedDB (via Dexie.js) for local storage and sync queue.
|
||||
- **`backend/`** — Laravel 12 (PHP 8.4). Serves multiple roles via separate domains:
|
||||
- `thats-me.test` — Public landing page (Blade + Tailwind + Flux UI)
|
||||
- `portal.thats-me.test` — Admin panel (Livewire/Volt + Tailwind + Flux UI)
|
||||
- `api.thats-me.test` — REST API for the Quasar frontend (OAuth2 via Laravel Passport)
|
||||
- `assets.thats-me.test` — Vite dev server (HMR)
|
||||
|
||||
Data flow: User interaction → Vue component → Pinia store → IndexedDB → Sync queue → Laravel REST API → MySQL / Synology C2 Object Storage (for multimedia).
|
||||
|
||||
## Development Environment (Docker)
|
||||
|
||||
Requires Docker Desktop and a Traefik proxy network (`docker network create proxy`). Add domains to `/etc/hosts` pointing to `127.0.0.1`.
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose up -d
|
||||
|
||||
# Access containers
|
||||
docker-compose exec laravel.test bash # Laravel (PHP)
|
||||
docker-compose exec quasar.app sh # Quasar (Node)
|
||||
docker-compose exec mysql mysql -u sail -p # MySQL (password: password)
|
||||
```
|
||||
|
||||
### Services & Ports
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|----------------|-------|----------------------------|
|
||||
| quasar.app | 9000 | Quasar frontend dev server |
|
||||
| mysql | 33070 | MySQL database |
|
||||
| mailpit | 8028 | Email testing dashboard |
|
||||
| redis | 6383 | Cache & queue |
|
||||
| laravel.test | 5180 | Vite dev server (HMR) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Backend (run inside `laravel.test` container or prefix with `docker-compose exec laravel.test`)
|
||||
|
||||
```bash
|
||||
composer dev # Full dev environment (server + queue + pail + npm dev)
|
||||
php artisan serve # Laravel server only
|
||||
php artisan test --compact # Run all tests (Pest v3)
|
||||
php artisan test --compact --filter=testName # Run specific test
|
||||
vendor/bin/pint --dirty --format agent # Format code (Laravel Pint)
|
||||
php artisan migrate # Run database migrations
|
||||
php artisan db:seed # Seed database
|
||||
npm run build # Build backend assets with Vite
|
||||
```
|
||||
|
||||
### Frontend (run inside `quasar.app` container or from `frontend/` directory)
|
||||
|
||||
```bash
|
||||
npm run dev # Quasar dev server with HMR
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint check
|
||||
npm run format # Prettier formatting
|
||||
```
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
The `backend/CLAUDE.md` file contains comprehensive Laravel Boost guidelines that must be followed. Key points:
|
||||
|
||||
- **PHP 8.4** with constructor property promotion, explicit return types, curly braces for all control structures
|
||||
- **Testing**: Every change must be tested with Pest v3. Run `php artisan test --compact` with filter for targeted runs. Create tests with `php artisan make:test --pest {name}`.
|
||||
- **Code formatting**: Always run `vendor/bin/pint --dirty --format agent` before finalizing changes.
|
||||
- **Artisan generators**: Use `php artisan make:*` commands with `--no-interaction` to create files (controllers, models, migrations, etc.)
|
||||
- **Database**: Prefer Eloquent over raw queries. Use `Model::query()` instead of `DB::`. Use eager loading to prevent N+1.
|
||||
- **Config**: Never use `env()` outside config files; use `config()` instead.
|
||||
- **Admin Panel UI**: Livewire + Volt (single-file components) styled with Tailwind CSS v4 + Flux UI (`<flux:*>` components)
|
||||
- **Laravel Boost MCP**: Use `search-docs`, `tinker`, `database-query`, `database-schema`, and `list-artisan-commands` tools when available.
|
||||
|
||||
## Frontend Conventions
|
||||
|
||||
- **State management**: Pinia stores in `frontend/src/stores/`
|
||||
- **Routing**: Vue Router with hash mode, config in `frontend/src/router/`
|
||||
- **UI framework**: Quasar components (preferred over custom HTML). Tailwind CSS only for specific customizations.
|
||||
- **Animation**: Anime.js for the "LifeWave" SVG visualization
|
||||
- **Build target**: ES2022, Firefox 115+, Chrome 115+, Safari 14+
|
||||
- **Dev server host**: `app.thats-me.test` on port 9000
|
||||
119
DEV-NOTES.md
Normal file
119
DEV-NOTES.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Dev Notes – Stand 25.02.2026
|
||||
|
||||
## node_modules-Konflikt: Mac ↔ Docker ↔ Cursor (gelöst)
|
||||
|
||||
### Ausgangslage
|
||||
|
||||
Die Quasar App läuft in drei Kontexten parallel:
|
||||
|
||||
| Kontext | Pfad | Plattform |
|
||||
|---|---|---|
|
||||
| **`quasar.app` Docker-Service** | Docker Volume (isoliert) | Linux ARM64 |
|
||||
| **Cursor IDE Terminal** | `/workspace/frontend/node_modules` | Linux ARM64 |
|
||||
| **Mac Terminal** | `~/Sites/thats-me.local/frontend/node_modules` | macOS ARM64 |
|
||||
|
||||
Der `frontend/`-Ordner ist überall **derselbe** (gemountetes Host-Filesystem). Rollup und andere native Pakete benötigen plattformspezifische Binaries – ein `npm install` auf einer Seite überschrieb bisher die Binaries der anderen.
|
||||
|
||||
---
|
||||
|
||||
### Fix 1 – Docker Volume für `quasar.app` (docker-compose.yml)
|
||||
|
||||
Der `quasar.app`-Service nutzt ein eigenes benanntes Docker-Volume für `node_modules`. Dadurch bleibt sein Linux-`node_modules` vollständig vom Host isoliert:
|
||||
|
||||
```yaml
|
||||
# quasar.app Service:
|
||||
volumes:
|
||||
- './frontend:/app'
|
||||
- 'quasar-node-modules:/app/node_modules' # überschattet Host-Ordner
|
||||
|
||||
# volumes-Block:
|
||||
volumes:
|
||||
quasar-node-modules:
|
||||
driver: local
|
||||
```
|
||||
|
||||
Der `quasar.app`-Container führt beim Start automatisch `npm install && npm run dev` aus (in sein isoliertes Volume). Er ist damit **völlig unabhängig** vom Host-`node_modules`.
|
||||
|
||||
---
|
||||
|
||||
### Fix 2 – Rollup-Binaries als `optionalDependencies` (package.json)
|
||||
|
||||
Das Cursor-Terminal und der Mac teilen denselben `node_modules`-Ordner. Um den gegenseitigen Konflikt abzumildern, wurden alle relevanten Rollup-Plattform-Binaries explizit als `optionalDependencies` eingetragen:
|
||||
|
||||
```json
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-darwin-arm64": "^4.0.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.0.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
npm installiert davon nur die zur aktuellen Plattform passenden, schlägt aber nie fehl wenn eine fehlt.
|
||||
|
||||
---
|
||||
|
||||
### Fix 3 – `npm run dev` repariert sich selbst (package.json)
|
||||
|
||||
Das `dev`-Script führt vor dem Start automatisch `npm install` aus:
|
||||
|
||||
```json
|
||||
"dev": "npm install && quasar dev"
|
||||
```
|
||||
|
||||
Damit werden nach einem Mac-`npm install` die Linux-Binaries im Cursor-Terminal beim nächsten `npm run dev` automatisch nachgezogen.
|
||||
|
||||
---
|
||||
|
||||
### Wichtig: Cursor Terminal ≠ `quasar.app`-Container
|
||||
|
||||
Das Cursor IDE Terminal hat **keinen** Zugriff auf das isolierte Docker-Volume des `quasar.app`-Services. Für den Web-Dev-Server gilt:
|
||||
|
||||
- **Empfohlen:** `docker-compose up -d quasar.app` → App läuft unter `app.thats-me.test` (isoliertes Volume, kein Konflikt)
|
||||
- **Alternativ:** `npm run dev` direkt im Cursor-Terminal (teilt node_modules mit dem Mac, aber Fix 3 gleicht das aus)
|
||||
|
||||
---
|
||||
|
||||
### Workflow: iOS-Build auf dem Mac
|
||||
|
||||
Nach einem `npm install` im Linux-Kontext (Cursor oder Docker) müssen auf dem Mac die node_modules neu installiert werden:
|
||||
|
||||
```bash
|
||||
cd ~/Sites/thats-me.local/frontend
|
||||
rm -rf node_modules && npm install
|
||||
npx quasar build -m capacitor -T ios --ide
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Allgemeiner Projekt-Stand
|
||||
|
||||
- **Backend:** Laravel 12, API-Routes unter `api.thats-me.test`, OAuth2 via Passport
|
||||
- **Frontend:** Quasar v2 SPA, offline-first mit Dexie.js (IndexedDB)
|
||||
- **Mobile:** Capacitor für iOS/Android (in `frontend/src-capacitor/`)
|
||||
- **Neue Komponenten:**
|
||||
- `AppSettingsModal.vue`, `ModalCard.vue`, `UserMenu.vue`, `ZoomControl.vue`
|
||||
- `frontend/src/composables/`, `frontend/src/db/`, `frontend/src/services/`
|
||||
- Backend API-Controller + Requests + Resources in `backend/app/Http/`
|
||||
- Event-Model + Migrations + Factory
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Domains (lokale Entwicklung)
|
||||
|
||||
| Domain | Zweck |
|
||||
|---|---|
|
||||
| `app.thats-me.test` | Quasar Frontend (Quasar Dev-Server) |
|
||||
| `api.thats-me.test` | Laravel REST API |
|
||||
| `portal.thats-me.test` | Admin Panel |
|
||||
| `thats-me.test` | Landingpage |
|
||||
| `assets.thats-me.test` | Vite HMR (Laravel Backend Assets) |
|
||||
|
||||
## Services & Ports
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---|---|---|
|
||||
| `quasar.app` | 9000 | Quasar Frontend Dev-Server |
|
||||
| `mysql` | 33070 | MySQL Datenbank |
|
||||
| `mailpit` | 8028 | E-Mail Testing Dashboard |
|
||||
| `redis` | 6383 | Cache & Queue |
|
||||
| `laravel.test` | 5180 | Vite Dev-Server (HMR) |
|
||||
296
DOCKER-SETUP.md
Normal file
296
DOCKER-SETUP.md
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
# Docker Setup für Thats-Me Projekt
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieses Projekt verwendet Docker mit Laravel Sail für das Backend und einen Node-Container für die Quasar Frontend App.
|
||||
|
||||
### Services
|
||||
|
||||
- **laravel.test** - Laravel Backend (PHP 8.4)
|
||||
- **quasar.app** - Quasar Frontend App (Node 20)
|
||||
- **mysql** - MySQL 8.0 Database
|
||||
- **mailpit** - E-Mail Testing Tool
|
||||
- **redis** - Cache & Queue Service
|
||||
|
||||
### Domains
|
||||
|
||||
Das Setup konfiguriert 4 Domains über Traefik:
|
||||
|
||||
1. **thats-me.test** - Laravel Webseite/Landingpage
|
||||
2. **portal.thats-me.test** - Laravel Admin Panel
|
||||
3. **api.thats-me.test** - Laravel API für Quasar App
|
||||
4. **app.thats-me.test** - Quasar Frontend App
|
||||
|
||||
Zusätzlich:
|
||||
|
||||
- **assets.thats-me.test** - Vite Dev Server für Laravel Assets
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
1. **Docker Desktop** installiert
|
||||
2. **Traefik Proxy** muss als externes Netzwerk verfügbar sein:
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
3. **Laravel Sail** muss im Backend installiert sein:
|
||||
```bash
|
||||
cd backend
|
||||
composer require laravel/sail --dev
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Environment Dateien einrichten
|
||||
|
||||
**Root .env Datei:**
|
||||
|
||||
```bash
|
||||
cp .env.docker .env
|
||||
```
|
||||
|
||||
**Backend .env Datei:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
Bearbeite `backend/.env` und stelle sicher, dass diese Einstellungen gesetzt sind:
|
||||
|
||||
```env
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=thats-me
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
### 2. Hosts-Datei konfigurieren
|
||||
|
||||
Füge folgende Einträge zu deiner `/etc/hosts` Datei hinzu:
|
||||
|
||||
```
|
||||
127.0.0.1 thats-me.test
|
||||
127.0.0.1 portal.thats-me.test
|
||||
127.0.0.1 api.thats-me.test
|
||||
127.0.0.1 app.thats-me.test
|
||||
127.0.0.1 assets.thats-me.test
|
||||
```
|
||||
|
||||
### 3. Docker Container starten
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 4. Laravel Installation abschließen
|
||||
|
||||
Beim ersten Start:
|
||||
|
||||
```bash
|
||||
# In den Laravel Container einsteigen
|
||||
docker-compose exec laravel.test bash
|
||||
|
||||
# Composer Dependencies installieren
|
||||
composer install
|
||||
|
||||
# Datenbank migrieren
|
||||
php artisan migrate
|
||||
|
||||
# Optional: Seeder ausführen
|
||||
php artisan db:seed
|
||||
```
|
||||
|
||||
### 5. Frontend Dependencies installieren
|
||||
|
||||
Der Quasar Container installiert automatisch die Dependencies beim Start.
|
||||
Falls manuell nötig:
|
||||
|
||||
```bash
|
||||
docker-compose exec quasar.app npm install
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Container starten
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Container stoppen
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Logs ansehen
|
||||
|
||||
```bash
|
||||
# Alle Services
|
||||
docker-compose logs -f
|
||||
|
||||
# Nur Laravel
|
||||
docker-compose logs -f laravel.test
|
||||
|
||||
# Nur Quasar
|
||||
docker-compose logs -f quasar.app
|
||||
```
|
||||
|
||||
### In Container einsteigen
|
||||
|
||||
**Laravel:**
|
||||
|
||||
```bash
|
||||
docker-compose exec laravel.test bash
|
||||
```
|
||||
|
||||
**Quasar:**
|
||||
|
||||
```bash
|
||||
docker-compose exec quasar.app sh
|
||||
```
|
||||
|
||||
**MySQL:**
|
||||
|
||||
```bash
|
||||
docker-compose exec mysql mysql -u sail -p
|
||||
# Passwort: password
|
||||
```
|
||||
|
||||
### Artisan Commands
|
||||
|
||||
```bash
|
||||
docker-compose exec laravel.test php artisan migrate
|
||||
docker-compose exec laravel.test php artisan cache:clear
|
||||
docker-compose exec laravel.test php artisan queue:work
|
||||
```
|
||||
|
||||
### NPM Commands (Frontend)
|
||||
|
||||
```bash
|
||||
docker-compose exec quasar.app npm run dev
|
||||
docker-compose exec quasar.app npm run build
|
||||
```
|
||||
|
||||
## Zugriff auf die Anwendung
|
||||
|
||||
### Mit Traefik (empfohlen)
|
||||
|
||||
- **Hauptwebseite:** https://thats-me.test
|
||||
- **Admin Portal:** https://portal.thats-me.test
|
||||
- **API:** https://api.thats-me.test
|
||||
- **Frontend App:** https://app.thats-me.test
|
||||
- **Vite Assets:** https://assets.thats-me.test
|
||||
|
||||
### Direkt über Ports
|
||||
|
||||
- **Laravel App:** http://localhost (Port 80 über Traefik)
|
||||
- **Vite Dev Server:** http://localhost:5179 (Host) → 5173 (Container)
|
||||
- **Quasar App:** http://localhost:9000
|
||||
- **Mailpit Dashboard:** http://localhost:8028
|
||||
- **MySQL:** localhost:33070
|
||||
- **Redis:** localhost:6383
|
||||
|
||||
## Vite Development Server
|
||||
|
||||
Um den Vite Dev Server für Laravel zu starten:
|
||||
|
||||
```bash
|
||||
docker-compose exec laravel.test npm install
|
||||
docker-compose exec laravel.test npm run dev
|
||||
```
|
||||
|
||||
Dann ist HMR (Hot Module Replacement) unter https://assets.thats-me.test verfügbar.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Proxy-Netzwerk existiert nicht
|
||||
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
|
||||
### Port-Konflikte
|
||||
|
||||
Ändere die Ports in der `.env` Datei:
|
||||
|
||||
```env
|
||||
FORWARD_DB_PORT=33071
|
||||
FORWARD_MAILPIT_DASHBOARD_PORT=8029
|
||||
QUASAR_PORT=9001
|
||||
VITE_PORT=5180
|
||||
```
|
||||
|
||||
### Permission-Probleme
|
||||
|
||||
```bash
|
||||
# User/Group IDs in .env anpassen
|
||||
WWWUSER=1000
|
||||
WWWGROUP=1000
|
||||
```
|
||||
|
||||
### Container neu bauen
|
||||
|
||||
```bash
|
||||
docker-compose down -v
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Quasar startet nicht
|
||||
|
||||
```bash
|
||||
# Manuell im Container starten
|
||||
docker-compose exec quasar.app sh
|
||||
cd /app
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Entwicklung ohne Traefik
|
||||
|
||||
Falls Sie kein Traefik haben, können Sie die Container auch direkt über Ports erreichen:
|
||||
|
||||
1. Entfernen Sie die `labels` Sektion aus `docker-compose.yml`
|
||||
2. Aktivieren Sie den Port-Mapping für Laravel:
|
||||
```yaml
|
||||
ports:
|
||||
- "${APP_PORT:-80}:80"
|
||||
- "${VITE_PORT:-5173}:5173"
|
||||
```
|
||||
3. Zugriff dann über:
|
||||
- Laravel: http://localhost
|
||||
- Vite: http://localhost:5173
|
||||
- Quasar: http://localhost:9000
|
||||
|
||||
## Nützliche Befehle
|
||||
|
||||
```bash
|
||||
# Container Status
|
||||
docker-compose ps
|
||||
|
||||
# Container neu starten
|
||||
docker-compose restart
|
||||
|
||||
# Bestimmten Service neu starten
|
||||
docker-compose restart laravel.test
|
||||
|
||||
# Container und Volumes löschen
|
||||
docker-compose down -v
|
||||
|
||||
# Logs von heute
|
||||
docker-compose logs --since 24h
|
||||
|
||||
# Ressourcen-Nutzung
|
||||
docker stats
|
||||
```
|
||||
|
|
@ -2,6 +2,13 @@
|
|||
|
||||
Eine kurze Beschreibung, was dieses Projekt macht (ein oder zwei Sätze).
|
||||
|
||||
## Domains auf dem Testserver
|
||||
|
||||
app.thats-me.test = frontend Quasar APP
|
||||
portal.thats-me.test = backend Laravel Admin Panel mit Tailwind CSS + FluxUI
|
||||
thats-me.test = backend Laravel Webseite / Landinpage mit Tailwind CSS + FluxUI
|
||||
api.thats-me.test = backend Laravel API für Quasar APP
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
- [Start des Projekts](#start)
|
||||
|
|
@ -93,4 +100,4 @@ Erkläre, wie andere zum Projekt beitragen können. Gibt es Richtlinien für Pul
|
|||
|
||||
Gib an, unter welcher Lizenz das Projekt veröffentlicht wird. Zum Beispiel:
|
||||
|
||||
Dieses Projekt ist unter der MIT-Lizenz lizenziert - siehe die [LICENSE.md](LICENSE.md)-Datei für Details (falls vorhanden).
|
||||
Dieses Projekt ist unter der MIT-Lizenz lizenziert - siehe die [LICENSE.md](LICENSE.md)-Datei für Details (falls vorhanden).
|
||||
|
|
|
|||
11
backend/.mcp.json
Normal file
11
backend/.mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
269
backend/AGENTS.md
Normal file
269
backend/AGENTS.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.13
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- livewire/volt (VOLT) - v1
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v3
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `fluxui-development` — Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components.
|
||||
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
|
||||
- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
# Laravel 12
|
||||
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== fluxui-free/core rules ===
|
||||
|
||||
# Flux UI Free
|
||||
|
||||
- Flux UI is the official Livewire component library. This project uses the free edition, which includes all free components and variants but not Pro components.
|
||||
- Use `<flux:*>` components when available; they are the recommended way to build Livewire interfaces.
|
||||
- IMPORTANT: Activate `fluxui-development` when working with Flux UI components.
|
||||
|
||||
=== volt/core rules ===
|
||||
|
||||
# Livewire Volt
|
||||
|
||||
- Single-file Livewire components: PHP logic and Blade templates in one file.
|
||||
- Always check existing Volt components to determine functional vs class-based style.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
|
||||
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
269
backend/CLAUDE.md
Normal file
269
backend/CLAUDE.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.13
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- livewire/volt (VOLT) - v1
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v3
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `fluxui-development` — Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components.
|
||||
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
|
||||
- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
# Laravel 12
|
||||
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== fluxui-free/core rules ===
|
||||
|
||||
# Flux UI Free
|
||||
|
||||
- Flux UI is the official Livewire component library. This project uses the free edition, which includes all free components and variants but not Pro components.
|
||||
- Use `<flux:*>` components when available; they are the recommended way to build Livewire interfaces.
|
||||
- IMPORTANT: Activate `fluxui-development` when working with Flux UI components.
|
||||
|
||||
=== volt/core rules ===
|
||||
|
||||
# Livewire Volt
|
||||
|
||||
- Single-file Livewire components: PHP logic and Blade templates in one file.
|
||||
- Always check existing Volt components to determine functional vs class-based style.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
|
||||
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
199
backend/app/Http/Controllers/Api/EventController.php
Normal file
199
backend/app/Http/Controllers/Api/EventController.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEventRequest;
|
||||
use App\Http\Requests\UpdateEventRequest;
|
||||
use App\Http\Resources\EventResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/events
|
||||
* Cursor-based pagination by date. Supports ?since=ISO for delta sync.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$query = $request->user()->events()->orderBy('date');
|
||||
|
||||
// Delta sync: only events updated since a given timestamp
|
||||
if ($request->has('since')) {
|
||||
$since = $request->date('since');
|
||||
$query->where('updated_at', '>', $since);
|
||||
}
|
||||
|
||||
// Cursor-based pagination (default 50, max 200)
|
||||
$limit = min((int) $request->input('limit', 50), 200);
|
||||
|
||||
return EventResource::collection(
|
||||
$query->cursorPaginate($limit)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/events
|
||||
*/
|
||||
public function store(StoreEventRequest $request): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()->create([
|
||||
'client_id' => $request->validated('id'),
|
||||
'title' => $request->validated('title'),
|
||||
'date' => $request->validated('date'),
|
||||
'emotion' => $request->validated('emotion'),
|
||||
'custom_color' => $request->validated('customColor'),
|
||||
'gradient_preset' => $request->validated('gradientPreset'),
|
||||
'image' => $request->validated('image'),
|
||||
'note' => $request->validated('note'),
|
||||
]);
|
||||
|
||||
return (new EventResource($event))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/{clientId}
|
||||
*/
|
||||
public function show(Request $request, string $clientId): EventResource
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
return new EventResource($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/events/{clientId}
|
||||
*/
|
||||
public function update(UpdateEventRequest $request, string $clientId): EventResource
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$data = [];
|
||||
$validated = $request->validated();
|
||||
|
||||
if (isset($validated['title'])) {
|
||||
$data['title'] = $validated['title'];
|
||||
}
|
||||
if (isset($validated['date'])) {
|
||||
$data['date'] = $validated['date'];
|
||||
}
|
||||
if (isset($validated['emotion'])) {
|
||||
$data['emotion'] = $validated['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $validated)) {
|
||||
$data['custom_color'] = $validated['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $validated)) {
|
||||
$data['gradient_preset'] = $validated['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $validated)) {
|
||||
$data['image'] = $validated['image'];
|
||||
}
|
||||
if (array_key_exists('note', $validated)) {
|
||||
$data['note'] = $validated['note'];
|
||||
}
|
||||
|
||||
$event->update($data);
|
||||
|
||||
return new EventResource($event->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/events/{clientId}
|
||||
*/
|
||||
public function destroy(Request $request, string $clientId): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$event->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/events/sync
|
||||
* Batch sync: process multiple mutations in one request.
|
||||
*/
|
||||
public function sync(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'mutations' => ['required', 'array', 'max:100'],
|
||||
'mutations.*.action' => ['required', 'in:create,update,delete'],
|
||||
'mutations.*.eventId' => ['required', 'uuid'],
|
||||
'mutations.*.payload' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$results = [];
|
||||
|
||||
foreach ($request->input('mutations') as $mutation) {
|
||||
$action = $mutation['action'];
|
||||
$clientId = $mutation['eventId'];
|
||||
$payload = $mutation['payload'] ?? [];
|
||||
|
||||
try {
|
||||
if ($action === 'create') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if (! $event) {
|
||||
$event = $user->events()->create([
|
||||
'client_id' => $clientId,
|
||||
'title' => $payload['title'] ?? 'Untitled',
|
||||
'date' => $payload['date'] ?? now()->format('Y-m-d'),
|
||||
'emotion' => $payload['emotion'] ?? 0,
|
||||
'custom_color' => $payload['customColor'] ?? null,
|
||||
'gradient_preset' => $payload['gradientPreset'] ?? null,
|
||||
'image' => $payload['image'] ?? null,
|
||||
'note' => $payload['note'] ?? null,
|
||||
]);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'update') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if ($event) {
|
||||
$data = [];
|
||||
if (isset($payload['title'])) {
|
||||
$data['title'] = $payload['title'];
|
||||
}
|
||||
if (isset($payload['date'])) {
|
||||
$data['date'] = $payload['date'];
|
||||
}
|
||||
if (isset($payload['emotion'])) {
|
||||
$data['emotion'] = $payload['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $payload)) {
|
||||
$data['custom_color'] = $payload['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $payload)) {
|
||||
$data['gradient_preset'] = $payload['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $payload)) {
|
||||
$data['image'] = $payload['image'];
|
||||
}
|
||||
if (array_key_exists('note', $payload)) {
|
||||
$data['note'] = $payload['note'];
|
||||
}
|
||||
$event->update($data);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'delete') {
|
||||
$user->events()->where('client_id', $clientId)->delete();
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'error', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
}
|
||||
27
backend/app/Http/Requests/StoreEventRequest.php
Normal file
27
backend/app/Http/Requests/StoreEventRequest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['required', 'uuid', 'unique:events,client_id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'date' => ['required', 'date_format:Y-m-d'],
|
||||
'emotion' => ['required', 'numeric', 'min:-1', 'max:1'],
|
||||
'customColor' => ['nullable', 'string', 'max:20'],
|
||||
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'note' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal file
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'date' => ['sometimes', 'required', 'date_format:Y-m-d'],
|
||||
'emotion' => ['sometimes', 'required', 'numeric', 'min:-1', 'max:1'],
|
||||
'customColor' => ['nullable', 'string', 'max:20'],
|
||||
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'note' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/app/Http/Resources/EventResource.php
Normal file
26
backend/app/Http/Resources/EventResource.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->client_id,
|
||||
'title' => $this->title,
|
||||
'date' => $this->date->format('Y-m-d'),
|
||||
'emotion' => (float) $this->emotion,
|
||||
'customColor' => $this->custom_color,
|
||||
'gradientPreset' => $this->gradient_preset,
|
||||
'image' => $this->image,
|
||||
'note' => $this->note ?? '',
|
||||
'syncStatus' => 'synced',
|
||||
'createdAt' => $this->created_at->getTimestampMs(),
|
||||
'updatedAt' => $this->updated_at->getTimestampMs(),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
backend/app/Models/Event.php
Normal file
38
backend/app/Models/Event.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\EventFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'title',
|
||||
'date',
|
||||
'emotion',
|
||||
'custom_color',
|
||||
'gradient_preset',
|
||||
'image',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date:Y-m-d',
|
||||
'emotion' => 'decimal:3',
|
||||
'gradient_preset' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,16 @@ namespace App\Models;
|
|||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -50,6 +52,11 @@ class User extends Authenticatable
|
|||
/**
|
||||
* Get the user's initials
|
||||
*/
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function initials(): string
|
||||
{
|
||||
return Str::of($this->name)
|
||||
|
|
|
|||
17
backend/boost.json
Normal file
17
backend/boost.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"agents": [
|
||||
"claude_code",
|
||||
"cursor"
|
||||
],
|
||||
"guidelines": true,
|
||||
"herd_mcp": false,
|
||||
"mcp": true,
|
||||
"nightwatch_mcp": false,
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"fluxui-development",
|
||||
"volt-development",
|
||||
"pest-testing",
|
||||
"tailwindcss-development"
|
||||
]
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,15 +11,17 @@
|
|||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/passport": "^13.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/volt": "^1.7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^2.1",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.18",
|
||||
"laravel/sail": "^1.41",
|
||||
"laravel/sail": "^1.46",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^3.7",
|
||||
|
|
@ -74,4 +76,4 @@
|
|||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3446
backend/composer.lock
generated
3446
backend/composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,10 @@ return [
|
|||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
'api' => [
|
||||
'driver' => 'passport',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
48
backend/config/passport.php
Normal file
48
backend/config/passport.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Passport will use when
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
'middleware' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Keys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Passport uses encryption keys while generating secure access tokens for
|
||||
| your application. By default, the keys are stored as local files but
|
||||
| can be set via environment variables when that is more convenient.
|
||||
|
|
||||
*/
|
||||
|
||||
'private_key' => env('PASSPORT_PRIVATE_KEY'),
|
||||
|
||||
'public_key' => env('PASSPORT_PUBLIC_KEY'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Passport's models will utilize your application's default
|
||||
| database connection. If you wish to use a different connection you
|
||||
| may specify the configured name of the database connection here.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('PASSPORT_CONNECTION'),
|
||||
|
||||
];
|
||||
28
backend/database/factories/EventFactory.php
Normal file
28
backend/database/factories/EventFactory.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
|
||||
*/
|
||||
class EventFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'client_id' => Str::uuid()->toString(),
|
||||
'user_id' => User::factory(),
|
||||
'title' => fake()->sentence(3),
|
||||
'date' => fake()->date(),
|
||||
'emotion' => fake()->randomFloat(3, -1, 1),
|
||||
'custom_color' => null,
|
||||
'gradient_preset' => fake()->optional(0.3)->numberBetween(0, 9),
|
||||
'image' => null,
|
||||
'note' => fake()->optional(0.5)->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_auth_codes', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->index();
|
||||
$table->foreignUuid('client_id');
|
||||
$table->text('scopes')->nullable();
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_auth_codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_access_tokens', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->foreignUuid('client_id');
|
||||
$table->string('name')->nullable();
|
||||
$table->text('scopes')->nullable();
|
||||
$table->boolean('revoked');
|
||||
$table->timestamps();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_access_tokens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->char('access_token_id', 80)->index();
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_refresh_tokens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_clients', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->nullableMorphs('owner');
|
||||
$table->string('name');
|
||||
$table->string('secret')->nullable();
|
||||
$table->string('provider')->nullable();
|
||||
$table->text('redirect_uris');
|
||||
$table->text('grant_types');
|
||||
$table->boolean('revoked');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_clients');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_device_codes', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->foreignUuid('client_id')->index();
|
||||
$table->char('user_code', 8)->unique();
|
||||
$table->text('scopes');
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('user_approved_at')->nullable();
|
||||
$table->dateTime('last_polled_at')->nullable();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_device_codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('client_id')->unique();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('title');
|
||||
$table->date('date');
|
||||
$table->decimal('emotion', 4, 3)->default(0);
|
||||
$table->string('custom_color')->nullable();
|
||||
$table->unsignedTinyInteger('gradient_preset')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->text('note')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'date']);
|
||||
$table->index(['user_id', 'updated_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events');
|
||||
}
|
||||
};
|
||||
320
backend/package-lock.json
generated
320
backend/package-lock.json
generated
|
|
@ -1,9 +1,10 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"name": "html",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "html",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
|
@ -420,9 +421,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz",
|
||||
"integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
|
||||
"integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -433,9 +434,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz",
|
||||
"integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -446,9 +447,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz",
|
||||
"integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -459,9 +460,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz",
|
||||
"integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
|
||||
"integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -472,9 +473,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz",
|
||||
"integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -485,9 +486,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz",
|
||||
"integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
|
||||
"integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -498,9 +499,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz",
|
||||
"integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
|
||||
"integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -511,9 +512,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz",
|
||||
"integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
|
||||
"integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -524,9 +525,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz",
|
||||
"integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -537,9 +538,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz",
|
||||
"integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
|
||||
"integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -549,10 +550,10 @@
|
|||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz",
|
||||
"integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==",
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -562,10 +563,10 @@
|
|||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz",
|
||||
"integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==",
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -576,9 +577,22 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz",
|
||||
"integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
|
||||
"integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -589,9 +603,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz",
|
||||
"integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -615,9 +629,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz",
|
||||
"integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
|
||||
"integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -627,10 +641,23 @@
|
|||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
|
||||
"integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz",
|
||||
"integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
|
||||
"integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -641,9 +668,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz",
|
||||
"integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
|
||||
"integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -653,10 +680,23 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz",
|
||||
"integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
|
||||
"integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -891,9 +931,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
|
|
@ -964,13 +1004,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -993,6 +1033,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001688",
|
||||
"electron-to-chromium": "^1.5.73",
|
||||
|
|
@ -1311,14 +1352,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1844,6 +1886,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -1875,12 +1918,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz",
|
||||
"integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
|
||||
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.6"
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
|
|
@ -1890,32 +1933,35 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.34.8",
|
||||
"@rollup/rollup-android-arm64": "4.34.8",
|
||||
"@rollup/rollup-darwin-arm64": "4.34.8",
|
||||
"@rollup/rollup-darwin-x64": "4.34.8",
|
||||
"@rollup/rollup-freebsd-arm64": "4.34.8",
|
||||
"@rollup/rollup-freebsd-x64": "4.34.8",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.34.8",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.34.8",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.34.8",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.34.8",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.34.8",
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.34.8",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.34.8",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.34.8",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.34.8",
|
||||
"@rollup/rollup-linux-x64-musl": "4.34.8",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.34.8",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.34.8",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.34.8",
|
||||
"@rollup/rollup-android-arm-eabi": "4.52.4",
|
||||
"@rollup/rollup-android-arm64": "4.52.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.52.4",
|
||||
"@rollup/rollup-darwin-x64": "4.52.4",
|
||||
"@rollup/rollup-freebsd-arm64": "4.52.4",
|
||||
"@rollup/rollup-freebsd-x64": "4.52.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.52.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.52.4",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.52.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.52.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.52.4",
|
||||
"@rollup/rollup-openharmony-arm64": "4.52.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.52.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.52.4",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.52.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.52.4",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.34.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz",
|
||||
"integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==",
|
||||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
|
||||
"integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2011,6 +2057,52 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
|
|
@ -2057,14 +2149,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
|
||||
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.0.tgz",
|
||||
"integrity": "sha512-oLnWs9Hak/LOlKjeSpOwD6JMks8BeICEdYMJBf6P4Lac/pO9tKiv/XhXnAM7nNfSkZahjlCZu9sS50zL8fSnsw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.30.1"
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
|
@ -2137,6 +2233,36 @@
|
|||
"picomatch": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@
|
|||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
|
|
|
|||
14
backend/routes/api.php
Normal file
14
backend/routes/api.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\EventController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('auth:api')->group(function () {
|
||||
Route::get('/user', fn (Request $request) => $request->user());
|
||||
|
||||
Route::apiResource('events', EventController::class)->parameters([
|
||||
'events' => 'clientId',
|
||||
]);
|
||||
Route::post('/events/sync', [EventController::class, 'sync']);
|
||||
});
|
||||
186
backend/tests/Feature/Api/EventTest.php
Normal file
186
backend/tests/Feature/Api/EventTest.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
Passport::actingAs($this->user);
|
||||
});
|
||||
|
||||
test('can list events', function () {
|
||||
Event::factory()->count(3)->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->getJson('/api/events');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(3, 'data');
|
||||
});
|
||||
|
||||
test('list only returns own events', function () {
|
||||
Event::factory()->count(2)->create(['user_id' => $this->user->id]);
|
||||
Event::factory()->count(3)->create(); // other user
|
||||
|
||||
$response = $this->getJson('/api/events');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(2, 'data');
|
||||
});
|
||||
|
||||
test('can filter events by since parameter', function () {
|
||||
Event::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now()->subDays(5),
|
||||
]);
|
||||
Event::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'updated_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/events?since=' . now()->subDay()->toISOString());
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
test('can create an event', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$response = $this->postJson('/api/events', [
|
||||
'id' => $clientId,
|
||||
'title' => 'Mein Event',
|
||||
'date' => '2024-06-15',
|
||||
'emotion' => 0.75,
|
||||
'customColor' => null,
|
||||
'gradientPreset' => 2,
|
||||
'image' => null,
|
||||
'note' => 'Eine Notiz',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.id', $clientId)
|
||||
->assertJsonPath('data.title', 'Mein Event')
|
||||
->assertJsonPath('data.syncStatus', 'synced');
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'client_id' => $clientId,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('create validates required fields', function () {
|
||||
$response = $this->postJson('/api/events', []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['id', 'title', 'date', 'emotion']);
|
||||
});
|
||||
|
||||
test('can show a single event', function () {
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->getJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.id', $event->client_id)
|
||||
->assertJsonPath('data.title', $event->title);
|
||||
});
|
||||
|
||||
test('cannot show another users event', function () {
|
||||
$event = Event::factory()->create();
|
||||
|
||||
$response = $this->getJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('can update an event', function () {
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->putJson("/api/events/{$event->client_id}", [
|
||||
'title' => 'Updated Title',
|
||||
'emotion' => -0.5,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.title', 'Updated Title');
|
||||
});
|
||||
|
||||
test('can delete an event', function () {
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->deleteJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing('events', ['id' => $event->id]);
|
||||
});
|
||||
|
||||
test('cannot delete another users event', function () {
|
||||
$event = Event::factory()->create();
|
||||
|
||||
$response = $this->deleteJson("/api/events/{$event->client_id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('batch sync creates updates and deletes', function () {
|
||||
$existingEvent = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
$newId = Str::uuid()->toString();
|
||||
$deleteEvent = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->postJson('/api/events/sync', [
|
||||
'mutations' => [
|
||||
[
|
||||
'action' => 'create',
|
||||
'eventId' => $newId,
|
||||
'payload' => [
|
||||
'title' => 'New via sync',
|
||||
'date' => '2025-01-01',
|
||||
'emotion' => 0.3,
|
||||
],
|
||||
],
|
||||
[
|
||||
'action' => 'update',
|
||||
'eventId' => $existingEvent->client_id,
|
||||
'payload' => [
|
||||
'title' => 'Updated via sync',
|
||||
],
|
||||
],
|
||||
[
|
||||
'action' => 'delete',
|
||||
'eventId' => $deleteEvent->client_id,
|
||||
'payload' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(3, 'results');
|
||||
|
||||
$this->assertDatabaseHas('events', ['client_id' => $newId, 'title' => 'New via sync']);
|
||||
$this->assertDatabaseHas('events', ['client_id' => $existingEvent->client_id, 'title' => 'Updated via sync']);
|
||||
$this->assertDatabaseMissing('events', ['client_id' => $deleteEvent->client_id]);
|
||||
});
|
||||
|
||||
test('sync is idempotent for creates', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'create',
|
||||
'eventId' => $clientId,
|
||||
'payload' => ['title' => 'First', 'date' => '2025-01-01', 'emotion' => 0],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
$this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'create',
|
||||
'eventId' => $clientId,
|
||||
'payload' => ['title' => 'Duplicate', 'date' => '2025-01-01', 'emotion' => 0],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
expect(Event::where('client_id', $clientId)->count())->toBe(1);
|
||||
});
|
||||
|
|
@ -1,15 +1,24 @@
|
|||
// npm run dev # dev server
|
||||
// npm run build # build for production
|
||||
// npm run preview # preview production build
|
||||
|
||||
/**
|
||||
* Vite-Konfiguration für Backend (Thats-Me)
|
||||
* - Domain: thats-me.test
|
||||
* - Port: 5173
|
||||
* - Verwendet FluxUI
|
||||
* - Build-Verzeichnis: public/build/thats-me
|
||||
*
|
||||
* Starten mit: npm run dev
|
||||
*/
|
||||
import { defineConfig } from "vite";
|
||||
import laravel from "laravel-vite-plugin";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import fs from "fs";
|
||||
|
||||
// Pfade zu deinen MAMP-Zertifikaten
|
||||
// const certPath = "/Applications/MAMP/Library/OpenSSL/certs/thats-me.test.crt";
|
||||
// const keyPath = "/Applications/MAMP/Library/OpenSSL/certs/thats-me.test.key";
|
||||
const httpsConfig =
|
||||
process.env.NODE_ENV === "production"
|
||||
? {
|
||||
// In Produktion: echte Zertifikate verwenden
|
||||
key: process.env.SSL_KEY_PATH,
|
||||
cert: process.env.SSL_CERT_PATH,
|
||||
}
|
||||
: true; // Self-signed für Entwicklung
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
|
|
@ -20,17 +29,34 @@ export default defineConfig({
|
|||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
// https: {
|
||||
// key: fs.readFileSync(keyPath),
|
||||
// cert: fs.readFileSync(certPath),
|
||||
// },
|
||||
cors: true, // Ergänze diese Zeile
|
||||
host: "192.168.1.8",
|
||||
port: 5173,
|
||||
hmr: {
|
||||
host: "192.168.1.8",
|
||||
protocol: "https",
|
||||
https: false, // Traefik übernimmt SSL
|
||||
cors: {
|
||||
origin: ["https://thats-me.test", "https://assets.thats-me.test"],
|
||||
credentials: true,
|
||||
},
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
allowedHosts: [
|
||||
"assets.thats-me.test",
|
||||
"thats-me.test",
|
||||
"localhost",
|
||||
"0.0.0.0",
|
||||
],
|
||||
hmr: {
|
||||
host: "assets.thats-me.test",
|
||||
protocol: "wss",
|
||||
},
|
||||
origin: "https://assets.thats-me.test", // Ohne Port!
|
||||
},
|
||||
build: {
|
||||
outDir: "public/build/thats-me",
|
||||
assetsDir: "",
|
||||
manifest: "manifest.json",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
origin: "https://192.168.1.8:5173",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
538
deploy.sh
Executable file
538
deploy.sh
Executable file
|
|
@ -0,0 +1,538 @@
|
|||
#!/bin/bash
|
||||
# filepath: /Users/tbehrend/Repositories/thatsme/deploy.sh
|
||||
|
||||
echo "🚀 Creating deployment packages for 'That's Me' deployment structure..."
|
||||
|
||||
# Clean up previous deployment
|
||||
rm -rf deployment
|
||||
mkdir -p deployment/{frontend,backend}
|
||||
|
||||
echo "📦 Building frontend..."
|
||||
cd frontend
|
||||
npm install
|
||||
quasar build
|
||||
cd ..
|
||||
|
||||
echo "🔧 Preparing backend..."
|
||||
cd backend
|
||||
composer install --optimize-autoloader --no-dev --no-interaction
|
||||
|
||||
if [ -f "package.json" ]; then
|
||||
npm install
|
||||
NODE_ENV=production npm run build
|
||||
fi
|
||||
cd ..
|
||||
|
||||
echo "📂 Creating FRONTEND deployment structure..."
|
||||
|
||||
# Frontend deployment - complete Quasar SPA
|
||||
cp -r frontend/dist/spa/* deployment/frontend/
|
||||
cp -r frontend/public/images deployment/frontend/
|
||||
cp -r frontend/public/videos deployment/frontend/
|
||||
if [ -d "frontend/public/audio" ]; then
|
||||
cp -r frontend/public/audio deployment/frontend/
|
||||
fi
|
||||
|
||||
# Create frontend config for deployment
|
||||
cat > deployment/frontend/config.js << 'EOF'
|
||||
// Frontend configuration for deployment
|
||||
window.APP_CONFIG = {
|
||||
API_BASE_URL: '/api',
|
||||
APP_URL: window.location.origin,
|
||||
environment: 'production',
|
||||
database: false
|
||||
};
|
||||
EOF
|
||||
|
||||
# Update frontend index.html to include config
|
||||
sed -i.bak 's|</head>| <script src="/config.js"></script>\n </head>|' deployment/frontend/index.html
|
||||
|
||||
# Create frontend .htaccess for SPA routing
|
||||
cat > deployment/frontend/.htaccess << 'EOF'
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle client-side routing for Vue SPA
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
|
||||
# Cache static assets
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive on
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpg "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/gif "access plus 1 year"
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType video/mp4 "access plus 1 month"
|
||||
</IfModule>
|
||||
|
||||
# Security headers
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options nosniff
|
||||
Header always set X-Frame-Options SAMEORIGIN
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
</IfModule>
|
||||
EOF
|
||||
|
||||
echo "📂 Creating BACKEND deployment structure..."
|
||||
|
||||
# Backend deployment - Laravel application
|
||||
mkdir -p deployment/backend/{app,bootstrap,config,database,resources,routes,storage,vendor,public}
|
||||
|
||||
# Copy Laravel core files
|
||||
cp -r backend/app deployment/backend/
|
||||
cp -r backend/bootstrap deployment/backend/
|
||||
cp -r backend/config deployment/backend/
|
||||
cp -r backend/database deployment/backend/
|
||||
cp -r backend/resources deployment/backend/
|
||||
cp -r backend/routes deployment/backend/
|
||||
cp -r backend/storage deployment/backend/
|
||||
cp -r backend/vendor deployment/backend/
|
||||
|
||||
# Copy backend root files
|
||||
cp backend/artisan deployment/backend/
|
||||
cp backend/composer.json deployment/backend/
|
||||
cp backend/composer.lock deployment/backend/
|
||||
if [ -f "backend/vite.config.js" ]; then
|
||||
cp backend/vite.config.js deployment/backend/
|
||||
fi
|
||||
|
||||
# Copy built assets to public
|
||||
if [ -d "backend/public/build" ]; then
|
||||
cp -r backend/public/build deployment/backend/public/
|
||||
fi
|
||||
|
||||
# Copy other public assets
|
||||
if [ -d "backend/public" ]; then
|
||||
rsync -av --exclude='build' backend/public/ deployment/backend/public/
|
||||
fi
|
||||
|
||||
# Create backend index.php for deployment structure
|
||||
cat > deployment/backend/public/index.php << 'EOF'
|
||||
<?php
|
||||
/**
|
||||
* Laravel Backend for That's Me Deployment
|
||||
*/
|
||||
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$kernel = $app->make(Kernel::class);
|
||||
|
||||
$response = $kernel->handle(
|
||||
$request = Request::capture()
|
||||
);
|
||||
|
||||
$response->send();
|
||||
|
||||
$kernel->terminate($request, $response);
|
||||
EOF
|
||||
|
||||
# Create backend .htaccess
|
||||
cat > deployment/backend/public/.htaccess << 'EOF'
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
|
||||
# Security headers
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options nosniff
|
||||
Header always set X-Frame-Options DENY
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
|
||||
# CORS for deployment
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
|
||||
</IfModule>
|
||||
EOF
|
||||
|
||||
# Create API routes for backend (database-free)
|
||||
cat > deployment/backend/routes/web.php << 'EOF'
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// API routes for Quasar frontend (no database needed)
|
||||
Route::prefix('api')->group(function () {
|
||||
Route::middleware(['web'])->group(function () {
|
||||
|
||||
Route::get('/health', function () {
|
||||
return response()->json([
|
||||
'status' => 'ok',
|
||||
'app' => 'That\'s Me Backend API',
|
||||
'version' => '1.0.0',
|
||||
'mode' => 'deployment-no-database',
|
||||
'timestamp' => now()->toISOString()
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock LifeWave data for anime.js visualizations
|
||||
Route::get('/life-events', function () {
|
||||
return response()->json([
|
||||
'events' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'title' => 'Schulanfang',
|
||||
'value' => 0.8,
|
||||
'x' => 0.1,
|
||||
'imageUrl' => '/images/thumbs/aaron-huber-RLs8LZcONCA-unsplash.jpg',
|
||||
'date' => '2010-09-01',
|
||||
'description' => 'Mein erster Schultag war aufregend und voller neuer Erfahrungen.',
|
||||
'animationDelay' => 100
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'title' => 'Erster Job',
|
||||
'value' => 0.9,
|
||||
'x' => 0.3,
|
||||
'imageUrl' => '/images/thumbs/andrew-bui-z7rzbFHXym0-unsplash.jpg',
|
||||
'date' => '2020-03-15',
|
||||
'description' => 'Der Beginn meiner beruflichen Laufbahn in einem tollen Team.',
|
||||
'animationDelay' => 200
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'title' => 'Umzug nach München',
|
||||
'value' => 0.6,
|
||||
'x' => 0.5,
|
||||
'imageUrl' => '/images/thumbs/becca-tapert--A_Sx8GrRWg-unsplash.jpg',
|
||||
'date' => '2022-06-20',
|
||||
'description' => 'Ein großer Schritt in eine neue Stadt und neue Möglichkeiten.',
|
||||
'animationDelay' => 300
|
||||
],
|
||||
[
|
||||
'id' => 4,
|
||||
'title' => 'Hochzeit',
|
||||
'value' => 1.0,
|
||||
'x' => 0.7,
|
||||
'imageUrl' => '/images/thumbs/fuu-j-r2nJPbEYuSQ-unsplash.jpg',
|
||||
'date' => '2023-08-12',
|
||||
'description' => 'Der schönste Tag meines Lebens mit der Person, die ich liebe.',
|
||||
'isFavorite' => true,
|
||||
'animationDelay' => 400
|
||||
],
|
||||
[
|
||||
'id' => 5,
|
||||
'title' => 'Neues Projekt',
|
||||
'value' => 0.85,
|
||||
'x' => 0.9,
|
||||
'imageUrl' => '/images/thumbs/ian-dooley-hpTH5b6mo2s-unsplash.jpg',
|
||||
'date' => '2024-01-10',
|
||||
'description' => 'Start eines spannenden neuen Projekts mit großem Potenzial.',
|
||||
'isFavorite' => true,
|
||||
'animationDelay' => 500
|
||||
]
|
||||
],
|
||||
'waveConfig' => [
|
||||
'amplitude' => 0.3,
|
||||
'frequency' => 2,
|
||||
'animationDuration' => 2000,
|
||||
'easingFunction' => 'easeInOutQuad'
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
// Mock entry detail for LifeWave interaction
|
||||
Route::get('/entries/{id}', function ($id) {
|
||||
$events = [
|
||||
1 => [
|
||||
'id' => 1,
|
||||
'title' => 'Schulanfang',
|
||||
'subtitle' => 'Ein wichtiger Meilenstein',
|
||||
'date' => '2010-09-01',
|
||||
'time' => '08:00',
|
||||
'location' => 'Grundschule am Park',
|
||||
'level' => 2,
|
||||
'keyImage' => '/images/thumbs/aaron-huber-RLs8LZcONCA-unsplash.jpg',
|
||||
'description' => 'Mein erster Schultag war aufregend und voller neuer Erfahrungen. Die Schultüte war schwer und ich war gleichzeitig nervös und neugierig.',
|
||||
'additionalImages' => [
|
||||
['url' => '/images/thumbs/andrew-bui-z7rzbFHXym0-unsplash.jpg', 'caption' => 'Vorbereitung am Morgen'],
|
||||
['url' => '/images/thumbs/becca-tapert--A_Sx8GrRWg-unsplash.jpg', 'caption' => 'Mit der Familie']
|
||||
],
|
||||
'audioRecordings' => [
|
||||
['name' => 'Erinnerungen', 'url' => '/audio/sample.mp3']
|
||||
],
|
||||
'videoRecordings' => [
|
||||
['name' => 'Einschulungsfeier', 'url' => '/videos/3191901-uhd_3840_2160_25fps.mp4']
|
||||
],
|
||||
'relatedPersons' => [
|
||||
['id' => 1, 'name' => 'Mama', 'relation' => 'Mutter', 'avatar' => null],
|
||||
['id' => 2, 'name' => 'Papa', 'relation' => 'Vater', 'avatar' => null]
|
||||
],
|
||||
'categories' => [
|
||||
['id' => 1, 'name' => 'Bildung', 'icon' => 'school'],
|
||||
['id' => 2, 'name' => 'Familie', 'icon' => 'family_restroom']
|
||||
],
|
||||
'tags' => [
|
||||
['id' => 1, 'name' => 'Aufregend', 'icon' => 'emoji_emotions'],
|
||||
['id' => 2, 'name' => 'Neuanfang', 'icon' => 'new_releases']
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$event = $events[$id] ?? [
|
||||
'id' => (int)$id,
|
||||
'title' => 'Sample Entry ' . $id,
|
||||
'subtitle' => 'Ein wichtiger Meilenstein',
|
||||
'date' => '2023-08-12',
|
||||
'time' => '14:30',
|
||||
'location' => 'München, Deutschland',
|
||||
'level' => 2,
|
||||
'keyImage' => '/images/familie2.png',
|
||||
'description' => 'Eine detaillierte Beschreibung dieses wichtigen Lebensereignisses.'
|
||||
];
|
||||
|
||||
return response()->json($event);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Simple API info page
|
||||
Route::get('/', function () {
|
||||
return response()->json([
|
||||
'app' => 'That\'s Me Backend API (Deployment)',
|
||||
'frontend_path' => '../frontend/',
|
||||
'api_endpoints' => [
|
||||
'health' => '/api/health',
|
||||
'life_events' => '/api/life-events',
|
||||
'entry_detail' => '/api/entries/{id}'
|
||||
],
|
||||
'note' => 'Database-free deployment mode with mock LifeWave data'
|
||||
]);
|
||||
});
|
||||
EOF
|
||||
|
||||
# Create backend environment file for deployment
|
||||
cat > deployment/backend/.env.production << 'EOF'
|
||||
APP_NAME="That's Me Backend (Deployment)"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
LOG_CHANNEL=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
# NO DATABASE CONFIGURATION
|
||||
# Database is disabled for this deployment
|
||||
|
||||
# Cache Configuration (file-based, no database)
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
# Mail Configuration (optional)
|
||||
MAIL_MAILER=log
|
||||
EOF
|
||||
|
||||
# Create root .htaccess for combined deployment
|
||||
cat > deployment/.htaccess << 'EOF'
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# API routes to backend
|
||||
RewriteCond %{REQUEST_URI} ^/api/
|
||||
RewriteRule ^api/(.*)$ /backend/public/index.php [L,QSA]
|
||||
|
||||
# Backend admin routes
|
||||
RewriteCond %{REQUEST_URI} ^/admin/
|
||||
RewriteRule ^admin/(.*)$ /backend/public/index.php [L,QSA]
|
||||
|
||||
# Frontend SPA (default)
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} !^/backend/
|
||||
RewriteRule ^(.*)$ /frontend/index.html [L]
|
||||
</IfModule>
|
||||
|
||||
# Security headers
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options nosniff
|
||||
Header always set X-Frame-Options SAMEORIGIN
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
</IfModule>
|
||||
|
||||
# Cache static assets
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive on
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpg "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/gif "access plus 1 year"
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType video/mp4 "access plus 1 month"
|
||||
</IfModule>
|
||||
EOF
|
||||
|
||||
# Create combined deployment instructions
|
||||
cat > deployment/README.md << 'EOF'
|
||||
# That's Me - Production Deployment
|
||||
|
||||
## Structure
|
||||
```
|
||||
deployment/
|
||||
├── frontend/ # Quasar SPA build
|
||||
│ ├── index.html
|
||||
│ ├── js/
|
||||
│ ├── css/
|
||||
│ ├── images/
|
||||
│ ├── videos/
|
||||
│ └── .htaccess
|
||||
├── backend/ # Laravel API
|
||||
│ ├── app/
|
||||
│ ├── public/
|
||||
│ │ └── index.php
|
||||
│ ├── routes/
|
||||
│ ├── .env.production
|
||||
│ └── ...
|
||||
├── .htaccess # Root routing
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Deployment Instructions
|
||||
|
||||
### Option 1: Combined Deployment (Single Domain)
|
||||
1. Upload entire `deployment/` folder contents to web root
|
||||
2. Configure backend:
|
||||
- Rename `backend/.env.production` to `backend/.env`
|
||||
- Generate APP_KEY (see below)
|
||||
- Set permissions: 755 for `backend/storage/` and `backend/bootstrap/cache/`
|
||||
|
||||
### Option 2: Separate Subdomains
|
||||
- Frontend: Upload `frontend/` contents to frontend subdomain
|
||||
- Backend: Upload `backend/` contents to backend subdomain
|
||||
|
||||
## Generate Laravel APP_KEY
|
||||
Create temporary file in web root:
|
||||
```php
|
||||
<?php
|
||||
// generate-key.php - DELETE AFTER USE!
|
||||
require_once 'backend/vendor/autoload.php';
|
||||
echo 'APP_KEY=' . 'base64:' . base64_encode(random_bytes(32));
|
||||
?>
|
||||
```
|
||||
|
||||
## URL Structure (Combined)
|
||||
- **Frontend**: `yourdomain.com/` (Quasar LifeWave SPA)
|
||||
- **API**: `yourdomain.com/api/health`, `/api/life-events`, `/api/entries/{id}`
|
||||
- **Backend Admin**: `yourdomain.com/admin/` (if Livewire/Volt components added)
|
||||
|
||||
## Features
|
||||
- ✅ LifeWave visualization with anime.js
|
||||
- ✅ Mock life events data for deployment
|
||||
- ✅ Database-free operation
|
||||
- ✅ CORS configured for cross-origin requests
|
||||
- ✅ File-based sessions and cache
|
||||
- ✅ SPA routing for Vue Router
|
||||
|
||||
## Testing
|
||||
- Health check: `/api/health`
|
||||
- Life events: `/api/life-events` (returns events with animation config)
|
||||
- Entry detail: `/api/entries/1`
|
||||
|
||||
Perfect for production and demo environments!
|
||||
EOF
|
||||
|
||||
# Create individual deployment instructions
|
||||
cat > deployment/frontend/DEPLOYMENT_INSTRUCTIONS.md << 'EOF'
|
||||
# Frontend Deployment
|
||||
|
||||
## Standalone Frontend Deployment
|
||||
Upload contents of this folder to web root for frontend-only deployment.
|
||||
|
||||
## What's Included
|
||||
- Complete Quasar SPA build with LifeWave visualization
|
||||
- Images, videos, and audio assets
|
||||
- SPA routing via .htaccess
|
||||
- Production configuration
|
||||
|
||||
## Dependencies
|
||||
- Backend API for data (configure API_BASE_URL in config.js)
|
||||
- anime.js for wave animations (included in build)
|
||||
EOF
|
||||
|
||||
cat > deployment/backend/DEPLOYMENT_INSTRUCTIONS.md << 'EOF'
|
||||
# Backend Deployment
|
||||
|
||||
## Laravel API Deployment
|
||||
Upload contents of this folder to web root for backend-only deployment.
|
||||
|
||||
## Setup Steps
|
||||
1. Rename `.env.production` to `.env`
|
||||
2. Generate APP_KEY using provided instructions
|
||||
3. Set file permissions: 755 for storage/ and bootstrap/cache/
|
||||
4. Test API endpoints
|
||||
|
||||
## API Endpoints
|
||||
- Health: `/api/health`
|
||||
- Life Events: `/api/life-events` (LifeWave data with animation config)
|
||||
- Entry Details: `/api/entries/{id}`
|
||||
|
||||
## Features
|
||||
- Database-free operation with mock data
|
||||
- CORS configured for frontend integration
|
||||
- Optimized for anime.js LifeWave visualizations
|
||||
EOF
|
||||
|
||||
echo "✅ Deployment structure created successfully!"
|
||||
echo ""
|
||||
echo "📁 Created deployment structure:"
|
||||
echo " - deployment/frontend/ (Quasar SPA)"
|
||||
echo " - deployment/backend/ (Laravel API)"
|
||||
echo " - deployment/.htaccess (Combined routing)"
|
||||
echo ""
|
||||
echo "🎯 Deployment Options:"
|
||||
echo " 1. Combined: Upload entire deployment/ folder"
|
||||
echo " 2. Separate: Upload frontend/ and backend/ to different domains"
|
||||
echo ""
|
||||
echo "🌊 Features:"
|
||||
echo " - LifeWave visualization ready"
|
||||
echo " - Mock data with animation config"
|
||||
echo " - Database-free operation"
|
||||
echo " - anime.js integration support"
|
||||
echo ""
|
||||
echo "📋 Next: Follow README.md in deployment/ folder"
|
||||
95
docker-compose.yml
Normal file
95
docker-compose.yml
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
services:
|
||||
# Laravel Backend Service
|
||||
laravel.test:
|
||||
build:
|
||||
context: './backend/vendor/laravel/sail/runtimes/8.4'
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP:-20}'
|
||||
WWWUSER: '${WWWUSER:-501}'
|
||||
image: 'sail-8.4/app'
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${VITE_PORT:-5180}:5173'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER:-501}'
|
||||
WWWGROUP: '${WWWGROUP:-20}'
|
||||
LARAVEL_SAIL: 1
|
||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||
|
||||
# --- Anbindung an das Mutterschiff ---
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: global-mysql
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: thats-me
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: password
|
||||
MAIL_HOST: global-mailpit
|
||||
MAIL_PORT: 1025
|
||||
REDIS_HOST: global-redis
|
||||
volumes:
|
||||
- './backend:/var/www/html'
|
||||
- '.:/workspace:cached'
|
||||
networks:
|
||||
- sail
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.thatsme-main.rule=Host(`thats-me.test`)"
|
||||
- "traefik.http.routers.thatsme-main.entrypoints=websecure"
|
||||
- "traefik.http.routers.thatsme-main.tls=true"
|
||||
- "traefik.http.routers.thatsme-main.service=thatsme-service"
|
||||
- "traefik.http.routers.thatsme-portal.rule=Host(`portal.thats-me.test`)"
|
||||
- "traefik.http.routers.thatsme-portal.entrypoints=websecure"
|
||||
- "traefik.http.routers.thatsme-portal.tls=true"
|
||||
- "traefik.http.routers.thatsme-portal.service=thatsme-service"
|
||||
- "traefik.http.routers.thatsme-api.rule=Host(`api.thats-me.test`)"
|
||||
- "traefik.http.routers.thatsme-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.thatsme-api.tls=true"
|
||||
- "traefik.http.routers.thatsme-api.service=thatsme-service"
|
||||
- "traefik.http.routers.thatsme-assets.rule=Host(`assets.thats-me.test`)"
|
||||
- "traefik.http.routers.thatsme-assets.entrypoints=websecure"
|
||||
- "traefik.http.routers.thatsme-assets.tls=true"
|
||||
- "traefik.http.routers.thatsme-assets.service=thatsme-assets-service"
|
||||
- "traefik.http.services.thatsme-service.loadbalancer.server.port=80"
|
||||
- "traefik.http.services.thatsme-assets-service.loadbalancer.server.port=5173"
|
||||
- "traefik.http.services.thatsme-assets-service.loadbalancer.server.scheme=http"
|
||||
- "traefik.docker.network=proxy"
|
||||
|
||||
# Quasar Frontend Service
|
||||
quasar.app:
|
||||
image: 'node:20-alpine'
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev"
|
||||
ports:
|
||||
- '${QUASAR_PORT:-9000}:9000'
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
volumes:
|
||||
- './frontend:/app'
|
||||
- 'quasar-node-modules:/app/node_modules'
|
||||
networks:
|
||||
- sail
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.thatsme-app.rule=Host(`app.thats-me.test`)"
|
||||
- "traefik.http.routers.thatsme-app.entrypoints=websecure"
|
||||
- "traefik.http.routers.thatsme-app.tls=true"
|
||||
- "traefik.http.routers.thatsme-app.service=thatsme-app-service"
|
||||
- "traefik.http.services.thatsme-app-service.loadbalancer.server.port=9000"
|
||||
- "traefik.http.services.thatsme-app-service.loadbalancer.server.scheme=http"
|
||||
- "traefik.docker.network=proxy"
|
||||
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
quasar-node-modules:
|
||||
driver: local
|
||||
121
documentation/layout/entry-edit-step-1.html
Normal file
121
documentation/layout/entry-edit-step-1.html
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 1</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 2: Single Image Gallery -->
|
||||
<div id="single-image-gallery"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-black shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:42</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-3 border-b border-white/10 bg-black/50 backdrop-blur-md sticky top-0 z-30">
|
||||
<a href="index.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="x" class="w-6 h-6"></i></a>
|
||||
<button class="flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-white/10 transition-colors"><span
|
||||
class="text-sm font-semibold tracking-tight">Recents</span><i data-lucide="chevron-down"
|
||||
class="w-4 h-4 text-white/50"></i></button>
|
||||
<a href="entry-edit-step-2.html"
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors inline-flex items-center justify-center">Next</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar">
|
||||
<div class="grid grid-cols-3 gap-0.5 pb-20">
|
||||
<div
|
||||
class="relative aspect-square bg-white/5 flex flex-col items-center justify-center gap-2 cursor-pointer hover:bg-white/10 transition-colors group">
|
||||
<i data-lucide="image-off" class="w-6 h-6 text-white/40 group-hover:text-white/80 transition-colors"></i>
|
||||
</div>
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1542038784456-1ea8e935640e?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt="Gallery">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1516762689617-e1cffcef479d?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1469334031218-e382a71b716b?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#2a2a2a] rounded-full border border-white/10 shadow-xl backdrop-blur-md">
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden border border-white/20"><img
|
||||
src="https://images.unsplash.com/photo-1542038784456-1ea8e935640e?ixlib=rb-4.0.3&w=100&q=60"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<span class="text-xs font-medium text-white/90">1 Selected</span>
|
||||
<button class="w-6 h-6 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"><i
|
||||
data-lucide="x" class="w-3.5 h-3.5 text-white/50"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
130
documentation/layout/entry-edit-step-2.html
Normal file
130
documentation/layout/entry-edit-step-2.html
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 2</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 3: Details Entry -->
|
||||
<div id="screen-details"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5 scroll-mt-8">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:43</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
<a href="entry-edit-step-1.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
<div class="flex-1"></div>
|
||||
<a href="entry-edit-step-3.html"
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors inline-flex items-center justify-center">Next</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 pt-4 pb-12 flex flex-col gap-8 no-scrollbar">
|
||||
|
||||
<!-- Emotional Level - Updated -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-between items-end"><label class="text-sm font-medium text-white/60">Emotional
|
||||
Level</label><span class="text-xs font-medium text-blue-400">Good</span></div>
|
||||
<div class="flex justify-between items-center pt-2 pb-1">
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😫</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😔</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">🙁</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😐</span></button>
|
||||
<!-- Selected -->
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-[#2563eb] border border-blue-400 shadow-[0_0_15px_rgba(37,99,235,0.5)] flex items-center justify-center transition-all scale-110"><span
|
||||
class="text-xl">🙂</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😄</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">🤩</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Date</label>
|
||||
<div class="group relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="calendar"
|
||||
class="w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors"></i></div><input
|
||||
type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-3 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium"
|
||||
value="2023-07-28">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Time</label>
|
||||
<div class="group relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="clock"
|
||||
class="w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors"></i></div><input
|
||||
type="time"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-3 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium"
|
||||
value="14:30">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2"><label class="text-sm font-medium text-white/60">Short Title</label><input
|
||||
type="text" placeholder="e.g. Summer Wedding"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
<div class="flex flex-col gap-2"><label class="text-sm font-medium text-white/60">Headline</label><input
|
||||
type="text" placeholder="A day to remember forever"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 flex-1"><label
|
||||
class="text-sm font-medium text-white/60">Description</label><textarea
|
||||
placeholder="Write your thoughts here..."
|
||||
class="w-full h-32 bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium resize-none leading-relaxed"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
150
documentation/layout/entry-edit-step-3.html
Normal file
150
documentation/layout/entry-edit-step-3.html
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 3</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 4: Multiple Image Gallery -->
|
||||
<div id="multiple-image-gallery"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-black shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:44</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-3 border-b border-white/10 bg-black/50 backdrop-blur-md sticky top-0 z-30">
|
||||
<a href="entry-edit-step-2.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Add Photos</span>
|
||||
<span class="text-[10px] text-white/50">Camera Roll</span>
|
||||
</div>
|
||||
<a href="entry-edit-step-4.html"
|
||||
class="px-4 py-1.5 bg-[#2563eb] text-white text-xs font-semibold rounded-full hover:bg-blue-600 transition-colors">Add
|
||||
(3)</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar">
|
||||
<div class="grid grid-cols-3 gap-0.5 pb-20">
|
||||
<!-- Selected 1 -->
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img src="https://images.unsplash.com/photo-1511895426328-dc8714191300?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
<div class="absolute bottom-1.5 left-1.5 bg-black/50 backdrop-blur px-1.5 rounded text-[10px] font-medium">1
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected 2 -->
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img src="https://images.unsplash.com/photo-1526047932273-341f2a7631f9?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
<div class="absolute bottom-1.5 left-1.5 bg-black/50 backdrop-blur px-1.5 rounded text-[10px] font-medium">2
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected 3 -->
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img src="https://images.unsplash.com/photo-1470240731273-7821a6eeb6bd?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
<div class="absolute bottom-1.5 left-1.5 bg-black/50 backdrop-blur px-1.5 rounded text-[10px] font-medium">3
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unselected -->
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1552083375-1447ce886485?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1506477331477-33d5d8b3dc85?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1496345875659-11f7dd282d1d?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/917d6f93-fb36-439a-8c48-884b67b35381_1600w.jpg"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1466112928291-0903b80a9466?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/4734259a-bad7-422f-981e-ce01e79184f2_1600w.jpg"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1509233725247-49e657c54213?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
145
documentation/layout/entry-edit-step-4.html
Normal file
145
documentation/layout/entry-edit-step-4.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 4</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 5: Video & Audio Selection -->
|
||||
<div id="video-audio"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:45</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
|
||||
<a href="entry-edit-step-3.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<div class="flex-1 text-center text-sm font-semibold">Media</div>
|
||||
<a href="entry-edit-step-5.html"
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors inline-flex items-center justify-center">Next</a>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-6 no-scrollbar">
|
||||
|
||||
<!-- Video Section -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-sm font-medium text-white/80">Videos</label>
|
||||
<span class="text-xs text-blue-400 font-medium cursor-pointer">View All</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative h-48 rounded-2xl bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-3 cursor-pointer hover:bg-white/10 transition-colors group border-dashed">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<i data-lucide="video" class="w-6 h-6 text-white/60"></i>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white/40">Tap to select videos</span>
|
||||
</div>
|
||||
<!-- Selected Video Mini-Preview (Simulated) -->
|
||||
<div class="flex items-center gap-3 bg-white/5 p-3 rounded-xl border border-white/10">
|
||||
<div class="w-12 h-12 bg-black rounded-lg overflow-hidden relative">
|
||||
<img src="https://images.unsplash.com/photo-1492684223066-81342ee5ff30?ixlib=rb-4.0.3&w=100&q=60"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<i data-lucide="play"
|
||||
class="w-4 h-4 text-white absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-white"></i>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-white">Holiday_clip.mp4</span>
|
||||
<span class="text-[10px] text-white/40">00:15 • 12MB</span>
|
||||
</div>
|
||||
<button class="text-white/40 hover:text-white"><i data-lucide="x" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Audio Section -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-sm font-medium text-white/80">Audio</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
class="h-20 rounded-xl bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-2 hover:bg-white/10 transition-colors">
|
||||
<i data-lucide="mic" class="w-5 h-5 text-red-400"></i>
|
||||
<span class="text-xs font-medium text-white/60">Record</span>
|
||||
</button>
|
||||
<button
|
||||
class="h-20 rounded-xl bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-2 hover:bg-white/10 transition-colors">
|
||||
<i data-lucide="music" class="w-5 h-5 text-blue-400"></i>
|
||||
<span class="text-xs font-medium text-white/60">Select File</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio Player (Visual) -->
|
||||
<div class="mt-2 bg-[#2563eb]/10 border border-[#2563eb]/30 p-4 rounded-xl flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-[#2563eb] flex items-center justify-center shrink-0">
|
||||
<i data-lucide="play" class="w-4 h-4 text-white fill-white ml-0.5"></i>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-white/90">Voice Note 01</span>
|
||||
<!-- Waveform Graphic -->
|
||||
<div class="flex items-center gap-[2px] h-4 items-end">
|
||||
<div class="w-1 h-2 bg-[#2563eb]/50 rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-[#2563eb]/70 rounded-full"></div>
|
||||
<div class="w-1 h-4 bg-[#2563eb] rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-[#2563eb]/80 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-[#2563eb]/60 rounded-full"></div>
|
||||
<div class="w-1 h-4 bg-[#2563eb] rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-[#2563eb]/70 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-[#2563eb]/50 rounded-full"></div>
|
||||
<div class="w-1 h-1 bg-[#2563eb]/30 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-[#2563eb]/50 rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-white/20 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-white/20 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-white/50 font-mono">0:14</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
173
documentation/layout/entry-edit-step-5.html
Normal file
173
documentation/layout/entry-edit-step-5.html
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 5</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 6: Location, People, Categories, Publish -->
|
||||
<div id="location"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:46</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
|
||||
<a href="entry-edit-step-4.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<div class="flex-1 text-center text-sm font-semibold">Details</div>
|
||||
<div class="w-10"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-2 flex flex-col gap-8 no-scrollbar pb-24">
|
||||
|
||||
<!-- Location -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Location</label>
|
||||
<div class="group relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="map-pin"
|
||||
class="w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors"></i></div>
|
||||
<input type="text" value="Central Park, New York"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-10 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all font-medium">
|
||||
<div
|
||||
class="absolute inset-y-0 right-3 flex items-center cursor-pointer hover:text-white text-white/40 transition-colors">
|
||||
<i data-lucide="locate-fixed" class="w-4 h-4"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Persons -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-sm font-medium text-white/60">With whom?</label>
|
||||
<span class="text-xs text-white/30">2 selected</span>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto no-scrollbar py-1">
|
||||
<button
|
||||
class="w-12 h-12 rounded-full bg-white/10 border border-white/10 flex items-center justify-center hover:bg-white/20 transition-colors shrink-0">
|
||||
<i data-lucide="plus" class="w-5 h-5 text-white/70"></i>
|
||||
</button>
|
||||
<div class="relative w-12 h-12 shrink-0">
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&w=100&q=60"
|
||||
class="w-full h-full rounded-full object-cover border border-white/10">
|
||||
<div class="absolute -top-1 -right-1 bg-black/60 rounded-full p-0.5"><i data-lucide="x"
|
||||
class="w-3 h-3 text-white"></i></div>
|
||||
</div>
|
||||
<div
|
||||
class="relative w-12 h-12 shrink-0 flex items-center justify-center bg-blue-900/50 border border-blue-500/30 rounded-full">
|
||||
<span class="text-xs font-semibold text-blue-200">JD</span>
|
||||
<div class="absolute -top-1 -right-1 bg-black/60 rounded-full p-0.5"><i data-lucide="x"
|
||||
class="w-3 h-3 text-white"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-sm font-medium text-white/60">Category</label>
|
||||
|
||||
<!-- Category Search Input -->
|
||||
<div class="relative mb-1">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="search"
|
||||
class="w-4 h-4 text-white/40"></i></div>
|
||||
<input type="text" placeholder="Search categories..."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-2.5 pl-9 pr-4 text-xs text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all font-medium placeholder-white/20">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all text-left group">
|
||||
<div class="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center text-purple-300"><i
|
||||
data-lucide="briefcase" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Career</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all text-left group">
|
||||
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center text-orange-300"><i
|
||||
data-lucide="graduation-cap" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Education</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all text-left group">
|
||||
<div class="w-8 h-8 rounded-lg bg-yellow-500/20 flex items-center justify-center text-yellow-300"><i
|
||||
data-lucide="trophy" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Awards</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-blue-600 border border-blue-500 rounded-xl shadow-[0_0_15px_rgba(37,99,235,0.3)] transition-all text-left">
|
||||
<div class="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white"><i
|
||||
data-lucide="party-popper" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white">Celebration</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Tags</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="hash"
|
||||
class="w-4 h-4 text-white/40"></i></div>
|
||||
<input type="text" placeholder="happy, summer, memories"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-9 pr-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all font-medium">
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-md bg-[#2563eb]/20 text-blue-300 text-[10px] font-medium border border-[#2563eb]/30">#happy</span>
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-md bg-[#2563eb]/20 text-blue-300 text-[10px] font-medium border border-[#2563eb]/30">#summer</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sticky Bottom Button -->
|
||||
<div class="absolute bottom-0 w-full p-6 bg-gradient-to-t from-[#09090b] via-[#09090b] to-transparent z-40">
|
||||
<a href="index.html"
|
||||
class="w-full bg-white text-black font-semibold py-3.5 rounded-full shadow-lg shadow-white/10 active:scale-[0.98] transition-all hover:bg-gray-100 flex items-center justify-center gap-2">
|
||||
<span>Publish</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
194
documentation/layout/entry.html
Normal file
194
documentation/layout/entry.html
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App Interface Variations</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-geist">.font-geist { font-family: 'Geist', sans-serif !important; }</style><link id="all-fonts-link-font-roboto" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-roboto">.font-roboto { font-family: 'Roboto', sans-serif !important; }</style><link id="all-fonts-link-font-montserrat" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-montserrat">.font-montserrat { font-family: 'Montserrat', sans-serif !important; }</style><link id="all-fonts-link-font-poppins" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-poppins">.font-poppins { font-family: 'Poppins', sans-serif !important; }</style><link id="all-fonts-link-font-playfair" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap"><style id="all-fonts-style-font-playfair">.font-playfair { font-family: 'Playfair Display', serif !important; }</style><link id="all-fonts-link-font-instrument-serif" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap"><style id="all-fonts-style-font-instrument-serif">.font-instrument-serif { font-family: 'Instrument Serif', serif !important; }</style><link id="all-fonts-link-font-merriweather" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap"><style id="all-fonts-style-font-merriweather">.font-merriweather { font-family: 'Merriweather', serif !important; }</style><link id="all-fonts-link-font-bricolage" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-bricolage">.font-bricolage { font-family: 'Bricolage Grotesque', sans-serif !important; }</style><link id="all-fonts-link-font-jakarta" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-jakarta">.font-jakarta { font-family: 'Plus Jakarta Sans', sans-serif !important; }</style><link id="all-fonts-link-font-manrope" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-manrope">.font-manrope { font-family: 'Manrope', sans-serif !important; }</style><link id="all-fonts-link-font-space-grotesk" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-space-grotesk">.font-space-grotesk { font-family: 'Space Grotesk', sans-serif !important; }</style><link id="all-fonts-link-font-work-sans" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-work-sans">.font-work-sans { font-family: 'Work Sans', sans-serif !important; }</style><link id="all-fonts-link-font-pt-serif" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap"><style id="all-fonts-style-font-pt-serif">.font-pt-serif { font-family: 'PT Serif', serif !important; }</style><link id="all-fonts-link-font-geist-mono" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-geist-mono">.font-geist-mono { font-family: 'Geist Mono', monospace !important; }</style><link id="all-fonts-link-font-space-mono" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"><style id="all-fonts-style-font-space-mono">.font-space-mono { font-family: 'Space Mono', monospace !important; }</style><link id="all-fonts-link-font-quicksand" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-quicksand">.font-quicksand { font-family: 'Quicksand', sans-serif !important; }</style><link id="all-fonts-link-font-nunito" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-nunito">.font-nunito { font-family: 'Nunito', sans-serif !important; }</style></head>
|
||||
<body class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
|
||||
|
||||
<!-- Screen 7: Complete Entry View -->
|
||||
<div class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<!-- Header Overlaid -->
|
||||
<div class="absolute top-0 w-full flex justify-between items-center px-6 pt-12 pb-2 z-30">
|
||||
<a href="index.html" class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
<button class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i data-lucide="more-horizontal" class="w-6 h-6"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar relative">
|
||||
|
||||
<!-- Hero Slider -->
|
||||
<div class="w-full h-[45%] relative group">
|
||||
<div class="flex overflow-x-auto snap-x snap-mandatory no-scrollbar h-full w-full">
|
||||
<div class="w-full shrink-0 h-full snap-center relative">
|
||||
<img src="https://images.unsplash.com/photo-1511895426328-dc8714191300?ixlib=rb-4.0.3&w=600&q=80" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-[#09090b]"></div>
|
||||
</div>
|
||||
<div class="w-full shrink-0 h-full snap-center relative">
|
||||
<img src="https://images.unsplash.com/photo-1526047932273-341f2a7631f9?ixlib=rb-4.0.3&w=600&q=80" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-[#09090b]"></div>
|
||||
</div>
|
||||
<div class="w-full shrink-0 h-full snap-center relative">
|
||||
<img src="https://images.unsplash.com/photo-1470240731273-7821a6eeb6bd?ixlib=rb-4.0.3&w=600&q=80" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-[#09090b]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Slider Indicators -->
|
||||
<div class="absolute bottom-10 left-1/2 -translate-x-1/2 flex gap-1.5 z-20">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-white/40"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-white/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Body -->
|
||||
<div class="relative px-6 -mt-6 z-20 pb-32">
|
||||
<!-- Meta Info -->
|
||||
<div class="flex flex-col gap-1 mb-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest text-blue-400">Summer Wedding</span>
|
||||
<div class="flex items-center gap-1 bg-white/5 px-2 py-0.5 rounded-full border border-white/5">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
|
||||
<span class="text-[10px] text-white/60">Positivity +2</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white leading-tight tracking-tight mt-1">A day to remember forever</h1>
|
||||
<div class="flex items-center gap-4 mt-3 text-xs text-white/50 font-medium">
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="calendar" class="w-3.5 h-3.5"></i><span>28 Jul, 14:30</span></div>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="map-pin" class="w-3.5 h-3.5"></i><span>Central Park, NY</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emotional Level Visual -->
|
||||
<div class="mb-8">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 block">Emotional Impact</label>
|
||||
<div class="h-1.5 w-full bg-white/10 rounded-full relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-[80%] bg-gradient-to-r from-blue-500 to-green-400 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-8">
|
||||
<p class="text-sm text-white/80 leading-relaxed font-light">
|
||||
The sun was shining perfectly through the trees as we arrived. It was one of those rare moments where everything feels exactly right. The laughter, the music, and the company made it unforgettable. I want to keep this memory close.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Video Slider -->
|
||||
<div class="mb-8">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-3 block">Videos</label>
|
||||
<div class="flex gap-3 overflow-x-auto no-scrollbar snap-x snap-mandatory">
|
||||
<div class="w-40 aspect-[9/16] bg-white/5 rounded-xl shrink-0 overflow-hidden relative border border-white/10 snap-start">
|
||||
<img src="https://images.unsplash.com/photo-1492684223066-81342ee5ff30?ixlib=rb-4.0.3&w=200&q=60" class="w-full h-full object-cover opacity-80">
|
||||
<div class="absolute inset-0 flex items-center justify-center"><div class="w-8 h-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center"><i data-lucide="play" class="w-4 h-4 text-white fill-white ml-0.5"></i></div></div>
|
||||
</div>
|
||||
<div class="w-40 aspect-[9/16] bg-white/5 rounded-xl shrink-0 overflow-hidden relative border border-white/10 snap-start">
|
||||
<img src="https://images.unsplash.com/photo-1516035069371-29a1b244cc32?ixlib=rb-4.0.3&w=200&q=60" class="w-full h-full object-cover opacity-80">
|
||||
<div class="absolute inset-0 flex items-center justify-center"><div class="w-8 h-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center"><i data-lucide="play" class="w-4 h-4 text-white fill-white ml-0.5"></i></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-end mb-3">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider block">Gallery</label>
|
||||
<span class="text-[10px] text-blue-400 font-medium cursor-pointer">View All</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5"><img src="https://images.unsplash.com/photo-1511895426328-dc8714191300?ixlib=rb-4.0.3&w=150&q=60" class="w-full h-full object-cover hover:opacity-80 transition-opacity"></div>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5"><img src="https://images.unsplash.com/photo-1526047932273-341f2a7631f9?ixlib=rb-4.0.3&w=150&q=60" class="w-full h-full object-cover hover:opacity-80 transition-opacity"></div>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5"><img src="https://images.unsplash.com/photo-1470240731273-7821a6eeb6bd?ixlib=rb-4.0.3&w=150&q=60" class="w-full h-full object-cover hover:opacity-80 transition-opacity"></div>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5 flex items-center justify-center text-white/50 text-xs font-medium bg-white/10">+5</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Recordings -->
|
||||
<div class="mb-8">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-3 block">Audio Notes</label>
|
||||
<div class="flex gap-3 mb-3">
|
||||
<!-- Thumbnails -->
|
||||
<div class="w-14 h-14 rounded-lg bg-[#2563eb] flex items-center justify-center border border-white/10 shadow-[0_0_10px_rgba(37,99,235,0.5)] shrink-0">
|
||||
<i data-lucide="mic" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<div class="w-14 h-14 rounded-lg bg-white/5 flex items-center justify-center border border-white/10 shrink-0 opacity-60">
|
||||
<i data-lucide="music-2" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<div class="w-14 h-14 rounded-lg bg-white/5 flex items-center justify-center border border-white/10 shrink-0 opacity-60">
|
||||
<i data-lucide="music-2" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Active Player -->
|
||||
<div class="bg-white/5 border border-white/10 p-3 pr-4 rounded-xl flex items-center gap-3">
|
||||
<button class="w-8 h-8 rounded-full bg-white text-black flex items-center justify-center shrink-0 hover:bg-gray-200">
|
||||
<i data-lucide="pause" class="w-3.5 h-3.5 fill-current"></i>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between text-[10px] mb-1.5">
|
||||
<span class="font-medium text-white">Speech.m4a</span>
|
||||
<span class="text-white/40">0:12 / 0:45</span>
|
||||
</div>
|
||||
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full w-1/3 bg-[#2563eb] rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- People & Categories -->
|
||||
<div class="mb-8 space-y-6">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 block">With Whom</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&w=100&q=60" class="w-10 h-10 rounded-full border border-white/10 object-cover">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-900/50 border border-blue-500/30 flex items-center justify-center text-xs font-semibold text-blue-200">JD</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 block">Tags & Category</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-2.5 py-1 rounded-full bg-blue-600/20 text-blue-300 text-[10px] font-medium border border-blue-500/30 flex items-center gap-1"><i data-lucide="party-popper" class="w-3 h-3"></i>Celebration</span>
|
||||
<span class="px-2.5 py-1 rounded-full bg-white/5 text-white/60 text-[10px] font-medium border border-white/10">#summer</span>
|
||||
<span class="px-2.5 py-1 rounded-full bg-white/5 text-white/60 text-[10px] font-medium border border-white/10">#memories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Footer Action -->
|
||||
<div class="absolute bottom-0 w-full px-6 py-4 bg-[#09090b]/90 backdrop-blur-xl border-t border-white/5 flex items-center gap-3 z-40">
|
||||
<button class="w-12 h-12 rounded-full border border-white/10 bg-white/5 flex items-center justify-center text-white hover:bg-white/10 transition-colors">
|
||||
<i data-lucide="share-2" class="w-5 h-5"></i>
|
||||
</button>
|
||||
<button class="flex-1 bg-white text-black h-12 rounded-full font-semibold text-sm flex items-center justify-center gap-2 hover:bg-gray-200 transition-colors shadow-lg shadow-white/5">
|
||||
<i data-lucide="edit-3" class="w-4 h-4"></i>
|
||||
Edit Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body></html>
|
||||
428
documentation/layout/index.html
Normal file
428
documentation/layout/index.html
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Wave Flow Duplicate</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-newsreader" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400..800&display=swap">
|
||||
<style id="all-fonts-style-font-newsreader">
|
||||
.font-newsreader {
|
||||
font-family: 'Newsreader', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-google-sans-flex" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-google-sans-flex">
|
||||
.font-google-sans-flex {
|
||||
font-family: 'Google Sans Flex', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-oswald" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Oswald:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-oswald">
|
||||
.font-oswald {
|
||||
font-family: 'Oswald', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-dm-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-dm-sans">
|
||||
.font-dm-sans {
|
||||
font-family: 'DM Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 pt-8 pr-8 pb-8 pl-8">
|
||||
|
||||
|
||||
<!-- SCREEN 1: Original with Image Logo -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-app-gradient shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<!-- Status Bar -->
|
||||
<div class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-normal z-20 mix-blend-plus-lighter">
|
||||
<span>9:41</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="signal" class="lucide lucide-signal w-3.5 h-3.5">
|
||||
<path d="M2 20h.01"></path>
|
||||
<path d="M7 20v-4"></path>
|
||||
<path d="M12 20v-8"></path>
|
||||
<path d="M17 20V8"></path>
|
||||
<path d="M22 4v16"></path>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="wifi" class="lucide lucide-wifi w-3.5 h-3.5">
|
||||
<path d="M12 20h.01"></path>
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0"></path>
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0"></path>
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0"></path>
|
||||
</svg>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-end px-6 py-2 z-20">
|
||||
<img src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/420cb7fa-1091-4075-a321-497c1290e4e2_3840w.png"
|
||||
alt="Logo" class="h-8 w-auto object-contain drop-shadow-md" style="">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all group overflow-hidden relative">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="sun"
|
||||
class="lucide lucide-sun w-4 h-4 text-yellow-300 absolute transition-all duration-300 scale-100 rotate-0 group-hover:scale-0 group-hover:rotate-90 opacity-100 group-hover:opacity-0">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="m4.93 4.93 1.41 1.41"></path>
|
||||
<path d="m17.66 17.66 1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="moon"
|
||||
class="lucide lucide-moon w-4 h-4 text-blue-200 absolute transition-all duration-300 scale-0 -rotate-90 group-hover:scale-100 group-hover:rotate-0 opacity-0 group-hover:opacity-100">
|
||||
<path
|
||||
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
<a href="login.html" class="p-1 hover:bg-white/10 rounded-full transition-colors"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="menu" class="lucide lucide-menu w-7 h-7 text-white">
|
||||
<path d="M4 5h16"></path>
|
||||
<path d="M4 12h16"></path>
|
||||
<path d="M4 19h16"></path>
|
||||
</svg></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 w-full h-full relative">
|
||||
<!-- mt-28 adds 112px, which is 48px more than previous mt-16 (64px) -->
|
||||
<div class="absolute inset-0 w-full h-full mt-28">
|
||||
<svg class="absolute top-0 w-full h-[400px] overflow-visible pointer-events-none z-0"
|
||||
viewBox="0 0 390 400" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="lineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:white;stop-opacity:0.1"></stop>
|
||||
<stop offset="20%" style="stop-color:white;stop-opacity:0.8"></stop>
|
||||
<stop offset="50%" style="stop-color:white;stop-opacity:1"></stop>
|
||||
<stop offset="80%" style="stop-color:white;stop-opacity:0.8"></stop>
|
||||
<stop offset="100%" style="stop-color:white;stop-opacity:0.1"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M -50,180
|
||||
C -20,180 10,100 39,100
|
||||
S 80,240 117,240
|
||||
S 155,60 195,60
|
||||
S 235,240 273,240
|
||||
S 310,180 351,180
|
||||
C 390,180 410,180 440,180" fill="none" stroke="url(#lineGrad)" stroke-width="1.5"
|
||||
stroke-linecap="round"></path>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="absolute left-[10%] -translate-x-1/2 top-[100px] flex flex-col-reverse items-center z-10 -translate-y-full pb-0.5">
|
||||
<div
|
||||
class="relative w-3 h-3 rounded-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.8)] z-20 translate-y-1.5">
|
||||
</div>
|
||||
<div class="h-6 w-px bg-white/40 my-1"></div>
|
||||
<div class="flex flex-col items-center opacity-90">
|
||||
<span
|
||||
class="text-[10px] font-semibold text-white/90 bg-white/10 px-2 py-1 rounded-full backdrop-blur-md border border-white/10">21
|
||||
Jun</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-[30%] -translate-x-1/2 top-[240px] flex flex-col items-center z-10 -translate-y-[6px]">
|
||||
<div class="relative w-3 h-3 rounded-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.8)] z-20">
|
||||
</div>
|
||||
<div class="h-8 w-px bg-white/40 my-1.5"></div>
|
||||
<div class="relative w-14 h-14 rounded-full border border-white/40 shadow-xl overflow-hidden group">
|
||||
<img src="https://images.unsplash.com/photo-1523580494863-6f3031224c94?ixlib=rb-4.0.3&auto=format&fit=crop&w=150&q=80"
|
||||
alt="Concert" class="w-full h-full object-cover" style="">
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-gradient-to-t from-black/80 to-transparent pt-3 pb-1 text-center">
|
||||
<span class="text-[9px] font-normal text-white/90">25 Jun</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link added here -->
|
||||
<a href="entry"
|
||||
class="absolute left-1/2 -translate-x-1/2 top-[60px] flex flex-col-reverse items-center z-20 -translate-y-full pb-1 transition-transform active:scale-95">
|
||||
<div
|
||||
class="relative w-4 h-4 rounded-full bg-white shadow-[0_0_20px_rgba(255,255,255,1)] ring-4 ring-white/10 z-20 translate-y-2">
|
||||
</div>
|
||||
<div class="h-8 w-px bg-white/40 my-1.5"></div>
|
||||
<div
|
||||
class="relative w-20 h-20 rounded-full border-[3px] border-white shadow-2xl overflow-hidden group">
|
||||
<img src="https://images.unsplash.com/photo-1515934751635-c81c6bc9a2d8?ixlib=rb-4.0.3&auto=format&fit=crop&w=300&q=80"
|
||||
alt="Wedding" class="w-full h-full object-cover">
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-gradient-to-t from-black/80 to-transparent pt-6 pb-2 text-center">
|
||||
<span class="text-xs font-semibold text-white">28 Jul</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="absolute left-[70%] -translate-x-1/2 top-[240px] flex flex-col items-center z-10 -translate-y-[6px]">
|
||||
<div class="relative w-3 h-3 rounded-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.8)] z-20">
|
||||
</div>
|
||||
<div class="h-8 w-px bg-white/40 my-1.5"></div>
|
||||
<div class="relative w-14 h-14 rounded-full border border-white/40 shadow-xl overflow-hidden group">
|
||||
<img src="https://images.unsplash.com/photo-1496337589254-7e19d01cec44?ixlib=rb-4.0.3&auto=format&fit=crop&w=150&q=80"
|
||||
alt="Dinner" class="w-full h-full object-cover" style="">
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-gradient-to-t from-black/80 to-transparent pt-3 pb-1 text-center">
|
||||
<span class="text-[9px] font-normal text-white/90">12 Aug</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-[90%] -translate-x-1/2 top-[180px] flex flex-col items-center z-10 -translate-y-1">
|
||||
<div
|
||||
class="relative w-2.5 h-2.5 rounded-full bg-white/90 shadow-[0_0_10px_rgba(255,255,255,0.6)] z-20">
|
||||
</div>
|
||||
<span class="mt-3 text-[9px] font-medium text-white/60">18 Aug</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-28 w-full">
|
||||
<div class="flex justify-between px-8 text-lg text-white/60 font-normal">
|
||||
<span class="opacity-50 text-sm">May</span>
|
||||
<span class="opacity-80">Jun</span>
|
||||
<span class="text-white scale-125 font-semibold drop-shadow-md mx-2">Jul</span>
|
||||
<span class="opacity-80">Aug</span>
|
||||
<span class="opacity-50 text-sm">Sep</span>
|
||||
</div>
|
||||
<div class="mx-8 mt-4 h-[1px] bg-gradient-to-r from-transparent via-white/30 to-transparent"></div>
|
||||
<div class="text-center mt-3 text-xl font-medium tracking-tight text-white/90">2023</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navbar -->
|
||||
<div
|
||||
class="flex z-30 bg-[#1a1a1a]/95 w-full h-20 border-white/5 border-t pr-8 pl-8 relative backdrop-blur-xl items-center justify-between">
|
||||
|
||||
<a href="index.html"
|
||||
class="flex flex-col hover:text-white transition-colors text-white/70 w-12 h-12 items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="activity" class="lucide lucide-activity w-[24px] h-[24px]">
|
||||
<path
|
||||
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2">
|
||||
</path>
|
||||
</svg></a>
|
||||
|
||||
<button
|
||||
class="flex flex-col items-center justify-center w-12 h-12 text-white/70 hover:text-white transition-colors mr-8"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="users" class="lucide lucide-users w-6 h-6">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
|
||||
<path d="M16 3.128a4 4 0 0 1 0 7.744"></path>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
</svg></button>
|
||||
<div class="absolute left-1/2 -translate-x-1/2 -top-6">
|
||||
<a href="entry-edit-step-1.html"
|
||||
class="flex border-[3px] hover:scale-105 transition-transform active:scale-95 group bg-[#3e3e3e] w-14 h-14 border-[#a7f3d0] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4)] items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="plus" class="lucide lucide-plus w-[28px] h-[28px]">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg></a>
|
||||
</div>
|
||||
|
||||
<a href="login.html"
|
||||
class="flex flex-col hover:text-white transition-colors text-white/70 w-12 h-12 items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="mail" class="lucide lucide-mail w-6 h-6">
|
||||
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||
</svg></a>
|
||||
|
||||
<a href="profile.html"
|
||||
class="flex flex-col hover:text-white transition-colors text-white/70 w-12 h-12 items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="user" class="lucide lucide-user w-6 h-6">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
296
documentation/layout/login.html
Normal file
296
documentation/layout/login.html
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App Interface Variations</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 1: Login -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex z-20 text-xs font-medium text-white mix-blend-difference pt-4 pr-6 pb-2 pl-6 items-center justify-between">
|
||||
<span class="">9:41</span>
|
||||
<div class="flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="signal" class="lucide lucide-signal w-3.5 h-3.5">
|
||||
<path d="M2 20h.01"></path>
|
||||
<path d="M7 20v-4"></path>
|
||||
<path d="M12 20v-8"></path>
|
||||
<path d="M17 20V8"></path>
|
||||
<path d="M22 4v16"></path>
|
||||
</svg><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="wifi" class="lucide lucide-wifi w-3.5 h-3.5">
|
||||
<path d="M12 20h.01"></path>
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0"></path>
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0"></path>
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0"></path>
|
||||
</svg>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col z-10 pr-8 pl-8 relative justify-center">
|
||||
<!-- Background Decorative Blur -->
|
||||
<div
|
||||
class="absolute top-[-10%] right-[-10%] w-64 h-64 bg-blue-600/20 rounded-full blur-[80px] pointer-events-none">
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-[-10%] left-[-10%] w-64 h-64 bg-purple-600/20 rounded-full blur-[80px] pointer-events-none">
|
||||
</div>
|
||||
|
||||
<div class="mb-12">
|
||||
<!-- Added Logo based on image provided -->
|
||||
<img src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/32e790c1-060e-4820-bca4-b639b2d815b0_320w.png"
|
||||
class="w-20 h-auto mb-6 drop-shadow-lg object-cover" alt="VΛYV Logo">
|
||||
|
||||
<p class="text-white/40 text-sm">Welcome back. Enter your credentials to access your VΛYV account.</p>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Username or Email</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="user"
|
||||
class="lucide lucide-user w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg></div>
|
||||
<input type="text" placeholder="name@example.com"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center ml-1">
|
||||
<label class="text-xs font-medium text-white/60">Password</label>
|
||||
<a href="#"
|
||||
class="text-[10px] text-blue-400 font-medium hover:text-blue-300 transition-colors">Forgot
|
||||
Password?</a>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="lock"
|
||||
class="lucide lucide-lock w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg></div>
|
||||
<input type="password" placeholder="••••••••"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="index.html"
|
||||
class="w-full bg-white text-black font-semibold py-3.5 rounded-xl mt-4 shadow-[0_0_20px_rgba(255,255,255,0.1)] active:scale-[0.98] transition-all hover:bg-gray-100 flex items-center justify-center gap-2">
|
||||
Log In <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="arrow-right" class="lucide lucide-arrow-right w-4 h-4">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="m12 5 7 7-7 7"></path>
|
||||
</svg></a>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="mt-8 flex items-center justify-between gap-4">
|
||||
<div class="h-[1px] bg-white/10 flex-1"></div>
|
||||
<span class="text-[10px] text-white/30 uppercase tracking-widest">Or continue with</span>
|
||||
<div class="h-[1px] bg-white/10 flex-1"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 mt-6">
|
||||
<button
|
||||
class="flex-1 bg-white/5 border border-white/10 hover:bg-white/10 transition-colors rounded-xl py-2.5 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-white/5 border border-white/10 hover:bg-white/10 transition-colors rounded-xl py-2.5 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M24 12.276c0-.853-.076-1.674-.219-2.472H12v4.676h6.728c-.29 1.554-1.171 2.87-2.496 3.757v3.124h4.043c2.365-2.177 3.729-5.385 3.729-9.085z"
|
||||
fill="#4285F4"></path>
|
||||
<path
|
||||
d="M12 24.48c3.243 0 5.983-1.076 7.97-2.909l-4.043-3.124c-1.076.721-2.454 1.148-3.927 1.148-3.129 0-5.782-2.114-6.73-4.956H1.206v3.121C3.18 21.676 7.294 24.48 12 24.48z"
|
||||
fill="#34A853"></path>
|
||||
<path
|
||||
d="M5.27 14.639c-.24-.721-.378-1.492-.378-2.289s.138-1.568.378-2.289V6.936H1.206C.437 8.468 0 10.198 0 12.35s.437 3.882 1.206 5.414l4.064-3.125z"
|
||||
fill="#FBBC05"></path>
|
||||
<path
|
||||
d="M12 4.777c1.764 0 3.349.607 4.595 1.798l3.447-3.448C17.978 1.178 15.238 0 12 0 7.294 0 3.18 2.804 1.206 6.936l4.064 3.125C6.218 7.194 8.871 4.777 12 4.777z"
|
||||
fill="#EA4335"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-xs text-white/40">Don't have an account? <a href="signup.html"
|
||||
class="text-white font-medium cursor-pointer hover:underline">Sign up</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
338
documentation/layout/profile.html
Normal file
338
documentation/layout/profile.html
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App Interface Variations</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Custom Range Slider Styling */
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-12 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 4: Personal Profile -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:44</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30 bg-[#09090b]/80 backdrop-blur-md">
|
||||
<a href="index.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Edit Profile</span>
|
||||
<button
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-20 no-scrollbar">
|
||||
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex flex-col items-center my-6">
|
||||
<div class="relative w-24 h-24 mb-3">
|
||||
<img src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?ixlib=rb-4.0.3&auto=format&fit=crop&w=300&q=80"
|
||||
class="w-full h-full rounded-full object-cover border-2 border-white/10">
|
||||
<button
|
||||
class="absolute bottom-0 right-0 w-8 h-8 bg-[#2563eb] rounded-full flex items-center justify-center border-2 border-[#09090b] text-white hover:bg-blue-600 transition-colors">
|
||||
<i data-lucide="camera" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-white">Change Photo</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Personal Info Group -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider">Personal Information</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">First Name</label>
|
||||
<input type="text" value="Alex"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Last Name</label>
|
||||
<input type="text" value="Morgan"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Username</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-2.5 text-white/40 text-sm">@</span>
|
||||
<input type="text" value="alexmorgan"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg pl-7 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Gender</label>
|
||||
<select
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors appearance-none">
|
||||
<option>Male</option>
|
||||
<option>Female</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Date of Birth</label>
|
||||
<input type="date" value="1995-08-15"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Introduction & Bio -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider">About</h3>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Short Introduction</label>
|
||||
<input type="text" value="Digital Nomad & Photographer"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Bio</label>
|
||||
<textarea
|
||||
class="w-full h-24 bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors resize-none leading-relaxed">Capturing moments across the globe. Lover of coffee, sunsets, and good design.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Contact & Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider">Contact & Links</h3>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Email</label>
|
||||
<div class="relative">
|
||||
<i data-lucide="mail" class="absolute left-3 top-2.5 w-4 h-4 text-white/40"></i>
|
||||
<input type="email" value="alex@vayv.app"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Website</label>
|
||||
<div class="relative">
|
||||
<i data-lucide="link" class="absolute left-3 top-2.5 w-4 h-4 text-white/40"></i>
|
||||
<input type="url" value="https://alexmorgan.com"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full my-6"></div>
|
||||
|
||||
<!-- App Settings -->
|
||||
<div class="px-1">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider mb-3">App Settings</h3>
|
||||
<div class="flex gap-3">
|
||||
<a href="settings.html"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded-2xl p-4 flex items-center justify-between hover:bg-white/10 transition-colors">
|
||||
<span class="text-sm font-medium text-white">Settings</span>
|
||||
<i data-lucide="settings" class="w-4 h-4 text-white/40"></i>
|
||||
</a>
|
||||
<a href="theme.html"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded-2xl p-4 flex items-center justify-between hover:bg-white/10 transition-colors">
|
||||
<span class="text-sm font-medium text-white">Theme</span>
|
||||
<i data-lucide="palette" class="w-4 h-4 text-white/40"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
339
documentation/layout/settings.html
Normal file
339
documentation/layout/settings.html
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Settings</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Custom Range Slider Styling */
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-12 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 5: Account Settings -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:45</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
<a href="profile.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Settings</span>
|
||||
<div class="w-10"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-12 no-scrollbar">
|
||||
|
||||
<!-- Search Settings -->
|
||||
<div class="relative mb-6 mt-2">
|
||||
<i data-lucide="search" class="absolute left-3 top-2.5 w-4 h-4 text-white/40"></i>
|
||||
<input type="text" placeholder="Search settings..."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/20 transition-all placeholder-white/20">
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="mb-6">
|
||||
<h3 class="px-2 mb-2 text-xs font-semibold text-white/40 uppercase tracking-wider">Account</h3>
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-blue-500/20 rounded-lg text-blue-400"><i data-lucide="mail" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Email</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-white/40">alex@vayv.app</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-green-500/20 rounded-lg text-green-400"><i data-lucide="lock" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Password</span>
|
||||
</div>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</button>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-purple-500/20 rounded-lg text-purple-400"><i data-lucide="shield-check"
|
||||
class="w-4 h-4"></i></div>
|
||||
<span class="text-sm font-medium text-white/90">Two-Factor Auth</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded font-medium border border-green-500/20">Enabled</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Section -->
|
||||
<div class="mb-6">
|
||||
<h3 class="px-2 mb-2 text-xs font-semibold text-white/40 uppercase tracking-wider">Billing & Plan</h3>
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-yellow-500/20 rounded-lg text-yellow-400"><i data-lucide="zap" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Plan</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] bg-white text-black px-2 py-0.5 rounded-full font-bold">PRO</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-gray-500/20 rounded-lg text-gray-400"><i data-lucide="credit-card"
|
||||
class="w-4 h-4"></i></div>
|
||||
<span class="text-sm font-medium text-white/90">Billing Method</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-white/40">•••• 4242</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences -->
|
||||
<div class="mb-8">
|
||||
<h3 class="px-2 mb-2 text-xs font-semibold text-white/40 uppercase tracking-wider">Preferences</h3>
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<div class="w-full px-4 py-3.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-pink-500/20 rounded-lg text-pink-400"><i data-lucide="bell" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Notifications</span>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 h-6 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="toggle" id="toggle"
|
||||
class="toggle-checkbox absolute block w-4 h-4 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-300 top-1 right-5 border-[#333]">
|
||||
<label for="toggle"
|
||||
class="toggle-label block overflow-hidden h-6 rounded-full bg-white/10 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<div class="w-full px-4 py-3.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-orange-500/20 rounded-lg text-orange-400"><i data-lucide="moon" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Dark Mode</span>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 h-6 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="toggle2" id="toggle2"
|
||||
class="toggle-checkbox absolute block w-4 h-4 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-300 right-0 border-[#2563eb]"
|
||||
checked="">
|
||||
<label for="toggle2"
|
||||
class="toggle-label block overflow-hidden h-6 rounded-full bg-[#2563eb] cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<button
|
||||
class="w-full bg-red-500/10 border border-red-500/20 rounded-2xl py-3.5 flex items-center justify-center gap-2 text-red-400 font-medium hover:bg-red-500/20 transition-colors">
|
||||
<i data-lucide="log-out" class="w-4 h-4"></i>
|
||||
Log Out
|
||||
</button>
|
||||
|
||||
<p class="text-center text-[10px] text-white/20 mt-6">Version 2.4.0 (Build 302)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
196
documentation/layout/signup.html
Normal file
196
documentation/layout/signup.html
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Sign Up</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<!-- Fonts included similarly to login.html -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 2: Registration -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span class="">9:42</span>
|
||||
<div class="flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="signal" class="lucide lucide-signal w-3.5 h-3.5">
|
||||
<path d="M2 20h.01"></path>
|
||||
<path d="M7 20v-4"></path>
|
||||
<path d="M12 20v-8"></path>
|
||||
<path d="M17 20V8"></path>
|
||||
<path d="M22 4v16"></path>
|
||||
</svg><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" data-lucide="wifi"
|
||||
class="lucide lucide-wifi w-3.5 h-3.5">
|
||||
<path d="M12 20h.01"></path>
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0"></path>
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0"></path>
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0"></path>
|
||||
</svg>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex z-30 pt-3 pr-4 pb-3 pl-4 items-center">
|
||||
<a href="login.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="arrow-left" class="lucide lucide-arrow-left w-6 h-6">
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
<path d="M19 12H5"></path>
|
||||
</svg></a>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-8 pb-8 no-scrollbar">
|
||||
<div class="mb-8">
|
||||
<!-- Added Logo based on image provided -->
|
||||
<img
|
||||
src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/32e790c1-060e-4820-bca4-b639b2d815b0_320w.png"
|
||||
class="w-20 h-auto mb-6 drop-shadow-lg object-cover" alt="VΛYV Logo">
|
||||
<h1 class="font-['Inter'] text-2xl tracking-tight font-semibold text-white mb-2">Create Account</h1>
|
||||
<p class="text-white/40 text-sm">Join VΛYV to start capturing your journey.</p>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-5">
|
||||
<!-- Names -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">First Name</label>
|
||||
<input type="text"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all font-medium placeholder-white/20">
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Last Name</label>
|
||||
<input type="text"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all font-medium placeholder-white/20">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username (Mandatory) -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Username <span class="text-blue-400">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><span
|
||||
class="text-white/40 text-sm font-medium">@</span></div>
|
||||
<input type="text" placeholder="username"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-8 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gender & DOB -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Gender</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-4 pr-8 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all appearance-none font-medium">
|
||||
<option>Select</option>
|
||||
<option>Male</option>
|
||||
<option>Female</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="chevron-down" class="lucide lucide-chevron-down w-4 h-4 text-white/40">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Date of Birth</label>
|
||||
<input type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all font-medium text-white/60">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email (Mandatory) -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Email <span class="text-blue-400">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="mail" class="lucide lucide-mail w-4 h-4 text-white/40">
|
||||
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||
</svg></div>
|
||||
<input type="email" placeholder="name@example.com"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password (Mandatory) -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Password <span class="text-blue-400">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="lock" class="lucide lucide-lock w-4 h-4 text-white/40">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg></div>
|
||||
<input type="password" placeholder="Create a strong password"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full bg-white text-black font-semibold py-3.5 rounded-xl mt-4 shadow-[0_0_20px_rgba(255,255,255,0.1)] active:scale-[0.98] transition-all hover:bg-gray-100 flex items-center justify-center gap-2">
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-white/40 leading-relaxed px-4">By creating an account, you agree to our <a href="#"
|
||||
class="text-white/60 underline">Terms of Service</a> and <a href="#" class="text-white/60 underline">Privacy
|
||||
Policy</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-xs text-white/40">Already have an account? <a href="login.html"
|
||||
class="text-white font-medium cursor-pointer hover:underline">Log in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
399
documentation/layout/theme.html
Normal file
399
documentation/layout/theme.html
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Theme Settings</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Custom Range Slider Styling */
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-12 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 6: Theme Settings -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:46</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
<a href="profile.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Theme</span>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="rotate-ccw" class="w-5 h-5"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-12 no-scrollbar">
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div
|
||||
class="w-full h-40 bg-gradient-to-b from-white/5 to-transparent border border-white/10 rounded-2xl mt-4 mb-8 flex flex-col items-center justify-center relative overflow-hidden group">
|
||||
<div
|
||||
class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-500 via-transparent to-transparent transition-colors duration-500">
|
||||
</div>
|
||||
<!-- Mini Chart Preview -->
|
||||
<svg viewBox="0 0 200 100"
|
||||
class="w-full h-full text-blue-500 fill-none px-4 pt-8 transition-colors duration-300">
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.5"></stop>
|
||||
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Main Line -->
|
||||
<path d="M0,80 C20,80 40,30 60,40 C80,50 100,80 120,60 C140,40 160,20 200,30" class="stroke-current stroke-2"
|
||||
style="filter: drop-shadow(0px 4px 6px rgba(59, 130, 246, 0.5));"></path>
|
||||
<!-- Secondary Lines for 'Count' preview -->
|
||||
<path d="M0,85 C20,85 40,35 60,45 C80,55 100,85 120,65 C140,45 160,25 200,35"
|
||||
class="stroke-current stroke-[1.5] opacity-50"></path>
|
||||
<path d="M0,90 C20,90 40,40 60,50 C80,60 100,90 120,70 C140,50 160,30 200,40"
|
||||
class="stroke-current stroke-1 opacity-20"></path>
|
||||
|
||||
<path d="M0,80 C20,80 40,30 60,40 C80,50 100,80 120,60 C140,40 160,20 200,30 V100 H0 Z"
|
||||
class="stroke-none fill-blue-500/10"></path>
|
||||
</svg>
|
||||
<div class="absolute bottom-3 left-4 text-[10px] font-medium text-white/40 uppercase tracking-widest">Preview
|
||||
</div>
|
||||
|
||||
<!-- Floating Thumbnail Preview -->
|
||||
<div
|
||||
class="absolute top-6 left-1/2 -translate-x-1/2 w-8 h-8 rounded-full bg-white shadow-lg overflow-hidden border border-white/20">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?ixlib=rb-4.0.3&auto=format&fit=crop&w=300&q=80"
|
||||
class="w-full h-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background Color Selection -->
|
||||
<div class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-4">
|
||||
<h3 class="text-xs font-semibold text-white/90 tracking-tight">Background Color</h3>
|
||||
<span class="text-[10px] text-white/40">10 presets</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-5 gap-3">
|
||||
<button
|
||||
class="group relative aspect-square rounded-xl bg-[#09090b] border-2 border-white/20 hover:border-white/40 transition-all ring-2 ring-white ring-offset-2 ring-offset-[#09090b] shadow-lg">
|
||||
<div class="absolute inset-0 flex items-center justify-center opacity-100 transition-opacity">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-white"></i>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#0f172a] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#171717] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#18181b] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#172554] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#1e1b4b] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#2e1065] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-gradient-to-br from-cyan-500 via-blue-500 to-emerald-500 border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-gradient-to-br from-rose-500 via-orange-500 to-amber-500 border border-white/10 hover:scale-105 transition-all"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wave & Appearance Settings -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold text-white/90 tracking-tight">Wave & Appearance</h3>
|
||||
<button class="text-[10px] text-blue-400 font-medium hover:text-blue-300">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl p-5 space-y-6">
|
||||
|
||||
<!-- Wave Color Selection -->
|
||||
<div>
|
||||
<label class="text-xs font-medium text-white/60 block mb-3">Wave Color</label>
|
||||
<div class="grid grid-cols-10 gap-2">
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-blue-500 ring-2 ring-white ring-offset-2 ring-offset-[#09090b] cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-purple-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-emerald-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-rose-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-white hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-amber-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-cyan-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-lime-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-fuchsia-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-orange-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Thumbnail Image Radius -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="text-xs font-medium text-white/60">Thumbnail Radius</label>
|
||||
<span class="text-xs font-mono text-white">50%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<i data-lucide="square" class="w-4 h-4 text-white/20"></i>
|
||||
<div class="relative w-full h-6 flex items-center">
|
||||
<input type="range" min="0" max="100" value="50"
|
||||
class="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer z-20">
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-blue-500 rounded-l-lg pointer-events-none"
|
||||
style="width: 50%"></div>
|
||||
</div>
|
||||
<i data-lucide="circle" class="w-4 h-4 text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Curvyness -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="text-xs font-medium text-white/60">Curvyness</label>
|
||||
<span class="text-xs font-mono text-white">75%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<i data-lucide="activity" class="w-4 h-4 text-white/20"></i>
|
||||
<div class="relative w-full h-6 flex items-center">
|
||||
<input type="range" min="0" max="100" value="75"
|
||||
class="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer z-20">
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-blue-500 rounded-l-lg pointer-events-none"
|
||||
style="width: 75%"></div>
|
||||
</div>
|
||||
<i data-lucide="waves" class="w-4 h-4 text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line Count -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="text-xs font-medium text-white/60">Count of Lines</label>
|
||||
<span class="text-xs font-mono text-white">3</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-medium text-white/20 w-4 text-center">1</span>
|
||||
<div class="relative w-full h-6 flex items-center">
|
||||
<input type="range" min="1" max="10" value="3"
|
||||
class="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer z-20">
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-blue-500 rounded-l-lg pointer-events-none"
|
||||
style="width: 30%"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white/80 w-4 text-center">10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show Grid Lines Toggle -->
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-white/5 rounded-lg text-white/80"><i data-lucide="grid" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Show Grid Lines</span>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 h-6 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="toggle3" id="toggle3"
|
||||
class="toggle-checkbox absolute block w-4 h-4 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-300 top-1 right-5 border-[#333]">
|
||||
<label for="toggle3"
|
||||
class="toggle-label block overflow-hidden h-6 rounded-full bg-white/10 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
24
dot-line-system/.gitignore
vendored
Normal file
24
dot-line-system/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
86
dot-line-system/.history/index_20250515080205.html
Normal file
86
dot-line-system/.history/index_20250515080205.html
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 6px 12px;
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visualization-container {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.spacer {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="spacer"></div>
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
86
dot-line-system/.history/index_20250515093839.html
Normal file
86
dot-line-system/.history/index_20250515093839.html
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 6px 12px;
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visualization-container {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.spacer {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
125
dot-line-system/.history/index_20250522083017.html
Normal file
125
dot-line-system/.history/index_20250522083017.html
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 6px 12px;
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visualization-container {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.scroll-container:active {
|
||||
cursor: grabbing; /* Change cursor when active */
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
<script>
|
||||
const scrollContainer = document.querySelector('.scroll-container');
|
||||
|
||||
let isDown = false;
|
||||
let startX;
|
||||
let scrollLeft;
|
||||
|
||||
scrollContainer.addEventListener('mousedown', (e) => {
|
||||
isDown = true;
|
||||
scrollContainer.classList.add('active');
|
||||
startX = e.pageX - scrollContainer.offsetLeft;
|
||||
scrollLeft = scrollContainer.scrollLeft;
|
||||
});
|
||||
|
||||
scrollContainer.addEventListener('mouseleave', () => {
|
||||
isDown = false;
|
||||
scrollContainer.classList.remove('active');
|
||||
});
|
||||
|
||||
scrollContainer.addEventListener('mouseup', () => {
|
||||
isDown = false;
|
||||
scrollContainer.classList.remove('active');
|
||||
});
|
||||
|
||||
scrollContainer.addEventListener('mousemove', (e) => {
|
||||
if(!isDown) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - scrollContainer.offsetLeft;
|
||||
const walk = (x - startX) * 3; // Multiply by a number to speed up scrolling
|
||||
scrollContainer.scrollLeft = scrollLeft - walk;
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
92
dot-line-system/.history/index_20250522083042.html
Normal file
92
dot-line-system/.history/index_20250522083042.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 6px 12px;
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visualization-container {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.scroll-container:active {
|
||||
cursor: grabbing; /* Change cursor when active */
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
93
dot-line-system/.history/index_20250522083155.html
Normal file
93
dot-line-system/.history/index_20250522083155.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 6px 12px;
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visualization-container {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
background: linear-gradient(to bottom, #4f46e5, #60a5fa, #4ade80);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.scroll-container:active {
|
||||
cursor: grabbing; /* Change cursor when active */
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
28
dot-line-system/.history/index_20250522085953.html
Normal file
28
dot-line-system/.history/index_20250522085953.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
28
dot-line-system/.history/index_20250522090004.html
Normal file
28
dot-line-system/.history/index_20250522090004.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<style>
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522090012.html
Normal file
25
dot-line-system/.history/index_20250522090012.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
dot-line-system/.history/index_20250522090611.html
Normal file
26
dot-line-system/.history/index_20250522090611.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
dot-line-system/.history/index_20250522090656.html
Normal file
26
dot-line-system/.history/index_20250522090656.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
27
dot-line-system/.history/index_20250522090717.html
Normal file
27
dot-line-system/.history/index_20250522090717.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container">
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
dot-line-system/.history/index_20250522090730.html
Normal file
26
dot-line-system/.history/index_20250522090730.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
dot-line-system/.history/index_20250522095109.html
Normal file
29
dot-line-system/.history/index_20250522095109.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
dot-line-system/.history/index_20250522114322.html
Normal file
29
dot-line-system/.history/index_20250522114322.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
30
dot-line-system/.history/index_20250522114359.html
Normal file
30
dot-line-system/.history/index_20250522114359.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="dynamic-gradient-bg"></div>
|
||||
<!-- <div class="gradient-bg"></div> -->
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
dot-line-system/.history/index_20250522114425.html
Normal file
29
dot-line-system/.history/index_20250522114425.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
dot-line-system/.history/index_20250522114819.html
Normal file
29
dot-line-system/.history/index_20250522114819.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
dot-line-system/.history/index_20250522130240.html
Normal file
29
dot-line-system/.history/index_20250522130240.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
dot-line-system/.history/index_20250522130258.html
Normal file
29
dot-line-system/.history/index_20250522130258.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
dot-line-system/.history/index_20250522130455.html
Normal file
29
dot-line-system/.history/index_20250522130455.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- <div class="spacer"></div> -->
|
||||
<!-- Controls removed as requested -->
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="spacer"></div> -->
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522130505.html
Normal file
25
dot-line-system/.history/index_20250522130505.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522130518.html
Normal file
25
dot-line-system/.history/index_20250522130518.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522130539.html
Normal file
25
dot-line-system/.history/index_20250522130539.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522130613.html
Normal file
25
dot-line-system/.history/index_20250522130613.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522132851.html
Normal file
25
dot-line-system/.history/index_20250522132851.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522133036.html
Normal file
25
dot-line-system/.history/index_20250522133036.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522222202.html
Normal file
25
dot-line-system/.history/index_20250522222202.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Connected Dots Visualization</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
dot-line-system/.history/index_20250522222233.html
Normal file
25
dot-line-system/.history/index_20250522222233.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Life Line</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../src/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
15
dot-line-system/.history/package_20250515093313.json
Normal file
15
dot-line-system/.history/package_20250515093313.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "dot-line-system",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
20
dot-line-system/.history/package_20250515093412.json
Normal file
20
dot-line-system/.history/package_20250515093412.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "dot-line-system",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"tsc": "tsc"
|
||||
}
|
||||
}
|
||||
20
dot-line-system/.history/package_20250515093415.json
Normal file
20
dot-line-system/.history/package_20250515093415.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "dot-line-system",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"tsc": "tsc"
|
||||
}
|
||||
}
|
||||
0
dot-line-system/.history/readme_20250521221324.md
Normal file
0
dot-line-system/.history/readme_20250521221324.md
Normal file
1
dot-line-system/.history/readme_20250521221329.md
Normal file
1
dot-line-system/.history/readme_20250521221329.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
npm run dev
|
||||
2
dot-line-system/.history/readme_20250521221343.md
Normal file
2
dot-line-system/.history/readme_20250521221343.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*Start the project*
|
||||
npm run dev
|
||||
3
dot-line-system/.history/readme_20250521221558.md
Normal file
3
dot-line-system/.history/readme_20250521221558.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
**Start the project**
|
||||
|
||||
npm run dev
|
||||
2
dot-line-system/.history/readme_20250521221603.md
Normal file
2
dot-line-system/.history/readme_20250521221603.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
**Start the project**
|
||||
npm run dev
|
||||
4
dot-line-system/.history/readme_20250521221932.md
Normal file
4
dot-line-system/.history/readme_20250521221932.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
**Start the project**
|
||||
npm install
|
||||
|
||||
npm run
|
||||
7
dot-line-system/.history/readme_20250522081835.md
Normal file
7
dot-line-system/.history/readme_20250522081835.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
**Prepare the project**
|
||||
npm install
|
||||
|
||||
**Start the project**
|
||||
npm start
|
||||
|
||||
http://localhost:5173/
|
||||
8
dot-line-system/.history/readme_20250522081843.md
Normal file
8
dot-line-system/.history/readme_20250522081843.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
**Prepare the project**
|
||||
npm install
|
||||
|
||||
**Start the project**
|
||||
npm start
|
||||
|
||||
**Aufrufen**
|
||||
http://localhost:5173/
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 120;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 200,
|
||||
tooltipHeight: 150,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 150; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 200;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 200,
|
||||
tooltipHeight: 150,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 150; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 160;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 200,
|
||||
tooltipHeight: 150,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 150; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 160;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 10) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 200,
|
||||
tooltipHeight: 150,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 150; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 160;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 200,
|
||||
tooltipHeight: 150,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 150; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 120;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 200,
|
||||
tooltipHeight: 150,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 150; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 120;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 128,
|
||||
tooltipHeight: 128,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 150; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 120;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 128,
|
||||
tooltipHeight: 128,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 250; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 120;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 128,
|
||||
tooltipHeight: 128,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 300; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 120;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 128,
|
||||
tooltipHeight: 128,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 260; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
imgContainer.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
imgContainer.setAttribute('height', (tooltipHeight / 2).toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
|
||||
constructor(containerId: string, dots: DotConfig[], config?: Partial<Config>) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 120;
|
||||
let calculatedWidth = 0;
|
||||
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 128,
|
||||
tooltipHeight: 128,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.curvePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.dotsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
this.tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter(dot => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map(dot => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = {current: 0, total: imageUrls.length};
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log('All images preloaded successfully');
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
};
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = 'connected-dots-styles';
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dot:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1; /* Always visible */
|
||||
}
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.svg.style.overflow = 'visible';
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add('grid');
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute('fill', 'none');
|
||||
this.curvePath.setAttribute('stroke', 'white');
|
||||
this.curvePath.setAttribute('stroke-width', '2');
|
||||
this.curvePath.setAttribute('stroke-linecap', 'round');
|
||||
this.curvePath.classList.add('curve-path');
|
||||
this.svg.appendChild(this.curvePath);
|
||||
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add('tooltips');
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 2;
|
||||
// Calculate raw Y position
|
||||
const rawY = centerY - (value / 3) * (this.config.height / 2 * 0.8);
|
||||
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 30; // tooltip height + some padding
|
||||
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
|
||||
private calculateBezierControlPoints(dots: DotConfig[], index: number): ControlPoints {
|
||||
const tension = this.config.tension * 260; // Scale tension for Bezier curve
|
||||
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return '';
|
||||
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(this.dots[0].value)}`;
|
||||
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(this.dots, i);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
|
||||
if (!this.config.showGrid) return;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', this.getDotY(value).toString());
|
||||
line.setAttribute('x2', this.config.totalWidth.toString());
|
||||
line.setAttribute('y2', this.getDotY(value).toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '10');
|
||||
text.setAttribute('y', (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(this.config.totalWidth / this.config.xUnitSize);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x.toString());
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', x.toString());
|
||||
line.setAttribute('y2', this.config.height.toString());
|
||||
line.setAttribute('stroke', 'rgba(219, 39, 119, 0.4)');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
this.gridGroup.appendChild(line);
|
||||
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute('fill', 'rgba(219, 39, 119, 0.8)');
|
||||
text.setAttribute('font-size', '12');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
tooltip.classList.add('dot-tooltip');
|
||||
tooltip.setAttribute('data-dot-id', dot.id.toString());
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipHeight = this.config.tooltipHeight;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
// Calculate tooltip Y position, ensuring it stays within the container
|
||||
let tooltipY = y - tooltipHeight - 20; // Position above the dot with some spacing
|
||||
|
||||
// Ensure tooltip doesn't go above the container
|
||||
tooltipY = Math.max(tooltipY, 10); // Keep at least 10px from the top
|
||||
|
||||
// Background rectangle
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bg.setAttribute('x', tooltipX.toString());
|
||||
bg.setAttribute('y', tooltipY.toString());
|
||||
bg.setAttribute('width', tooltipWidth.toString());
|
||||
bg.setAttribute('height', tooltipHeight.toString());
|
||||
bg.setAttribute('rx', '5');
|
||||
bg.setAttribute('ry', '5');
|
||||
bg.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Tooltip arrow (pointing to the dot)
|
||||
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
arrow.setAttribute('d', `M ${x} ${tooltipY + tooltipHeight} L ${x - 10} ${tooltipY + tooltipHeight - 10} L ${x + 10} ${tooltipY + tooltipHeight - 10} Z`);
|
||||
arrow.setAttribute('fill', 'rgba(0, 0, 0, 0.8)');
|
||||
tooltip.appendChild(arrow);
|
||||
|
||||
// Image (if provided)
|
||||
// Image (if provided)
|
||||
if (dot.imageUrl) {
|
||||
const imgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
imgContainer.setAttribute('x', (tooltipX + 10).toString());
|
||||
imgContainer.setAttribute('y', (tooltipY + 10).toString());
|
||||
|
||||
// Set width and height to the same value for a square aspect
|
||||
const imageSize = Math.min((tooltipWidth - 20), (tooltipHeight / 2));
|
||||
imgContainer.setAttribute('width', imageSize.toString());
|
||||
imgContainer.setAttribute('height', imageSize.toString());
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dot.imageUrl;
|
||||
img.className = 'tooltip-img';
|
||||
img.style.width = '100%';
|
||||
img.style.height = '100%';
|
||||
img.style.objectFit = 'cover'; // Ensure the image covers the space while maintaining aspect ratio
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
tooltip.appendChild(imgContainer);
|
||||
}
|
||||
|
||||
|
||||
// Title (if provided)
|
||||
if (dot.title) {
|
||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
title.setAttribute('x', (tooltipX + 10).toString());
|
||||
title.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 26).toString()
|
||||
: (tooltipY + 25).toString());
|
||||
title.setAttribute('fill', 'white');
|
||||
title.setAttribute('font-size', '14');
|
||||
title.setAttribute('font-weight', 'bold');
|
||||
title.textContent = dot.title;
|
||||
tooltip.appendChild(title);
|
||||
}
|
||||
|
||||
// Description (if provided)
|
||||
if (dot.description) {
|
||||
const descriptionFO = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
||||
descriptionFO.setAttribute('x', (tooltipX + 10).toString());
|
||||
descriptionFO.setAttribute('y', dot.imageUrl
|
||||
? (tooltipY + tooltipHeight / 2 + 32).toString()
|
||||
: dot.title
|
||||
? (tooltipY + 35).toString()
|
||||
: (tooltipY + 15).toString());
|
||||
descriptionFO.setAttribute('width', (tooltipWidth - 20).toString());
|
||||
descriptionFO.setAttribute('height', (tooltipHeight / 2 - 10).toString());
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.style.color = 'white';
|
||||
descriptionDiv.style.fontSize = '12px';
|
||||
descriptionDiv.style.overflow = 'hidden';
|
||||
descriptionDiv.textContent = dot.description;
|
||||
|
||||
descriptionFO.appendChild(descriptionDiv);
|
||||
tooltip.appendChild(descriptionFO);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute('d', pathData);
|
||||
}
|
||||
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', x.toString());
|
||||
circle.setAttribute('cy', y.toString());
|
||||
circle.setAttribute('r', this.config.dotRadius.toString());
|
||||
circle.setAttribute('fill', 'white');
|
||||
circle.setAttribute('data-dot-id', dot.id.toString());
|
||||
circle.classList.add('dot');
|
||||
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener('click', () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error('Dot has no link');
|
||||
throw new Error('Dot has no link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.dotsGroup.appendChild(circle);
|
||||
};
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute('width', `${this.config.totalWidth}`);
|
||||
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map(dot => dot.x));
|
||||
const maxX = Math.max(...this.dots.map(dot => dot.x));
|
||||
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute('height', `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue