Compare commits

...

10 commits

Author SHA1 Message Date
d054732bf5 30-04-2026 2026-04-30 14:54:39 +02:00
761b1156c1 10-04-2026 2026-04-22 12:57:10 +02:00
70a7776da5 25-02-2025 2026-03-06 14:01:49 +01:00
98084de7d0 20-02-2026 2026-03-06 13:56:20 +01:00
c62234e1ca docker setup
# Conflicts:
#	.gitignore
#	backend/vite.config.js
#	frontend/package-lock.json
2026-03-06 13:46:43 +01:00
Tilman Behrend
f01a0a967f add splitted layouts 2026-02-27 07:30:34 +01:00
Tilman Behrend
9fe2bcade3 update sample entry 2025-09-24 12:58:48 +02:00
Tilman Behrend
d598751598 add video poster and audio sample, alter deploment 2025-09-24 11:54:33 +02:00
Tilman Behrend
f1d1d3f96d add poster to videos 2025-09-24 10:27:30 +02:00
Tilman Behrend
8a9719ab9f add deployment for shared hosting without db 2025-09-22 13:21:36 +02:00
733 changed files with 174472 additions and 2273 deletions

207
.devcontainer/README.md Normal file
View 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

View 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
View file

@ -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
View 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
View 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
View 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
View 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
```

View file

@ -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
View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

269
backend/AGENTS.md Normal file
View 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
View 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>

View 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]);
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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(),
];
}
}

View 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);
}
}

View file

@ -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
View 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"
]
}

View file

@ -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',
)

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,10 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
],
/*

View 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'),
];

View 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(),
];
}
}

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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",

View file

@ -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
View 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']);
});

View 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);
});

View file

@ -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
View 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
View 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

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;auto=format&amp;fit=crop&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=100&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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&amp;w=400&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;w=100&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;w=100&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;w=600&amp;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&amp;w=600&amp;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&amp;w=600&amp;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&amp;w=200&amp;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&amp;w=200&amp;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&amp;w=150&amp;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&amp;w=150&amp;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&amp;w=150&amp;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&amp;w=100&amp;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 &amp; 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>

View 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&amp;family=Inter:wght@300;400;500;600;700&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;auto=format&amp;fit=crop&amp;w=150&amp;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&amp;auto=format&amp;fit=crop&amp;w=300&amp;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&amp;auto=format&amp;fit=crop&amp;w=150&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;auto=format&amp;fit=crop&amp;w=300&amp;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 &amp; 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 &amp; 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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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 &amp; 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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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>

View 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&amp;family=Inter:wght@300;400;500;600&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;auto=format&amp;fit=crop&amp;w=300&amp;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 &amp; 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
View 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?

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"
}
}

View 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"
}
}

View 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"
}
}

View file

@ -0,0 +1 @@
npm run dev

View file

@ -0,0 +1,2 @@
*Start the project*
npm run dev

View file

@ -0,0 +1,3 @@
**Start the project**
npm run dev

View file

@ -0,0 +1,2 @@
**Start the project**
npm run dev

View file

@ -0,0 +1,4 @@
**Start the project**
npm install
npm run

View file

@ -0,0 +1,7 @@
**Prepare the project**
npm install
**Start the project**
npm start
http://localhost:5173/

View file

@ -0,0 +1,8 @@
**Prepare the project**
npm install
**Start the project**
npm start
**Aufrufen**
http://localhost:5173/

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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