Compare commits
5 commits
main
...
wip/stash-
| Author | SHA1 | Date | |
|---|---|---|---|
| 965e2ac6ad | |||
| 5e7133cbfe | |||
| 0cb1db6aca | |||
| 5cd1d1a870 | |||
| 8491d3e454 |
|
|
@ -1,12 +1,24 @@
|
|||
{
|
||||
"name": "Thats-Me (Dev Container)",
|
||||
// 1. DIES IST DER WICHTIGSTE TEIL:
|
||||
// Wir verwenden Docker Compose für alle Services
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.yml"
|
||||
],
|
||||
"service": "laravel.test",
|
||||
// 3. WIR DEFINIEREN DEN ARBEITSBEREICH:
|
||||
// Wir mounten das gesamte Projekt, damit Sie Backend UND Frontend sehen
|
||||
"workspaceFolder": "/workspace",
|
||||
// 4. WIR LEGEN DEN BENUTZER FEST:
|
||||
// Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden.
|
||||
"remoteUser": "sail",
|
||||
// 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES):
|
||||
// Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden
|
||||
"features": {},
|
||||
// 6. BEFEHLE NACH DEM ERSTELLEN:
|
||||
// Installiert nur die Tools die ohne Root-Rechte funktionieren
|
||||
//"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader",
|
||||
// 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen):
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
|
@ -22,26 +34,65 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
// 8. ZU STARTENDE DIENSTE:
|
||||
// Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen.
|
||||
"runServices": [
|
||||
"laravel.test",
|
||||
"quasar.app"
|
||||
"quasar.app",
|
||||
"mysql",
|
||||
"redis",
|
||||
"mailpit"
|
||||
],
|
||||
// 9. ZUSÄTZLICHE KONFIGURATION:
|
||||
// Umgebungsvariablen für den DevContainer
|
||||
"containerEnv": {
|
||||
"WWWUSER": "501",
|
||||
"WWWGROUP": "20",
|
||||
"LARAVEL_SAIL": "1"
|
||||
},
|
||||
// 9b. MOUNTS:
|
||||
// Mountet das gesamte Projekt (Root) nach /workspace, damit Sie Backend UND Frontend sehen
|
||||
"mounts": [
|
||||
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
|
||||
],
|
||||
// 10. FORWARD PORTS:
|
||||
// Ports die automatisch weitergeleitet werden sollen (Container-Ports)
|
||||
"forwardPorts": [
|
||||
80,
|
||||
5173,
|
||||
3306,
|
||||
6379,
|
||||
1025,
|
||||
8025,
|
||||
9000
|
||||
],
|
||||
"portsAttributes": {
|
||||
"5173": {
|
||||
"label": "Vite Dev Server (Backend)",
|
||||
"80": {
|
||||
"label": "Laravel App (HTTP)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"5173": {
|
||||
"label": "Vite Dev Server",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3306": {
|
||||
"label": "MySQL",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"6379": {
|
||||
"label": "Redis",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"8025": {
|
||||
"label": "Mailpit Dashboard",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"1025": {
|
||||
"label": "Mailpit SMTP",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"9000": {
|
||||
"label": "Quasar App (Frontend)",
|
||||
"label": "Quasar App",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
.github/copilot-instructions.md
vendored
|
|
@ -1,109 +0,0 @@
|
|||
# Copilot Instructions for "That's Me" Project
|
||||
|
||||
## Project Architecture
|
||||
|
||||
This is a dual-stack application with separate **Laravel backend** and **Quasar frontend**:
|
||||
|
||||
- **Backend** (`/backend/`): Laravel 12 with Livewire/Volt for server-side rendered admin panels
|
||||
- **Frontend** (`/frontend/`): Quasar/Vue 3 SPA with anime.js for "LifeWave" visualizations
|
||||
- **Core Feature**: Interactive wave visualization showing life events as animated points
|
||||
|
||||
## Key Technology Patterns
|
||||
|
||||
### Backend (Laravel/Livewire/Volt)
|
||||
- **Livewire Volt**: Single-file components in `resources/views/livewire/` with PHP class logic at top
|
||||
- **Flux UI**: Use `flux:` prefixed components (`flux:input`, `flux:button`, `flux:modal`) instead of raw HTML
|
||||
- **Layout Pattern**: Nested layouts via `x-layouts.app` → `x-layouts.app.sidebar` for main app UI
|
||||
- **Auth**: Uses Laravel Breeze with Volt components (`auth.login`, `auth.register`, etc.)
|
||||
- **Routing**: Volt routes defined in `routes/web.php` as `Volt::route('path', 'component.name')`
|
||||
|
||||
### Frontend (Quasar/Vue)
|
||||
- **Animation Core**: Uses anime.js for wave animations (see `/test/` prototypes)
|
||||
- **State Management**: Pinia for Vue state
|
||||
- **Build Tool**: Vite with Quasar CLI
|
||||
- **Components**: Lives in `frontend/src/pages/` and `frontend/src/components/`
|
||||
|
||||
### Development Setup
|
||||
- **Backend Dev**: `cd backend && php artisan serve` + `npm run dev` (Vite for assets)
|
||||
- **Frontend Dev**: `cd frontend && quasar dev`
|
||||
- **HTTPS Config**: Backend Vite configured for MAMP SSL certificates
|
||||
- **Testing**: Pest for backend tests (`php artisan test`)
|
||||
|
||||
## File Structure Conventions
|
||||
|
||||
### Backend Key Files
|
||||
- `routes/web.php`: Main routing with Volt component mappings
|
||||
- `resources/views/livewire/`: Volt single-file components
|
||||
- `resources/views/components/layouts/`: Layout components
|
||||
- `app/Providers/VoltServiceProvider.php`: Volt mount configuration
|
||||
- `resources/css/app.css`: Imports Tailwind + Flux UI
|
||||
|
||||
### Frontend Key Files
|
||||
- `src/pages/WavePage.vue`: Main life visualization component
|
||||
- `src/utils/ConnectedDotsVisualization.js`: Wave animation utilities
|
||||
- `quasar.config.js`: Build configuration
|
||||
- `/test/anime-*.html`: Animation prototypes and examples
|
||||
|
||||
## Specific Coding Patterns
|
||||
|
||||
### Livewire/Volt Components
|
||||
```php
|
||||
<?php
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
||||
new #[Layout('components.layouts.auth')] class extends Component {
|
||||
public string $email = '';
|
||||
|
||||
public function someMethod(): void {
|
||||
// Component logic
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<!-- Blade template with Flux UI -->
|
||||
<flux:input wire:model="email" :label="__('Email')" />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Wave Animation Pattern
|
||||
The project centers around animated life event visualizations:
|
||||
- Life events have `value` (emotional weight), `x` (timeline position), `imageUrl`, `title`
|
||||
- Uses anime.js for smooth wave animations with multiple sine wave layers
|
||||
- See `test/anime-points-animation.html` for complete implementation patterns
|
||||
|
||||
### Flux UI Usage
|
||||
- Always use `flux:` components: `flux:button`, `flux:input`, `flux:modal`, `flux:navlist`
|
||||
- Layout components: `flux:header`, `flux:sidebar`, `flux:main`
|
||||
- Include `@fluxScripts` in layout files
|
||||
- Wire navigation: `wire:navigate` for SPA-like navigation
|
||||
|
||||
## Development Workflows
|
||||
|
||||
- **Backend Changes**: Run `npm run dev` in `/backend/` for asset hot reload
|
||||
- **Component Testing**: Use Pest with Volt test helpers: `Volt::test('component.name')`
|
||||
- **Styling**: Tailwind + Flux UI (no custom CSS files needed)
|
||||
- **Animation Prototyping**: Create in `/test/` directory first, then integrate
|
||||
|
||||
## Project-Specific Guidelines
|
||||
|
||||
1. **Animation First**: Wave visualizations are core - always consider animation performance
|
||||
2. **Offline Support**: Frontend designed for PWA with offline capabilities
|
||||
3. **Dual Development**: Backend and frontend are separate apps - coordinate API contracts
|
||||
4. **Component Isolation**: Livewire components should be self-contained with embedded logic
|
||||
5. **German Language**: UI text often in German (`__('German text')` for translations)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && composer install && php artisan serve
|
||||
cd backend && npm run dev # Asset compilation
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm install && quasar dev
|
||||
cd frontend && quasar build # Production build
|
||||
|
||||
# Testing
|
||||
cd backend && php artisan test
|
||||
```
|
||||
2
.gitignore
vendored
|
|
@ -55,4 +55,4 @@ Icon
|
|||
# Project specific
|
||||
_static/
|
||||
_work/
|
||||
_storage/
|
||||
_storage/
|
||||
27
.vscode/settings.json
vendored
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#ff62b9",
|
||||
"activityBar.background": "#ff62b9",
|
||||
"activityBar.foreground": "#15202b",
|
||||
"activityBar.inactiveForeground": "#15202b99",
|
||||
"activityBarBadge.background": "#8dff00",
|
||||
"activityBarBadge.foreground": "#15202b",
|
||||
"commandCenter.border": "#e7e7e799",
|
||||
"sash.hoverBorder": "#ff62b9",
|
||||
"statusBar.background": "#ff2fa2",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"statusBarItem.hoverBackground": "#ff62b9",
|
||||
"statusBarItem.remoteBackground": "#ff2fa2",
|
||||
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||
"titleBar.activeBackground": "#ff2fa2",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveBackground": "#ff2fa299",
|
||||
"titleBar.inactiveForeground": "#e7e7e799"
|
||||
},
|
||||
"peacock.color": "#ff2fa2",
|
||||
"cSpell.words": [
|
||||
"audiotrack",
|
||||
"Nominatim",
|
||||
"videocam"
|
||||
]
|
||||
}
|
||||
|
|
@ -2,12 +2,6 @@
|
|||
|
||||
Eine kurze Beschreibung, was dieses Projekt macht (ein oder zwei Sätze).
|
||||
|
||||
## Version
|
||||
|
||||
Aktuelle Projektversion: `0.0.1`
|
||||
|
||||
Die Frontend-App-Version wird in `frontend/src/config/appVersion.js` gepflegt. Bei weiteren Entwicklungsschritten muessen `frontend/package.json`, Dokumentation und relevante Entwicklungsplaene synchron gehalten werden.
|
||||
|
||||
## Domains auf dem Testserver
|
||||
|
||||
app.thats-me.test = frontend Quasar APP
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = User::query()
|
||||
->where('email', $credentials['email'])
|
||||
->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
$token = $user->createToken('quasar-app')->accessToken;
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'tokenType' => 'Bearer',
|
||||
'user' => [
|
||||
'id' => (string) $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'avatar' => $this->avatarFor($user),
|
||||
'mode' => 'remote',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()?->token()?->revoke();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
private function avatarFor(User $user): string
|
||||
{
|
||||
return substr(strtoupper($user->initials()), 0, 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ class EventController extends Controller
|
|||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$query = $request->user()->events()->with('media')->orderBy('date');
|
||||
$query = $request->user()->events()->orderBy('date');
|
||||
|
||||
// Delta sync: only events updated since a given timestamp
|
||||
if ($request->has('since')) {
|
||||
|
|
@ -50,7 +50,7 @@ class EventController extends Controller
|
|||
'note' => $request->validated('note'),
|
||||
]);
|
||||
|
||||
return (new EventResource($event->load('media')))
|
||||
return (new EventResource($event))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ class EventController extends Controller
|
|||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
return new EventResource($event->load('media'));
|
||||
return new EventResource($event);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -103,7 +103,7 @@ class EventController extends Controller
|
|||
|
||||
$event->update($data);
|
||||
|
||||
return new EventResource($event->fresh()->load('media'));
|
||||
return new EventResource($event->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEventMediaRequest;
|
||||
use App\Http\Resources\EventMediaResource;
|
||||
use App\Models\EventMedia;
|
||||
use App\Services\EventMediaImageProcessor;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class EventMediaController extends Controller
|
||||
{
|
||||
public function index(Request $request, string $clientId): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
return response()->json([
|
||||
'data' => EventMediaResource::collection(
|
||||
$event->media()->orderBy('created_at')->get()
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreEventMediaRequest $request,
|
||||
EventMediaImageProcessor $processor,
|
||||
string $clientId
|
||||
): JsonResponse {
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$collection = $request->validated('collection') ?? 'gallery';
|
||||
$processed = $processor->process($request->file('file'));
|
||||
$uuid = (string) str()->uuid();
|
||||
$directory = "event-media/{$request->user()->id}/{$event->client_id}";
|
||||
$path = "{$directory}/{$uuid}.jpg";
|
||||
$previewPath = "{$directory}/{$uuid}_preview.jpg";
|
||||
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
|
||||
|
||||
Storage::disk('local')->put($path, $processed['original']);
|
||||
Storage::disk('local')->put($previewPath, $processed['preview']);
|
||||
Storage::disk('local')->put($thumbnailPath, $processed['thumbnail']);
|
||||
|
||||
if ($collection === 'key_image') {
|
||||
$this->deleteExistingKeyImages($event->media()->where('collection', 'key_image')->get());
|
||||
}
|
||||
|
||||
$media = $event->media()->create([
|
||||
'uuid' => $uuid,
|
||||
'user_id' => $request->user()->id,
|
||||
'collection' => $collection,
|
||||
'name' => $request->file('file')->getClientOriginalName(),
|
||||
'mime_type' => $processed['mime_type'],
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'thumbnail_path' => $thumbnailPath,
|
||||
'preview_path' => $previewPath,
|
||||
'size' => $processed['size'],
|
||||
'width' => $processed['width'],
|
||||
'height' => $processed['height'],
|
||||
'thumbnail_width' => $processed['thumbnail_width'],
|
||||
'thumbnail_height' => $processed['thumbnail_height'],
|
||||
'preview_width' => $processed['preview_width'],
|
||||
'preview_height' => $processed['preview_height'],
|
||||
]);
|
||||
|
||||
if ($collection === 'key_image') {
|
||||
$event->update(['image' => "/event-media/{$media->id}/thumb"]);
|
||||
}
|
||||
|
||||
return (new EventMediaResource($media))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, EventMedia $media, string $variant): Response
|
||||
{
|
||||
abort_unless(in_array($variant, ['thumb', 'preview', 'original'], true), 404);
|
||||
abort_unless((int) $media->user_id === (int) $request->user()->id, 404);
|
||||
|
||||
$path = match ($variant) {
|
||||
'thumb' => $media->thumbnail_path,
|
||||
'preview' => $media->preview_path ?: $media->thumbnail_path,
|
||||
default => $media->path,
|
||||
};
|
||||
|
||||
abort_unless(is_string($path) && $path !== '', 404);
|
||||
abort_unless(Storage::disk($media->disk)->exists($path), 404);
|
||||
|
||||
return response(Storage::disk($media->disk)->get($path), 200, [
|
||||
'Content-Type' => 'image/jpeg',
|
||||
'Cache-Control' => 'private, max-age=604800',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $clientId, EventMedia $media): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless((int) $media->event_id === (int) $event->id, 404);
|
||||
|
||||
$wasKeyImage = $media->collection === 'key_image';
|
||||
$this->deleteMediaFiles($media);
|
||||
$media->delete();
|
||||
|
||||
if ($wasKeyImage) {
|
||||
$event->update(['image' => null]);
|
||||
}
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
private function deleteExistingKeyImages(iterable $mediaItems): void
|
||||
{
|
||||
foreach ($mediaItems as $media) {
|
||||
$this->deleteMediaFiles($media);
|
||||
$media->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteMediaFiles(EventMedia $media): void
|
||||
{
|
||||
Storage::disk($media->disk)->delete(array_filter([
|
||||
$media->path,
|
||||
$media->preview_path,
|
||||
$media->thumbnail_path,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UpdateSettingsRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $request->user()->settings?->settings ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateSettingsRequest $request): JsonResponse
|
||||
{
|
||||
$settings = $request->validated('settings');
|
||||
|
||||
$userSettings = $request->user()->settings()->updateOrCreate(
|
||||
[],
|
||||
['settings' => $settings],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $userSettings->settings,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSettingsMediaRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
class SettingsMediaController extends Controller
|
||||
{
|
||||
private const BACKGROUND_MAX_SIDE = 1600;
|
||||
|
||||
private const BACKGROUND_QUALITY = 86;
|
||||
|
||||
public function store(StoreSettingsMediaRequest $request): JsonResponse
|
||||
{
|
||||
$processed = $this->processBackground($request->file('file'));
|
||||
$path = $this->backgroundPath((int) $request->user()->id);
|
||||
|
||||
Storage::disk('local')->put($path, $processed['contents']);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'url' => '/settings/media/background?v='.time(),
|
||||
'width' => $processed['width'],
|
||||
'height' => $processed['height'],
|
||||
'mimeType' => 'image/jpeg',
|
||||
'size' => strlen($processed['contents']),
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$path = $this->backgroundPath((int) $request->user()->id);
|
||||
|
||||
abort_unless(Storage::disk('local')->exists($path), 404);
|
||||
|
||||
return response(Storage::disk('local')->get($path), 200, [
|
||||
'Content-Type' => 'image/jpeg',
|
||||
'Cache-Control' => 'private, max-age=604800',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
Storage::disk('local')->delete($this->backgroundPath((int) $request->user()->id));
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
private function backgroundPath(int $userId): string
|
||||
{
|
||||
return "settings-media/{$userId}/background.jpg";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, width: int, height: int}
|
||||
*/
|
||||
private function processBackground(UploadedFile $file): array
|
||||
{
|
||||
$source = imagecreatefromstring((string) file_get_contents($file->getRealPath()));
|
||||
|
||||
if (! $source) {
|
||||
throw new RuntimeException('The uploaded background image could not be processed.');
|
||||
}
|
||||
|
||||
$sourceWidth = imagesx($source);
|
||||
$sourceHeight = imagesy($source);
|
||||
$scale = min(1, self::BACKGROUND_MAX_SIDE / max($sourceWidth, $sourceHeight));
|
||||
$width = max(1, (int) round($sourceWidth * $scale));
|
||||
$height = max(1, (int) round($sourceHeight * $scale));
|
||||
|
||||
$canvas = imagecreatetruecolor($width, $height);
|
||||
$background = imagecolorallocate($canvas, 255, 255, 255);
|
||||
imagefilledrectangle($canvas, 0, 0, $width, $height, $background);
|
||||
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $width, $height, $sourceWidth, $sourceHeight);
|
||||
|
||||
imagedestroy($source);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($canvas, null, self::BACKGROUND_QUALITY);
|
||||
$contents = (string) ob_get_clean();
|
||||
imagedestroy($canvas);
|
||||
|
||||
return [
|
||||
'contents' => $contents,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEventMediaRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||||
'collection' => ['nullable', 'in:key_image,gallery'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreSettingsMediaRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateSettingsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'settings' => ['required', 'array'],
|
||||
'settings.theme' => ['nullable', 'string', 'max:50'],
|
||||
'settings.floatingLines' => ['nullable', 'array'],
|
||||
'settings.appearance' => ['nullable', 'string', 'max:50'],
|
||||
'settings.accentColor' => ['nullable', 'string', 'max:50'],
|
||||
'settings.language' => ['nullable', 'string', 'max:10'],
|
||||
'settings.emotionGradientStart' => ['nullable', 'string', 'max:20'],
|
||||
'settings.emotionGradientEnd' => ['nullable', 'string', 'max:20'],
|
||||
'settings.timelineZoom' => ['nullable', 'numeric', 'min:0.1', 'max:10'],
|
||||
'settings.timelineScrollLeft' => ['nullable', 'numeric', 'min:0'],
|
||||
'settings.showFps' => ['nullable', 'boolean'],
|
||||
'settings.presets' => ['nullable', 'array'],
|
||||
'settings.activePresetId' => ['nullable', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventMediaResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'type' => 'image',
|
||||
'collection' => $this->collection,
|
||||
'name' => $this->name,
|
||||
'mimeType' => $this->mime_type,
|
||||
'size' => $this->size,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'thumbnailWidth' => $this->thumbnail_width,
|
||||
'thumbnailHeight' => $this->thumbnail_height,
|
||||
'previewWidth' => $this->preview_width,
|
||||
'previewHeight' => $this->preview_height,
|
||||
'src' => "/event-media/{$this->id}/thumb",
|
||||
'thumbnailUrl' => "/event-media/{$this->id}/thumb",
|
||||
'previewUrl' => "/event-media/{$this->id}/preview",
|
||||
'originalUrl' => "/event-media/{$this->id}/original",
|
||||
'createdAt' => $this->created_at->getTimestampMs(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ class EventResource extends JsonResource
|
|||
'gradientPreset' => $this->gradient_preset,
|
||||
'image' => $this->image,
|
||||
'note' => $this->note ?? '',
|
||||
'media' => EventMediaResource::collection($this->whenLoaded('media')),
|
||||
'syncStatus' => 'synced',
|
||||
'createdAt' => $this->created_at->getTimestampMs(),
|
||||
'updatedAt' => $this->updated_at->getTimestampMs(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ namespace App\Models;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
|
|
@ -36,9 +35,4 @@ class Event extends Model
|
|||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function media(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventMedia::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventMedia extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\EventMediaFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'user_id',
|
||||
'event_id',
|
||||
'collection',
|
||||
'name',
|
||||
'mime_type',
|
||||
'disk',
|
||||
'path',
|
||||
'thumbnail_path',
|
||||
'preview_path',
|
||||
'size',
|
||||
'width',
|
||||
'height',
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'preview_width',
|
||||
'preview_height',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'thumbnail_width' => 'integer',
|
||||
'thumbnail_height' => 'integer',
|
||||
'preview_width' => 'integer',
|
||||
'preview_height' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (EventMedia $media): void {
|
||||
if (! $media->uuid) {
|
||||
$media->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ namespace App\Models;
|
|||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -58,11 +57,6 @@ class User extends Authenticatable
|
|||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function settings(): HasOne
|
||||
{
|
||||
return $this->hasOne(UserSetting::class);
|
||||
}
|
||||
|
||||
public function initials(): string
|
||||
{
|
||||
return Str::of($this->name)
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserSetting extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserSettingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
|
||||
class EventMediaImageProcessor
|
||||
{
|
||||
private const MAX_ORIGINAL_SIDE = 3508;
|
||||
|
||||
private const PREVIEW_MAX_SIDE = 900;
|
||||
|
||||
private const THUMBNAIL_SIZE = 320;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* original: string,
|
||||
* preview: string,
|
||||
* thumbnail: string,
|
||||
* width: int,
|
||||
* height: int,
|
||||
* preview_width: int,
|
||||
* preview_height: int,
|
||||
* thumbnail_width: int,
|
||||
* thumbnail_height: int,
|
||||
* mime_type: string,
|
||||
* size: int
|
||||
* }
|
||||
*/
|
||||
public function process(UploadedFile $file): array
|
||||
{
|
||||
$source = imagecreatefromstring((string) file_get_contents($file->getRealPath()));
|
||||
|
||||
if (! $source) {
|
||||
throw new RuntimeException('The uploaded image could not be processed.');
|
||||
}
|
||||
|
||||
$sourceWidth = imagesx($source);
|
||||
$sourceHeight = imagesy($source);
|
||||
|
||||
$original = $this->resizeContain($source, $sourceWidth, $sourceHeight, self::MAX_ORIGINAL_SIDE);
|
||||
$preview = $this->resizeContain($source, $sourceWidth, $sourceHeight, self::PREVIEW_MAX_SIDE, 84);
|
||||
$thumbnail = $this->resizeCover($source, $sourceWidth, $sourceHeight, self::THUMBNAIL_SIZE);
|
||||
|
||||
imagedestroy($source);
|
||||
|
||||
return [
|
||||
'original' => $original['contents'],
|
||||
'preview' => $preview['contents'],
|
||||
'thumbnail' => $thumbnail['contents'],
|
||||
'width' => $original['width'],
|
||||
'height' => $original['height'],
|
||||
'preview_width' => $preview['width'],
|
||||
'preview_height' => $preview['height'],
|
||||
'thumbnail_width' => self::THUMBNAIL_SIZE,
|
||||
'thumbnail_height' => self::THUMBNAIL_SIZE,
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size' => strlen($original['contents']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, width: int, height: int}
|
||||
*/
|
||||
private function resizeContain(
|
||||
\GdImage $source,
|
||||
int $sourceWidth,
|
||||
int $sourceHeight,
|
||||
int $maxSide,
|
||||
int $quality = 90
|
||||
): array {
|
||||
$scale = min(1, $maxSide / max($sourceWidth, $sourceHeight));
|
||||
$width = max(1, (int) round($sourceWidth * $scale));
|
||||
$height = max(1, (int) round($sourceHeight * $scale));
|
||||
|
||||
$canvas = imagecreatetruecolor($width, $height);
|
||||
$this->fillCanvas($canvas, $width, $height);
|
||||
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $width, $height, $sourceWidth, $sourceHeight);
|
||||
|
||||
return [
|
||||
'contents' => $this->encodeJpeg($canvas, $quality),
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, width: int, height: int}
|
||||
*/
|
||||
private function resizeCover(\GdImage $source, int $sourceWidth, int $sourceHeight, int $size): array
|
||||
{
|
||||
$scale = max($size / $sourceWidth, $size / $sourceHeight);
|
||||
$width = (int) round($sourceWidth * $scale);
|
||||
$height = (int) round($sourceHeight * $scale);
|
||||
$x = (int) round(($size - $width) / 2);
|
||||
$y = (int) round(($size - $height) / 2);
|
||||
|
||||
$canvas = imagecreatetruecolor($size, $size);
|
||||
$this->fillCanvas($canvas, $size, $size);
|
||||
imagecopyresampled($canvas, $source, $x, $y, 0, 0, $width, $height, $sourceWidth, $sourceHeight);
|
||||
|
||||
return [
|
||||
'contents' => $this->encodeJpeg($canvas, 80),
|
||||
'width' => $size,
|
||||
'height' => $size,
|
||||
];
|
||||
}
|
||||
|
||||
private function fillCanvas(\GdImage $canvas, int $width, int $height): void
|
||||
{
|
||||
$background = imagecolorallocate($canvas, 255, 255, 255);
|
||||
imagefilledrectangle($canvas, 0, 0, $width, $height, $background);
|
||||
}
|
||||
|
||||
private function encodeJpeg(\GdImage $image, int $quality): string
|
||||
{
|
||||
ob_start();
|
||||
imagejpeg($image, null, $quality);
|
||||
$contents = (string) ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
return $contents;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'paths' => ['api/*'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [
|
||||
'https://thats-me.app',
|
||||
'https://www.thats-me.app',
|
||||
'https://app.thats-me.app',
|
||||
'https://app.thats-me.test',
|
||||
'http://app.thats-me.test:9000',
|
||||
'http://localhost:9000',
|
||||
'http://127.0.0.1:9000',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => false,
|
||||
];
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EventMedia>
|
||||
*/
|
||||
class EventMediaFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$user = User::factory();
|
||||
|
||||
return [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $user,
|
||||
'event_id' => Event::factory()->for($user),
|
||||
'collection' => 'gallery',
|
||||
'name' => fake()->word().'.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'disk' => 'local',
|
||||
'path' => 'event-media/test/original.jpg',
|
||||
'thumbnail_path' => 'event-media/test/thumb.jpg',
|
||||
'preview_path' => 'event-media/test/preview.jpg',
|
||||
'size' => 12345,
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'thumbnail_width' => 320,
|
||||
'thumbnail_height' => 320,
|
||||
'preview_width' => 900,
|
||||
'preview_height' => 600,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserSetting>
|
||||
*/
|
||||
class UserSettingFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'settings' => [
|
||||
'appearance' => 'system',
|
||||
'accentColor' => 'base',
|
||||
'language' => 'de',
|
||||
'timelineZoom' => 1,
|
||||
'timelineScrollLeft' => null,
|
||||
'presets' => [],
|
||||
'activePresetId' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?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('user_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$table->json('settings');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_settings');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<?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('event_media', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('collection')->default('gallery');
|
||||
$table->string('name');
|
||||
$table->string('mime_type');
|
||||
$table->string('disk')->default('local');
|
||||
$table->string('path');
|
||||
$table->string('thumbnail_path');
|
||||
$table->string('preview_path')->nullable();
|
||||
$table->unsignedBigInteger('size');
|
||||
$table->unsignedInteger('width')->nullable();
|
||||
$table->unsignedInteger('height')->nullable();
|
||||
$table->unsignedInteger('thumbnail_width')->nullable();
|
||||
$table->unsignedInteger('thumbnail_height')->nullable();
|
||||
$table->unsignedInteger('preview_width')->nullable();
|
||||
$table->unsignedInteger('preview_height')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'event_id']);
|
||||
$table->index(['event_id', 'collection']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_media');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?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::table('event_media', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('event_media', 'preview_path')) {
|
||||
$table->string('preview_path')->nullable()->after('thumbnail_path');
|
||||
}
|
||||
if (! Schema::hasColumn('event_media', 'preview_width')) {
|
||||
$table->unsignedInteger('preview_width')->nullable()->after('thumbnail_height');
|
||||
}
|
||||
if (! Schema::hasColumn('event_media', 'preview_height')) {
|
||||
$table->unsignedInteger('preview_height')->nullable()->after('preview_width');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('event_media', function (Blueprint $table) {
|
||||
$columns = array_filter([
|
||||
Schema::hasColumn('event_media', 'preview_path') ? 'preview_path' : null,
|
||||
Schema::hasColumn('event_media', 'preview_width') ? 'preview_width' : null,
|
||||
Schema::hasColumn('event_media', 'preview_height') ? 'preview_height' : null,
|
||||
]);
|
||||
|
||||
if ($columns !== []) {
|
||||
$table->dropColumn($columns);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -3,9 +3,8 @@
|
|||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Laravel\Passport\Client;
|
||||
use Laravel\Passport\ClientRepository;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
|
|
@ -14,37 +13,11 @@ class DatabaseSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
User::query()->updateOrCreate(
|
||||
['email' => 'test@example.com'],
|
||||
[
|
||||
'name' => 'Test User',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
],
|
||||
);
|
||||
// User::factory(10)->create();
|
||||
|
||||
foreach (range(1, 6) as $number) {
|
||||
User::query()->updateOrCreate(
|
||||
['email' => "user{$number}@thats-me.app"],
|
||||
[
|
||||
'name' => "User {$number}",
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'pass',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$hasPersonalAccessClient = Client::query()
|
||||
->where('provider', 'users')
|
||||
->where('revoked', false)
|
||||
->get()
|
||||
->contains(fn (Client $client): bool => $client->hasGrantType('personal_access'));
|
||||
|
||||
if (! $hasPersonalAccessClient) {
|
||||
app(ClientRepository::class)->createPersonalAccessGrantClient(
|
||||
'Thats Me Quasar Personal Access Client',
|
||||
'users',
|
||||
);
|
||||
}
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\EventController;
|
||||
use App\Http\Controllers\Api\EventMediaController;
|
||||
use App\Http\Controllers\Api\SettingsController;
|
||||
use App\Http\Controllers\Api\SettingsMediaController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
|
||||
Route::middleware('auth:api')->group(function () {
|
||||
Route::get('/user', fn (Request $request) => $request->user());
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
Route::get('/settings', [SettingsController::class, 'show']);
|
||||
Route::put('/settings', [SettingsController::class, 'update']);
|
||||
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
|
||||
Route::post('/settings/media/background', [SettingsMediaController::class, 'store']);
|
||||
Route::delete('/settings/media/background', [SettingsMediaController::class, 'destroy']);
|
||||
Route::get('/event-media/{media}/{variant}', [EventMediaController::class, 'show']);
|
||||
Route::get('/events/{clientId}/media', [EventMediaController::class, 'index']);
|
||||
Route::post('/events/{clientId}/media', [EventMediaController::class, 'store']);
|
||||
Route::delete('/events/{clientId}/media/{media}', [EventMediaController::class, 'destroy']);
|
||||
|
||||
Route::apiResource('events', EventController::class)->parameters([
|
||||
'events' => 'clientId',
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Passport\Client;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
test('api user endpoint requires a token', function () {
|
||||
$this->getJson('/api/user')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('events endpoint requires a token', function () {
|
||||
$this->getJson('/api/events')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('api user endpoint returns the authenticated user', function () {
|
||||
$user = User::factory()->create([
|
||||
'name' => 'API User',
|
||||
'email' => 'api-user@example.com',
|
||||
]);
|
||||
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->getJson('/api/user')
|
||||
->assertOk()
|
||||
->assertJsonPath('id', $user->id)
|
||||
->assertJsonPath('name', 'API User')
|
||||
->assertJsonPath('email', 'api-user@example.com');
|
||||
});
|
||||
|
||||
test('can login with presentation user credentials', function () {
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'user1@thats-me.app',
|
||||
'password' => 'pass',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('tokenType', 'Bearer')
|
||||
->assertJsonPath('user.email', 'user1@thats-me.app')
|
||||
->assertJsonPath('user.name', 'User 1')
|
||||
->assertJsonPath('user.mode', 'remote')
|
||||
->assertJsonStructure([
|
||||
'token',
|
||||
'tokenType',
|
||||
'user' => ['id', 'name', 'email', 'avatar', 'mode'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('login rejects invalid credentials', function () {
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'user1@thats-me.app',
|
||||
'password' => 'wrong-password',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['email']);
|
||||
});
|
||||
|
||||
test('authenticated user can logout', function () {
|
||||
$user = User::factory()->create();
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->postJson('/api/logout')
|
||||
->assertNoContent();
|
||||
});
|
||||
|
||||
test('database seeder creates the presentation api users', function () {
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
foreach (range(1, 6) as $number) {
|
||||
$user = User::query()
|
||||
->where('email', "user{$number}@thats-me.app")
|
||||
->first();
|
||||
|
||||
expect($user)->not->toBeNull()
|
||||
->and($user->name)->toBe("User {$number}")
|
||||
->and(Hash::check('pass', $user->password))->toBeTrue();
|
||||
}
|
||||
|
||||
$hasPersonalAccessClient = Client::query()
|
||||
->where('provider', 'users')
|
||||
->where('revoked', false)
|
||||
->get()
|
||||
->contains(fn (Client $client): bool => $client->hasGrantType('personal_access'));
|
||||
|
||||
expect($hasPersonalAccessClient)->toBeTrue();
|
||||
});
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMedia;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
test('media endpoints require a token', function () {
|
||||
$this->getJson('/api/events/event-id/media')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('can upload key image and receive protected urls', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
$response = $this->postJson("/api/events/{$event->client_id}/media", [
|
||||
'collection' => 'key_image',
|
||||
'file' => UploadedFile::fake()->image('avatar.png', 1200, 800),
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.collection', 'key_image')
|
||||
->assertJsonPath('data.type', 'image')
|
||||
->assertJsonPath('data.thumbnailWidth', 320)
|
||||
->assertJsonPath('data.thumbnailHeight', 320)
|
||||
->assertJsonPath('data.previewWidth', 900);
|
||||
|
||||
$media = EventMedia::query()->firstOrFail();
|
||||
Storage::disk('local')->assertExists($media->path);
|
||||
Storage::disk('local')->assertExists($media->preview_path);
|
||||
Storage::disk('local')->assertExists($media->thumbnail_path);
|
||||
|
||||
expect($event->fresh()->image)->toBe("/event-media/{$media->id}/thumb");
|
||||
});
|
||||
|
||||
test('uploaded originals keep a4 quality maximum side', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->postJson("/api/events/{$event->client_id}/media", [
|
||||
'collection' => 'gallery',
|
||||
'file' => UploadedFile::fake()->image('large.jpg', 5000, 2500),
|
||||
])->assertCreated();
|
||||
|
||||
$media = EventMedia::query()->firstOrFail();
|
||||
|
||||
expect($media->width)->toBe(3508)
|
||||
->and($media->height)->toBe(1754)
|
||||
->and($media->preview_width)->toBe(900)
|
||||
->and($media->preview_height)->toBe(450)
|
||||
->and(max($media->width, $media->height))->toBe(3508);
|
||||
});
|
||||
|
||||
test('can list media for own event', function () {
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
EventMedia::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'event_id' => $event->id,
|
||||
'collection' => 'gallery',
|
||||
]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->getJson("/api/events/{$event->client_id}/media")
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.collection', 'gallery');
|
||||
});
|
||||
|
||||
test('can stream thumbnail for own media', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'event_id' => $event->id,
|
||||
'thumbnail_path' => 'event-media/test/thumb.jpg',
|
||||
]);
|
||||
Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg');
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->get("/api/event-media/{$media->id}/thumb")
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'image/jpeg');
|
||||
});
|
||||
|
||||
test('can stream preview for own media', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'event_id' => $event->id,
|
||||
'preview_path' => 'event-media/test/preview.jpg',
|
||||
]);
|
||||
Storage::disk('local')->put($media->preview_path, 'fake-jpeg');
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->get("/api/event-media/{$media->id}/preview")
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'image/jpeg');
|
||||
});
|
||||
|
||||
test('preview falls back to thumbnail for legacy media', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'event_id' => $event->id,
|
||||
'preview_path' => null,
|
||||
'thumbnail_path' => 'event-media/test/thumb.jpg',
|
||||
]);
|
||||
Storage::disk('local')->put($media->thumbnail_path, 'legacy-thumb');
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->get("/api/event-media/{$media->id}/preview")
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'image/jpeg');
|
||||
});
|
||||
|
||||
test('cannot access another users media', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $otherUser->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'event_id' => $event->id,
|
||||
'thumbnail_path' => 'event-media/test/thumb.jpg',
|
||||
]);
|
||||
Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg');
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->getJson("/api/events/{$event->client_id}/media")
|
||||
->assertNotFound();
|
||||
|
||||
$this->get("/api/event-media/{$media->id}/thumb")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('can delete own media and files', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'event_id' => $event->id,
|
||||
'path' => 'event-media/test/original.jpg',
|
||||
'preview_path' => null,
|
||||
'thumbnail_path' => 'event-media/test/thumb.jpg',
|
||||
]);
|
||||
Storage::disk('local')->put($media->path, 'original');
|
||||
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->deleteJson("/api/events/{$event->client_id}/media/{$media->id}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseMissing('event_media', ['id' => $media->id]);
|
||||
Storage::disk('local')->assertMissing($media->path);
|
||||
Storage::disk('local')->assertMissing($media->thumbnail_path);
|
||||
});
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserSetting;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
test('settings endpoint requires a token', function () {
|
||||
$this->getJson('/api/settings')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('can get empty settings for authenticated user', function () {
|
||||
Passport::actingAs(User::factory()->create());
|
||||
|
||||
$this->getJson('/api/settings')
|
||||
->assertOk()
|
||||
->assertJsonPath('data', null);
|
||||
});
|
||||
|
||||
test('can store and update settings', function () {
|
||||
$user = User::factory()->create();
|
||||
Passport::actingAs($user);
|
||||
|
||||
$settings = [
|
||||
'appearance' => 'dark',
|
||||
'accentColor' => 'green',
|
||||
'language' => 'de',
|
||||
'timelineZoom' => 1.5,
|
||||
'timelineScrollLeft' => 420,
|
||||
'floatingLines' => [
|
||||
'speed' => 1.2,
|
||||
'lineCount' => 12,
|
||||
],
|
||||
'presets' => [[
|
||||
'id' => 'preset-1',
|
||||
'name' => 'Praesentation',
|
||||
'settings' => ['accentColor' => 'green'],
|
||||
'updatedAt' => 1710000000000,
|
||||
]],
|
||||
'activePresetId' => 'preset-1',
|
||||
'showFps' => false,
|
||||
];
|
||||
|
||||
$this->putJson('/api/settings', ['settings' => $settings])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.appearance', 'dark')
|
||||
->assertJsonPath('data.floatingLines.speed', 1.2)
|
||||
->assertJsonPath('data.presets.0.name', 'Praesentation');
|
||||
|
||||
expect($user->settings()->count())->toBe(1);
|
||||
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => [
|
||||
...$settings,
|
||||
'accentColor' => 'blue',
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.accentColor', 'blue');
|
||||
|
||||
expect($user->settings()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('settings are isolated per user', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
UserSetting::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'settings' => [
|
||||
'accentColor' => 'rose',
|
||||
'language' => 'en',
|
||||
],
|
||||
]);
|
||||
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->getJson('/api/settings')
|
||||
->assertOk()
|
||||
->assertJsonPath('data', null);
|
||||
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => [
|
||||
'accentColor' => 'green',
|
||||
'language' => 'de',
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
expect($user->settings()->first()->settings['accentColor'])->toBe('green')
|
||||
->and($otherUser->settings()->first()->settings['accentColor'])->toBe('rose');
|
||||
});
|
||||
|
||||
test('settings payload is required', function () {
|
||||
Passport::actingAs(User::factory()->create());
|
||||
|
||||
$this->putJson('/api/settings', [])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['settings']);
|
||||
});
|
||||
|
||||
test('can upload and stream own settings background image', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
Passport::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/settings/media/background', [
|
||||
'file' => UploadedFile::fake()->image('background.png', 2400, 1200),
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.width', 1600)
|
||||
->assertJsonPath('data.height', 800)
|
||||
->assertJsonPath('data.mimeType', 'image/jpeg');
|
||||
|
||||
expect($response->json('data.url'))->toStartWith('/settings/media/background?v=');
|
||||
Storage::disk('local')->assertExists("settings-media/{$user->id}/background.jpg");
|
||||
|
||||
$this->get('/api/settings/media/background')
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'image/jpeg');
|
||||
});
|
||||
|
||||
test('settings background images are isolated per user', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
Storage::disk('local')->put("settings-media/{$otherUser->id}/background.jpg", 'other-background');
|
||||
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->get('/api/settings/media/background')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('can delete own settings background image', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
Storage::disk('local')->put("settings-media/{$user->id}/background.jpg", 'background');
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->deleteJson('/api/settings/media/background')
|
||||
->assertNoContent();
|
||||
|
||||
Storage::disk('local')->assertMissing("settings-media/{$user->id}/background.jpg");
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineConfig } from "vite";
|
||||
import laravel from "laravel-vite-plugin";
|
||||
// import tailwindcss from "@tailwindcss/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
|
|
|
|||
538
deploy.sh
|
|
@ -1,538 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -19,41 +19,52 @@ services:
|
|||
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_HOST: mysql
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: thats-me
|
||||
DB_USERNAME: root
|
||||
DB_USERNAME: sail
|
||||
DB_PASSWORD: password
|
||||
MAIL_HOST: global-mailpit
|
||||
MAIL_HOST: mailpit
|
||||
MAIL_PORT: 1025
|
||||
REDIS_HOST: global-redis
|
||||
REDIS_HOST: redis
|
||||
volumes:
|
||||
- './backend:/var/www/html'
|
||||
- '.:/workspace:cached'
|
||||
networks:
|
||||
- sail
|
||||
- proxy
|
||||
depends_on:
|
||||
- mysql
|
||||
- mailpit
|
||||
- redis
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
# Domain 1: Hauptdomain thats-me.test (Webseite/Landingpage)
|
||||
- "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"
|
||||
|
||||
# Domain 2: portal.thats-me.test (Admin Panel)
|
||||
- "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"
|
||||
|
||||
# Domain 3: api.thats-me.test (API für Quasar App)
|
||||
- "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"
|
||||
|
||||
# Vite Asset Domain für Backend Development
|
||||
- "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"
|
||||
|
||||
# Service Definitions
|
||||
- "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"
|
||||
|
|
@ -76,20 +87,79 @@ services:
|
|||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
# Domain 4: app.thats-me.test (Quasar Frontend App)
|
||||
- "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"
|
||||
|
||||
# Service Definition
|
||||
- "traefik.http.services.thatsme-app-service.loadbalancer.server.port=9000"
|
||||
- "traefik.http.services.thatsme-app-service.loadbalancer.server.scheme=http"
|
||||
- "traefik.docker.network=proxy"
|
||||
|
||||
# MySQL Database
|
||||
mysql:
|
||||
image: 'mysql/mysql-server:8.0'
|
||||
ports:
|
||||
- '${FORWARD_DB_PORT:-33070}:3306'
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-password}'
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
MYSQL_DATABASE: '${DB_DATABASE:-thats-me}'
|
||||
MYSQL_USER: '${DB_USERNAME:-sail}'
|
||||
MYSQL_PASSWORD: '${DB_PASSWORD:-password}'
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
volumes:
|
||||
- 'sail-mysql:/var/lib/mysql'
|
||||
- './backend/vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- mysqladmin
|
||||
- ping
|
||||
- '-p${DB_PASSWORD:-password}'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
|
||||
# Mailpit für E-Mail Testing
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
ports:
|
||||
- '${FORWARD_MAILPIT_PORT:-1028}:1025'
|
||||
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8028}:8025'
|
||||
networks:
|
||||
- sail
|
||||
|
||||
# Redis Cache/Queue
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
ports:
|
||||
- '${FORWARD_REDIS_PORT:-6383}:6379'
|
||||
volumes:
|
||||
- 'sail-redis:/data'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
sail-mysql:
|
||||
driver: local
|
||||
sail-redis:
|
||||
driver: local
|
||||
quasar-node-modules:
|
||||
driver: local
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 1</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 2: Single Image Gallery -->
|
||||
<div id="single-image-gallery"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-black shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:42</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-3 border-b border-white/10 bg-black/50 backdrop-blur-md sticky top-0 z-30">
|
||||
<a href="index.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="x" class="w-6 h-6"></i></a>
|
||||
<button class="flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-white/10 transition-colors"><span
|
||||
class="text-sm font-semibold tracking-tight">Recents</span><i data-lucide="chevron-down"
|
||||
class="w-4 h-4 text-white/50"></i></button>
|
||||
<a href="entry-edit-step-2.html"
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors inline-flex items-center justify-center">Next</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar">
|
||||
<div class="grid grid-cols-3 gap-0.5 pb-20">
|
||||
<div
|
||||
class="relative aspect-square bg-white/5 flex flex-col items-center justify-center gap-2 cursor-pointer hover:bg-white/10 transition-colors group">
|
||||
<i data-lucide="image-off" class="w-6 h-6 text-white/40 group-hover:text-white/80 transition-colors"></i>
|
||||
</div>
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1542038784456-1ea8e935640e?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt="Gallery">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1516762689617-e1cffcef479d?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1469334031218-e382a71b716b?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1493246507139-91e8fad9978e?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#2a2a2a] rounded-full border border-white/10 shadow-xl backdrop-blur-md">
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden border border-white/20"><img
|
||||
src="https://images.unsplash.com/photo-1542038784456-1ea8e935640e?ixlib=rb-4.0.3&w=100&q=60"
|
||||
class="w-full h-full object-cover"></div>
|
||||
<span class="text-xs font-medium text-white/90">1 Selected</span>
|
||||
<button class="w-6 h-6 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"><i
|
||||
data-lucide="x" class="w-3.5 h-3.5 text-white/50"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 2</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 3: Details Entry -->
|
||||
<div id="screen-details"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5 scroll-mt-8">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:43</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
<a href="entry-edit-step-1.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
<div class="flex-1"></div>
|
||||
<a href="entry-edit-step-3.html"
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors inline-flex items-center justify-center">Next</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 pt-4 pb-12 flex flex-col gap-8 no-scrollbar">
|
||||
|
||||
<!-- Emotional Level - Updated -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-between items-end"><label class="text-sm font-medium text-white/60">Emotional
|
||||
Level</label><span class="text-xs font-medium text-blue-400">Good</span></div>
|
||||
<div class="flex justify-between items-center pt-2 pb-1">
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😫</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😔</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">🙁</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😐</span></button>
|
||||
<!-- Selected -->
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-[#2563eb] border border-blue-400 shadow-[0_0_15px_rgba(37,99,235,0.5)] flex items-center justify-center transition-all scale-110"><span
|
||||
class="text-xl">🙂</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">😄</span></button>
|
||||
<button
|
||||
class="group w-10 h-10 rounded-full bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 hover:border-white/20 transition-all active:scale-95"><span
|
||||
class="text-xl group-hover:scale-110 transition-transform grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100">🤩</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Date</label>
|
||||
<div class="group relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="calendar"
|
||||
class="w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors"></i></div><input
|
||||
type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-3 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium"
|
||||
value="2023-07-28">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Time</label>
|
||||
<div class="group relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="clock"
|
||||
class="w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors"></i></div><input
|
||||
type="time"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-3 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium"
|
||||
value="14:30">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2"><label class="text-sm font-medium text-white/60">Short Title</label><input
|
||||
type="text" placeholder="e.g. Summer Wedding"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
<div class="flex flex-col gap-2"><label class="text-sm font-medium text-white/60">Headline</label><input
|
||||
type="text" placeholder="A day to remember forever"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 flex-1"><label
|
||||
class="text-sm font-medium text-white/60">Description</label><textarea
|
||||
placeholder="Write your thoughts here..."
|
||||
class="w-full h-32 bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all placeholder-white/20 font-medium resize-none leading-relaxed"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 3</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 4: Multiple Image Gallery -->
|
||||
<div id="multiple-image-gallery"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-black shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:44</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-3 border-b border-white/10 bg-black/50 backdrop-blur-md sticky top-0 z-30">
|
||||
<a href="entry-edit-step-2.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Add Photos</span>
|
||||
<span class="text-[10px] text-white/50">Camera Roll</span>
|
||||
</div>
|
||||
<a href="entry-edit-step-4.html"
|
||||
class="px-4 py-1.5 bg-[#2563eb] text-white text-xs font-semibold rounded-full hover:bg-blue-600 transition-colors">Add
|
||||
(3)</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar">
|
||||
<div class="grid grid-cols-3 gap-0.5 pb-20">
|
||||
<!-- Selected 1 -->
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img src="https://images.unsplash.com/photo-1511895426328-dc8714191300?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
<div class="absolute bottom-1.5 left-1.5 bg-black/50 backdrop-blur px-1.5 rounded text-[10px] font-medium">1
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected 2 -->
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img src="https://images.unsplash.com/photo-1526047932273-341f2a7631f9?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
<div class="absolute bottom-1.5 left-1.5 bg-black/50 backdrop-blur px-1.5 rounded text-[10px] font-medium">2
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected 3 -->
|
||||
<div class="relative aspect-square group cursor-pointer">
|
||||
<img src="https://images.unsplash.com/photo-1470240731273-7821a6eeb6bd?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<div class="absolute inset-0 border-[3px] border-[#2563eb] z-10"></div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 w-5 h-5 bg-[#2563eb] rounded-full flex items-center justify-center z-20 shadow-sm">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i></div>
|
||||
<div class="absolute bottom-1.5 left-1.5 bg-black/50 backdrop-blur px-1.5 rounded text-[10px] font-medium">3
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unselected -->
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1552083375-1447ce886485?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1506477331477-33d5d8b3dc85?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1496345875659-11f7dd282d1d?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/917d6f93-fb36-439a-8c48-884b67b35381_1600w.jpg"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1466112928291-0903b80a9466?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/4734259a-bad7-422f-981e-ce01e79184f2_1600w.jpg"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
<div class="relative aspect-square"><img
|
||||
src="https://images.unsplash.com/photo-1509233725247-49e657c54213?ixlib=rb-4.0.3&w=400&q=80"
|
||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity">
|
||||
<div class="absolute top-1.5 right-1.5 w-5 h-5 border border-white/40 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 4</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 5: Video & Audio Selection -->
|
||||
<div id="video-audio"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:45</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
|
||||
<a href="entry-edit-step-3.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<div class="flex-1 text-center text-sm font-semibold">Media</div>
|
||||
<a href="entry-edit-step-5.html"
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors inline-flex items-center justify-center">Next</a>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-6 no-scrollbar">
|
||||
|
||||
<!-- Video Section -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-sm font-medium text-white/80">Videos</label>
|
||||
<span class="text-xs text-blue-400 font-medium cursor-pointer">View All</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative h-48 rounded-2xl bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-3 cursor-pointer hover:bg-white/10 transition-colors group border-dashed">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<i data-lucide="video" class="w-6 h-6 text-white/60"></i>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white/40">Tap to select videos</span>
|
||||
</div>
|
||||
<!-- Selected Video Mini-Preview (Simulated) -->
|
||||
<div class="flex items-center gap-3 bg-white/5 p-3 rounded-xl border border-white/10">
|
||||
<div class="w-12 h-12 bg-black rounded-lg overflow-hidden relative">
|
||||
<img src="https://images.unsplash.com/photo-1492684223066-81342ee5ff30?ixlib=rb-4.0.3&w=100&q=60"
|
||||
class="w-full h-full object-cover opacity-60">
|
||||
<i data-lucide="play"
|
||||
class="w-4 h-4 text-white absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-white"></i>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-white">Holiday_clip.mp4</span>
|
||||
<span class="text-[10px] text-white/40">00:15 • 12MB</span>
|
||||
</div>
|
||||
<button class="text-white/40 hover:text-white"><i data-lucide="x" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Audio Section -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-sm font-medium text-white/80">Audio</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
class="h-20 rounded-xl bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-2 hover:bg-white/10 transition-colors">
|
||||
<i data-lucide="mic" class="w-5 h-5 text-red-400"></i>
|
||||
<span class="text-xs font-medium text-white/60">Record</span>
|
||||
</button>
|
||||
<button
|
||||
class="h-20 rounded-xl bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-2 hover:bg-white/10 transition-colors">
|
||||
<i data-lucide="music" class="w-5 h-5 text-blue-400"></i>
|
||||
<span class="text-xs font-medium text-white/60">Select File</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio Player (Visual) -->
|
||||
<div class="mt-2 bg-[#2563eb]/10 border border-[#2563eb]/30 p-4 rounded-xl flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-[#2563eb] flex items-center justify-center shrink-0">
|
||||
<i data-lucide="play" class="w-4 h-4 text-white fill-white ml-0.5"></i>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-white/90">Voice Note 01</span>
|
||||
<!-- Waveform Graphic -->
|
||||
<div class="flex items-center gap-[2px] h-4 items-end">
|
||||
<div class="w-1 h-2 bg-[#2563eb]/50 rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-[#2563eb]/70 rounded-full"></div>
|
||||
<div class="w-1 h-4 bg-[#2563eb] rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-[#2563eb]/80 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-[#2563eb]/60 rounded-full"></div>
|
||||
<div class="w-1 h-4 bg-[#2563eb] rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-[#2563eb]/70 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-[#2563eb]/50 rounded-full"></div>
|
||||
<div class="w-1 h-1 bg-[#2563eb]/30 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-[#2563eb]/50 rounded-full"></div>
|
||||
<div class="w-1 h-3 bg-white/20 rounded-full"></div>
|
||||
<div class="w-1 h-2 bg-white/20 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-white/50 font-mono">0:14</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Entry Edit Step 5</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 6: Location, People, Categories, Publish -->
|
||||
<div id="location"
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:46</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
|
||||
<a href="entry-edit-step-4.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<div class="flex-1 text-center text-sm font-semibold">Details</div>
|
||||
<div class="w-10"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-2 flex flex-col gap-8 no-scrollbar pb-24">
|
||||
|
||||
<!-- Location -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Location</label>
|
||||
<div class="group relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="map-pin"
|
||||
class="w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors"></i></div>
|
||||
<input type="text" value="Central Park, New York"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-10 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all font-medium">
|
||||
<div
|
||||
class="absolute inset-y-0 right-3 flex items-center cursor-pointer hover:text-white text-white/40 transition-colors">
|
||||
<i data-lucide="locate-fixed" class="w-4 h-4"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Persons -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-sm font-medium text-white/60">With whom?</label>
|
||||
<span class="text-xs text-white/30">2 selected</span>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto no-scrollbar py-1">
|
||||
<button
|
||||
class="w-12 h-12 rounded-full bg-white/10 border border-white/10 flex items-center justify-center hover:bg-white/20 transition-colors shrink-0">
|
||||
<i data-lucide="plus" class="w-5 h-5 text-white/70"></i>
|
||||
</button>
|
||||
<div class="relative w-12 h-12 shrink-0">
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&w=100&q=60"
|
||||
class="w-full h-full rounded-full object-cover border border-white/10">
|
||||
<div class="absolute -top-1 -right-1 bg-black/60 rounded-full p-0.5"><i data-lucide="x"
|
||||
class="w-3 h-3 text-white"></i></div>
|
||||
</div>
|
||||
<div
|
||||
class="relative w-12 h-12 shrink-0 flex items-center justify-center bg-blue-900/50 border border-blue-500/30 rounded-full">
|
||||
<span class="text-xs font-semibold text-blue-200">JD</span>
|
||||
<div class="absolute -top-1 -right-1 bg-black/60 rounded-full p-0.5"><i data-lucide="x"
|
||||
class="w-3 h-3 text-white"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-sm font-medium text-white/60">Category</label>
|
||||
|
||||
<!-- Category Search Input -->
|
||||
<div class="relative mb-1">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="search"
|
||||
class="w-4 h-4 text-white/40"></i></div>
|
||||
<input type="text" placeholder="Search categories..."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-2.5 pl-9 pr-4 text-xs text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all font-medium placeholder-white/20">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all text-left group">
|
||||
<div class="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center text-purple-300"><i
|
||||
data-lucide="briefcase" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Career</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all text-left group">
|
||||
<div class="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center text-orange-300"><i
|
||||
data-lucide="graduation-cap" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Education</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all text-left group">
|
||||
<div class="w-8 h-8 rounded-lg bg-yellow-500/20 flex items-center justify-center text-yellow-300"><i
|
||||
data-lucide="trophy" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Awards</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 p-3 bg-blue-600 border border-blue-500 rounded-xl shadow-[0_0_15px_rgba(37,99,235,0.3)] transition-all text-left">
|
||||
<div class="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white"><i
|
||||
data-lucide="party-popper" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white">Celebration</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-white/60">Tags</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><i data-lucide="hash"
|
||||
class="w-4 h-4 text-white/40"></i></div>
|
||||
<input type="text" placeholder="happy, summer, memories"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-9 pr-4 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent transition-all font-medium">
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-md bg-[#2563eb]/20 text-blue-300 text-[10px] font-medium border border-[#2563eb]/30">#happy</span>
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-md bg-[#2563eb]/20 text-blue-300 text-[10px] font-medium border border-[#2563eb]/30">#summer</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sticky Bottom Button -->
|
||||
<div class="absolute bottom-0 w-full p-6 bg-gradient-to-t from-[#09090b] via-[#09090b] to-transparent z-40">
|
||||
<a href="index.html"
|
||||
class="w-full bg-white text-black font-semibold py-3.5 rounded-full shadow-lg shadow-white/10 active:scale-[0.98] transition-all hover:bg-gray-100 flex items-center justify-center gap-2">
|
||||
<span>Publish</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App Interface Variations</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-geist">.font-geist { font-family: 'Geist', sans-serif !important; }</style><link id="all-fonts-link-font-roboto" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-roboto">.font-roboto { font-family: 'Roboto', sans-serif !important; }</style><link id="all-fonts-link-font-montserrat" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-montserrat">.font-montserrat { font-family: 'Montserrat', sans-serif !important; }</style><link id="all-fonts-link-font-poppins" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-poppins">.font-poppins { font-family: 'Poppins', sans-serif !important; }</style><link id="all-fonts-link-font-playfair" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap"><style id="all-fonts-style-font-playfair">.font-playfair { font-family: 'Playfair Display', serif !important; }</style><link id="all-fonts-link-font-instrument-serif" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap"><style id="all-fonts-style-font-instrument-serif">.font-instrument-serif { font-family: 'Instrument Serif', serif !important; }</style><link id="all-fonts-link-font-merriweather" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap"><style id="all-fonts-style-font-merriweather">.font-merriweather { font-family: 'Merriweather', serif !important; }</style><link id="all-fonts-link-font-bricolage" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-bricolage">.font-bricolage { font-family: 'Bricolage Grotesque', sans-serif !important; }</style><link id="all-fonts-link-font-jakarta" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-jakarta">.font-jakarta { font-family: 'Plus Jakarta Sans', sans-serif !important; }</style><link id="all-fonts-link-font-manrope" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-manrope">.font-manrope { font-family: 'Manrope', sans-serif !important; }</style><link id="all-fonts-link-font-space-grotesk" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-space-grotesk">.font-space-grotesk { font-family: 'Space Grotesk', sans-serif !important; }</style><link id="all-fonts-link-font-work-sans" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-work-sans">.font-work-sans { font-family: 'Work Sans', sans-serif !important; }</style><link id="all-fonts-link-font-pt-serif" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap"><style id="all-fonts-style-font-pt-serif">.font-pt-serif { font-family: 'PT Serif', serif !important; }</style><link id="all-fonts-link-font-geist-mono" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-geist-mono">.font-geist-mono { font-family: 'Geist Mono', monospace !important; }</style><link id="all-fonts-link-font-space-mono" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"><style id="all-fonts-style-font-space-mono">.font-space-mono { font-family: 'Space Mono', monospace !important; }</style><link id="all-fonts-link-font-quicksand" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap"><style id="all-fonts-style-font-quicksand">.font-quicksand { font-family: 'Quicksand', sans-serif !important; }</style><link id="all-fonts-link-font-nunito" rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap"><style id="all-fonts-style-font-nunito">.font-nunito { font-family: 'Nunito', sans-serif !important; }</style></head>
|
||||
<body class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
|
||||
|
||||
<!-- Screen 7: Complete Entry View -->
|
||||
<div class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<!-- Header Overlaid -->
|
||||
<div class="absolute top-0 w-full flex justify-between items-center px-6 pt-12 pb-2 z-30">
|
||||
<a href="index.html" class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
<button class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i data-lucide="more-horizontal" class="w-6 h-6"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto no-scrollbar relative">
|
||||
|
||||
<!-- Hero Slider -->
|
||||
<div class="w-full h-[45%] relative group">
|
||||
<div class="flex overflow-x-auto snap-x snap-mandatory no-scrollbar h-full w-full">
|
||||
<div class="w-full shrink-0 h-full snap-center relative">
|
||||
<img src="https://images.unsplash.com/photo-1511895426328-dc8714191300?ixlib=rb-4.0.3&w=600&q=80" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-[#09090b]"></div>
|
||||
</div>
|
||||
<div class="w-full shrink-0 h-full snap-center relative">
|
||||
<img src="https://images.unsplash.com/photo-1526047932273-341f2a7631f9?ixlib=rb-4.0.3&w=600&q=80" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-[#09090b]"></div>
|
||||
</div>
|
||||
<div class="w-full shrink-0 h-full snap-center relative">
|
||||
<img src="https://images.unsplash.com/photo-1470240731273-7821a6eeb6bd?ixlib=rb-4.0.3&w=600&q=80" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-[#09090b]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Slider Indicators -->
|
||||
<div class="absolute bottom-10 left-1/2 -translate-x-1/2 flex gap-1.5 z-20">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-white/40"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-white/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Body -->
|
||||
<div class="relative px-6 -mt-6 z-20 pb-32">
|
||||
<!-- Meta Info -->
|
||||
<div class="flex flex-col gap-1 mb-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest text-blue-400">Summer Wedding</span>
|
||||
<div class="flex items-center gap-1 bg-white/5 px-2 py-0.5 rounded-full border border-white/5">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
|
||||
<span class="text-[10px] text-white/60">Positivity +2</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white leading-tight tracking-tight mt-1">A day to remember forever</h1>
|
||||
<div class="flex items-center gap-4 mt-3 text-xs text-white/50 font-medium">
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="calendar" class="w-3.5 h-3.5"></i><span>28 Jul, 14:30</span></div>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="map-pin" class="w-3.5 h-3.5"></i><span>Central Park, NY</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emotional Level Visual -->
|
||||
<div class="mb-8">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 block">Emotional Impact</label>
|
||||
<div class="h-1.5 w-full bg-white/10 rounded-full relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-[80%] bg-gradient-to-r from-blue-500 to-green-400 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-8">
|
||||
<p class="text-sm text-white/80 leading-relaxed font-light">
|
||||
The sun was shining perfectly through the trees as we arrived. It was one of those rare moments where everything feels exactly right. The laughter, the music, and the company made it unforgettable. I want to keep this memory close.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Video Slider -->
|
||||
<div class="mb-8">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-3 block">Videos</label>
|
||||
<div class="flex gap-3 overflow-x-auto no-scrollbar snap-x snap-mandatory">
|
||||
<div class="w-40 aspect-[9/16] bg-white/5 rounded-xl shrink-0 overflow-hidden relative border border-white/10 snap-start">
|
||||
<img src="https://images.unsplash.com/photo-1492684223066-81342ee5ff30?ixlib=rb-4.0.3&w=200&q=60" class="w-full h-full object-cover opacity-80">
|
||||
<div class="absolute inset-0 flex items-center justify-center"><div class="w-8 h-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center"><i data-lucide="play" class="w-4 h-4 text-white fill-white ml-0.5"></i></div></div>
|
||||
</div>
|
||||
<div class="w-40 aspect-[9/16] bg-white/5 rounded-xl shrink-0 overflow-hidden relative border border-white/10 snap-start">
|
||||
<img src="https://images.unsplash.com/photo-1516035069371-29a1b244cc32?ixlib=rb-4.0.3&w=200&q=60" class="w-full h-full object-cover opacity-80">
|
||||
<div class="absolute inset-0 flex items-center justify-center"><div class="w-8 h-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center"><i data-lucide="play" class="w-4 h-4 text-white fill-white ml-0.5"></i></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-end mb-3">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider block">Gallery</label>
|
||||
<span class="text-[10px] text-blue-400 font-medium cursor-pointer">View All</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5"><img src="https://images.unsplash.com/photo-1511895426328-dc8714191300?ixlib=rb-4.0.3&w=150&q=60" class="w-full h-full object-cover hover:opacity-80 transition-opacity"></div>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5"><img src="https://images.unsplash.com/photo-1526047932273-341f2a7631f9?ixlib=rb-4.0.3&w=150&q=60" class="w-full h-full object-cover hover:opacity-80 transition-opacity"></div>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5"><img src="https://images.unsplash.com/photo-1470240731273-7821a6eeb6bd?ixlib=rb-4.0.3&w=150&q=60" class="w-full h-full object-cover hover:opacity-80 transition-opacity"></div>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-white/5 flex items-center justify-center text-white/50 text-xs font-medium bg-white/10">+5</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Recordings -->
|
||||
<div class="mb-8">
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-3 block">Audio Notes</label>
|
||||
<div class="flex gap-3 mb-3">
|
||||
<!-- Thumbnails -->
|
||||
<div class="w-14 h-14 rounded-lg bg-[#2563eb] flex items-center justify-center border border-white/10 shadow-[0_0_10px_rgba(37,99,235,0.5)] shrink-0">
|
||||
<i data-lucide="mic" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<div class="w-14 h-14 rounded-lg bg-white/5 flex items-center justify-center border border-white/10 shrink-0 opacity-60">
|
||||
<i data-lucide="music-2" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<div class="w-14 h-14 rounded-lg bg-white/5 flex items-center justify-center border border-white/10 shrink-0 opacity-60">
|
||||
<i data-lucide="music-2" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Active Player -->
|
||||
<div class="bg-white/5 border border-white/10 p-3 pr-4 rounded-xl flex items-center gap-3">
|
||||
<button class="w-8 h-8 rounded-full bg-white text-black flex items-center justify-center shrink-0 hover:bg-gray-200">
|
||||
<i data-lucide="pause" class="w-3.5 h-3.5 fill-current"></i>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between text-[10px] mb-1.5">
|
||||
<span class="font-medium text-white">Speech.m4a</span>
|
||||
<span class="text-white/40">0:12 / 0:45</span>
|
||||
</div>
|
||||
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full w-1/3 bg-[#2563eb] rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- People & Categories -->
|
||||
<div class="mb-8 space-y-6">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 block">With Whom</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&w=100&q=60" class="w-10 h-10 rounded-full border border-white/10 object-cover">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-900/50 border border-blue-500/30 flex items-center justify-center text-xs font-semibold text-blue-200">JD</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 block">Tags & Category</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-2.5 py-1 rounded-full bg-blue-600/20 text-blue-300 text-[10px] font-medium border border-blue-500/30 flex items-center gap-1"><i data-lucide="party-popper" class="w-3 h-3"></i>Celebration</span>
|
||||
<span class="px-2.5 py-1 rounded-full bg-white/5 text-white/60 text-[10px] font-medium border border-white/10">#summer</span>
|
||||
<span class="px-2.5 py-1 rounded-full bg-white/5 text-white/60 text-[10px] font-medium border border-white/10">#memories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Footer Action -->
|
||||
<div class="absolute bottom-0 w-full px-6 py-4 bg-[#09090b]/90 backdrop-blur-xl border-t border-white/5 flex items-center gap-3 z-40">
|
||||
<button class="w-12 h-12 rounded-full border border-white/10 bg-white/5 flex items-center justify-center text-white hover:bg-white/10 transition-colors">
|
||||
<i data-lucide="share-2" class="w-5 h-5"></i>
|
||||
</button>
|
||||
<button class="flex-1 bg-white text-black h-12 rounded-full font-semibold text-sm flex items-center justify-center gap-2 hover:bg-gray-200 transition-colors shadow-lg shadow-white/5">
|
||||
<i data-lucide="edit-3" class="w-4 h-4"></i>
|
||||
Edit Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Wave Flow Duplicate</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-newsreader" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400..800&display=swap">
|
||||
<style id="all-fonts-style-font-newsreader">
|
||||
.font-newsreader {
|
||||
font-family: 'Newsreader', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-google-sans-flex" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-google-sans-flex">
|
||||
.font-google-sans-flex {
|
||||
font-family: 'Google Sans Flex', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-oswald" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Oswald:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-oswald">
|
||||
.font-oswald {
|
||||
font-family: 'Oswald', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-dm-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-dm-sans">
|
||||
.font-dm-sans {
|
||||
font-family: 'DM Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 pt-8 pr-8 pb-8 pl-8">
|
||||
|
||||
|
||||
<!-- SCREEN 1: Original with Image Logo -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-app-gradient shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<!-- Status Bar -->
|
||||
<div class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-normal z-20 mix-blend-plus-lighter">
|
||||
<span>9:41</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="signal" class="lucide lucide-signal w-3.5 h-3.5">
|
||||
<path d="M2 20h.01"></path>
|
||||
<path d="M7 20v-4"></path>
|
||||
<path d="M12 20v-8"></path>
|
||||
<path d="M17 20V8"></path>
|
||||
<path d="M22 4v16"></path>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="wifi" class="lucide lucide-wifi w-3.5 h-3.5">
|
||||
<path d="M12 20h.01"></path>
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0"></path>
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0"></path>
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0"></path>
|
||||
</svg>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-end px-6 py-2 z-20">
|
||||
<img src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/420cb7fa-1091-4075-a321-497c1290e4e2_3840w.png"
|
||||
alt="Logo" class="h-8 w-auto object-contain drop-shadow-md" style="">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 flex items-center justify-center hover:bg-white/20 transition-all group overflow-hidden relative">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="sun"
|
||||
class="lucide lucide-sun w-4 h-4 text-yellow-300 absolute transition-all duration-300 scale-100 rotate-0 group-hover:scale-0 group-hover:rotate-90 opacity-100 group-hover:opacity-0">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="m4.93 4.93 1.41 1.41"></path>
|
||||
<path d="m17.66 17.66 1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="moon"
|
||||
class="lucide lucide-moon w-4 h-4 text-blue-200 absolute transition-all duration-300 scale-0 -rotate-90 group-hover:scale-100 group-hover:rotate-0 opacity-0 group-hover:opacity-100">
|
||||
<path
|
||||
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
<a href="login.html" class="p-1 hover:bg-white/10 rounded-full transition-colors"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="menu" class="lucide lucide-menu w-7 h-7 text-white">
|
||||
<path d="M4 5h16"></path>
|
||||
<path d="M4 12h16"></path>
|
||||
<path d="M4 19h16"></path>
|
||||
</svg></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 w-full h-full relative">
|
||||
<!-- mt-28 adds 112px, which is 48px more than previous mt-16 (64px) -->
|
||||
<div class="absolute inset-0 w-full h-full mt-28">
|
||||
<svg class="absolute top-0 w-full h-[400px] overflow-visible pointer-events-none z-0"
|
||||
viewBox="0 0 390 400" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="lineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:white;stop-opacity:0.1"></stop>
|
||||
<stop offset="20%" style="stop-color:white;stop-opacity:0.8"></stop>
|
||||
<stop offset="50%" style="stop-color:white;stop-opacity:1"></stop>
|
||||
<stop offset="80%" style="stop-color:white;stop-opacity:0.8"></stop>
|
||||
<stop offset="100%" style="stop-color:white;stop-opacity:0.1"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M -50,180
|
||||
C -20,180 10,100 39,100
|
||||
S 80,240 117,240
|
||||
S 155,60 195,60
|
||||
S 235,240 273,240
|
||||
S 310,180 351,180
|
||||
C 390,180 410,180 440,180" fill="none" stroke="url(#lineGrad)" stroke-width="1.5"
|
||||
stroke-linecap="round"></path>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="absolute left-[10%] -translate-x-1/2 top-[100px] flex flex-col-reverse items-center z-10 -translate-y-full pb-0.5">
|
||||
<div
|
||||
class="relative w-3 h-3 rounded-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.8)] z-20 translate-y-1.5">
|
||||
</div>
|
||||
<div class="h-6 w-px bg-white/40 my-1"></div>
|
||||
<div class="flex flex-col items-center opacity-90">
|
||||
<span
|
||||
class="text-[10px] font-semibold text-white/90 bg-white/10 px-2 py-1 rounded-full backdrop-blur-md border border-white/10">21
|
||||
Jun</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-[30%] -translate-x-1/2 top-[240px] flex flex-col items-center z-10 -translate-y-[6px]">
|
||||
<div class="relative w-3 h-3 rounded-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.8)] z-20">
|
||||
</div>
|
||||
<div class="h-8 w-px bg-white/40 my-1.5"></div>
|
||||
<div class="relative w-14 h-14 rounded-full border border-white/40 shadow-xl overflow-hidden group">
|
||||
<img src="https://images.unsplash.com/photo-1523580494863-6f3031224c94?ixlib=rb-4.0.3&auto=format&fit=crop&w=150&q=80"
|
||||
alt="Concert" class="w-full h-full object-cover" style="">
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-gradient-to-t from-black/80 to-transparent pt-3 pb-1 text-center">
|
||||
<span class="text-[9px] font-normal text-white/90">25 Jun</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link added here -->
|
||||
<a href="entry"
|
||||
class="absolute left-1/2 -translate-x-1/2 top-[60px] flex flex-col-reverse items-center z-20 -translate-y-full pb-1 transition-transform active:scale-95">
|
||||
<div
|
||||
class="relative w-4 h-4 rounded-full bg-white shadow-[0_0_20px_rgba(255,255,255,1)] ring-4 ring-white/10 z-20 translate-y-2">
|
||||
</div>
|
||||
<div class="h-8 w-px bg-white/40 my-1.5"></div>
|
||||
<div
|
||||
class="relative w-20 h-20 rounded-full border-[3px] border-white shadow-2xl overflow-hidden group">
|
||||
<img src="https://images.unsplash.com/photo-1515934751635-c81c6bc9a2d8?ixlib=rb-4.0.3&auto=format&fit=crop&w=300&q=80"
|
||||
alt="Wedding" class="w-full h-full object-cover">
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-gradient-to-t from-black/80 to-transparent pt-6 pb-2 text-center">
|
||||
<span class="text-xs font-semibold text-white">28 Jul</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="absolute left-[70%] -translate-x-1/2 top-[240px] flex flex-col items-center z-10 -translate-y-[6px]">
|
||||
<div class="relative w-3 h-3 rounded-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.8)] z-20">
|
||||
</div>
|
||||
<div class="h-8 w-px bg-white/40 my-1.5"></div>
|
||||
<div class="relative w-14 h-14 rounded-full border border-white/40 shadow-xl overflow-hidden group">
|
||||
<img src="https://images.unsplash.com/photo-1496337589254-7e19d01cec44?ixlib=rb-4.0.3&auto=format&fit=crop&w=150&q=80"
|
||||
alt="Dinner" class="w-full h-full object-cover" style="">
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-gradient-to-t from-black/80 to-transparent pt-3 pb-1 text-center">
|
||||
<span class="text-[9px] font-normal text-white/90">12 Aug</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-[90%] -translate-x-1/2 top-[180px] flex flex-col items-center z-10 -translate-y-1">
|
||||
<div
|
||||
class="relative w-2.5 h-2.5 rounded-full bg-white/90 shadow-[0_0_10px_rgba(255,255,255,0.6)] z-20">
|
||||
</div>
|
||||
<span class="mt-3 text-[9px] font-medium text-white/60">18 Aug</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-28 w-full">
|
||||
<div class="flex justify-between px-8 text-lg text-white/60 font-normal">
|
||||
<span class="opacity-50 text-sm">May</span>
|
||||
<span class="opacity-80">Jun</span>
|
||||
<span class="text-white scale-125 font-semibold drop-shadow-md mx-2">Jul</span>
|
||||
<span class="opacity-80">Aug</span>
|
||||
<span class="opacity-50 text-sm">Sep</span>
|
||||
</div>
|
||||
<div class="mx-8 mt-4 h-[1px] bg-gradient-to-r from-transparent via-white/30 to-transparent"></div>
|
||||
<div class="text-center mt-3 text-xl font-medium tracking-tight text-white/90">2023</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navbar -->
|
||||
<div
|
||||
class="flex z-30 bg-[#1a1a1a]/95 w-full h-20 border-white/5 border-t pr-8 pl-8 relative backdrop-blur-xl items-center justify-between">
|
||||
|
||||
<a href="index.html"
|
||||
class="flex flex-col hover:text-white transition-colors text-white/70 w-12 h-12 items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="activity" class="lucide lucide-activity w-[24px] h-[24px]">
|
||||
<path
|
||||
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2">
|
||||
</path>
|
||||
</svg></a>
|
||||
|
||||
<button
|
||||
class="flex flex-col items-center justify-center w-12 h-12 text-white/70 hover:text-white transition-colors mr-8"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="users" class="lucide lucide-users w-6 h-6">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
|
||||
<path d="M16 3.128a4 4 0 0 1 0 7.744"></path>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
</svg></button>
|
||||
<div class="absolute left-1/2 -translate-x-1/2 -top-6">
|
||||
<a href="entry-edit-step-1.html"
|
||||
class="flex border-[3px] hover:scale-105 transition-transform active:scale-95 group bg-[#3e3e3e] w-14 h-14 border-[#a7f3d0] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4)] items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="plus" class="lucide lucide-plus w-[28px] h-[28px]">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg></a>
|
||||
</div>
|
||||
|
||||
<a href="login.html"
|
||||
class="flex flex-col hover:text-white transition-colors text-white/70 w-12 h-12 items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="mail" class="lucide lucide-mail w-6 h-6">
|
||||
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||
</svg></a>
|
||||
|
||||
<a href="profile.html"
|
||||
class="flex flex-col hover:text-white transition-colors text-white/70 w-12 h-12 items-center justify-center"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="user" class="lucide lucide-user w-6 h-6">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App Interface Variations</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 1: Login -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex z-20 text-xs font-medium text-white mix-blend-difference pt-4 pr-6 pb-2 pl-6 items-center justify-between">
|
||||
<span class="">9:41</span>
|
||||
<div class="flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="signal" class="lucide lucide-signal w-3.5 h-3.5">
|
||||
<path d="M2 20h.01"></path>
|
||||
<path d="M7 20v-4"></path>
|
||||
<path d="M12 20v-8"></path>
|
||||
<path d="M17 20V8"></path>
|
||||
<path d="M22 4v16"></path>
|
||||
</svg><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="wifi" class="lucide lucide-wifi w-3.5 h-3.5">
|
||||
<path d="M12 20h.01"></path>
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0"></path>
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0"></path>
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0"></path>
|
||||
</svg>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col z-10 pr-8 pl-8 relative justify-center">
|
||||
<!-- Background Decorative Blur -->
|
||||
<div
|
||||
class="absolute top-[-10%] right-[-10%] w-64 h-64 bg-blue-600/20 rounded-full blur-[80px] pointer-events-none">
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-[-10%] left-[-10%] w-64 h-64 bg-purple-600/20 rounded-full blur-[80px] pointer-events-none">
|
||||
</div>
|
||||
|
||||
<div class="mb-12">
|
||||
<!-- Added Logo based on image provided -->
|
||||
<img src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/32e790c1-060e-4820-bca4-b639b2d815b0_320w.png"
|
||||
class="w-20 h-auto mb-6 drop-shadow-lg object-cover" alt="VΛYV Logo">
|
||||
|
||||
<p class="text-white/40 text-sm">Welcome back. Enter your credentials to access your VΛYV account.</p>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Username or Email</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="user"
|
||||
class="lucide lucide-user w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg></div>
|
||||
<input type="text" placeholder="name@example.com"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center ml-1">
|
||||
<label class="text-xs font-medium text-white/60">Password</label>
|
||||
<a href="#"
|
||||
class="text-[10px] text-blue-400 font-medium hover:text-blue-300 transition-colors">Forgot
|
||||
Password?</a>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="lock"
|
||||
class="lucide lucide-lock w-4 h-4 text-white/40 group-focus-within:text-blue-400 transition-colors">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg></div>
|
||||
<input type="password" placeholder="••••••••"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="index.html"
|
||||
class="w-full bg-white text-black font-semibold py-3.5 rounded-xl mt-4 shadow-[0_0_20px_rgba(255,255,255,0.1)] active:scale-[0.98] transition-all hover:bg-gray-100 flex items-center justify-center gap-2">
|
||||
Log In <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="arrow-right" class="lucide lucide-arrow-right w-4 h-4">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="m12 5 7 7-7 7"></path>
|
||||
</svg></a>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="mt-8 flex items-center justify-between gap-4">
|
||||
<div class="h-[1px] bg-white/10 flex-1"></div>
|
||||
<span class="text-[10px] text-white/30 uppercase tracking-widest">Or continue with</span>
|
||||
<div class="h-[1px] bg-white/10 flex-1"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 mt-6">
|
||||
<button
|
||||
class="flex-1 bg-white/5 border border-white/10 hover:bg-white/10 transition-colors rounded-xl py-2.5 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-white/5 border border-white/10 hover:bg-white/10 transition-colors rounded-xl py-2.5 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M24 12.276c0-.853-.076-1.674-.219-2.472H12v4.676h6.728c-.29 1.554-1.171 2.87-2.496 3.757v3.124h4.043c2.365-2.177 3.729-5.385 3.729-9.085z"
|
||||
fill="#4285F4"></path>
|
||||
<path
|
||||
d="M12 24.48c3.243 0 5.983-1.076 7.97-2.909l-4.043-3.124c-1.076.721-2.454 1.148-3.927 1.148-3.129 0-5.782-2.114-6.73-4.956H1.206v3.121C3.18 21.676 7.294 24.48 12 24.48z"
|
||||
fill="#34A853"></path>
|
||||
<path
|
||||
d="M5.27 14.639c-.24-.721-.378-1.492-.378-2.289s.138-1.568.378-2.289V6.936H1.206C.437 8.468 0 10.198 0 12.35s.437 3.882 1.206 5.414l4.064-3.125z"
|
||||
fill="#FBBC05"></path>
|
||||
<path
|
||||
d="M12 4.777c1.764 0 3.349.607 4.595 1.798l3.447-3.448C17.978 1.178 15.238 0 12 0 7.294 0 3.18 2.804 1.206 6.936l4.064 3.125C6.218 7.194 8.871 4.777 12 4.777z"
|
||||
fill="#EA4335"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-xs text-white/40">Don't have an account? <a href="signup.html"
|
||||
class="text-white font-medium cursor-pointer hover:underline">Sign up</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App Interface Variations</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Custom Range Slider Styling */
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-12 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 4: Personal Profile -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:44</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30 bg-[#09090b]/80 backdrop-blur-md">
|
||||
<a href="index.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Edit Profile</span>
|
||||
<button
|
||||
class="px-4 py-1.5 bg-white text-black text-xs font-semibold rounded-full hover:bg-gray-200 transition-colors">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-20 no-scrollbar">
|
||||
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex flex-col items-center my-6">
|
||||
<div class="relative w-24 h-24 mb-3">
|
||||
<img src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?ixlib=rb-4.0.3&auto=format&fit=crop&w=300&q=80"
|
||||
class="w-full h-full rounded-full object-cover border-2 border-white/10">
|
||||
<button
|
||||
class="absolute bottom-0 right-0 w-8 h-8 bg-[#2563eb] rounded-full flex items-center justify-center border-2 border-[#09090b] text-white hover:bg-blue-600 transition-colors">
|
||||
<i data-lucide="camera" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-white">Change Photo</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Personal Info Group -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider">Personal Information</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">First Name</label>
|
||||
<input type="text" value="Alex"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Last Name</label>
|
||||
<input type="text" value="Morgan"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Username</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-2.5 text-white/40 text-sm">@</span>
|
||||
<input type="text" value="alexmorgan"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg pl-7 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Gender</label>
|
||||
<select
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors appearance-none">
|
||||
<option>Male</option>
|
||||
<option>Female</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Date of Birth</label>
|
||||
<input type="date" value="1995-08-15"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Introduction & Bio -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider">About</h3>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Short Introduction</label>
|
||||
<input type="text" value="Digital Nomad & Photographer"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Bio</label>
|
||||
<textarea
|
||||
class="w-full h-24 bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors resize-none leading-relaxed">Capturing moments across the globe. Lover of coffee, sunsets, and good design.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Contact & Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider">Contact & Links</h3>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Email</label>
|
||||
<div class="relative">
|
||||
<i data-lucide="mail" class="absolute left-3 top-2.5 w-4 h-4 text-white/40"></i>
|
||||
<input type="email" value="alex@vayv.app"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[11px] font-medium text-white/60">Website</label>
|
||||
<div class="relative">
|
||||
<i data-lucide="link" class="absolute left-3 top-2.5 w-4 h-4 text-white/40"></i>
|
||||
<input type="url" value="https://alexmorgan.com"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full my-6"></div>
|
||||
|
||||
<!-- App Settings -->
|
||||
<div class="px-1">
|
||||
<h3 class="text-xs font-semibold text-white/40 uppercase tracking-wider mb-3">App Settings</h3>
|
||||
<div class="flex gap-3">
|
||||
<a href="settings.html"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded-2xl p-4 flex items-center justify-between hover:bg-white/10 transition-colors">
|
||||
<span class="text-sm font-medium text-white">Settings</span>
|
||||
<i data-lucide="settings" class="w-4 h-4 text-white/40"></i>
|
||||
</a>
|
||||
<a href="theme.html"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded-2xl p-4 flex items-center justify-between hover:bg-white/10 transition-colors">
|
||||
<span class="text-sm font-medium text-white">Theme</span>
|
||||
<i data-lucide="palette" class="w-4 h-4 text-white/40"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Settings</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Custom Range Slider Styling */
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-12 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 5: Account Settings -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:45</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
<a href="profile.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Settings</span>
|
||||
<div class="w-10"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-12 no-scrollbar">
|
||||
|
||||
<!-- Search Settings -->
|
||||
<div class="relative mb-6 mt-2">
|
||||
<i data-lucide="search" class="absolute left-3 top-2.5 w-4 h-4 text-white/40"></i>
|
||||
<input type="text" placeholder="Search settings..."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/20 transition-all placeholder-white/20">
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="mb-6">
|
||||
<h3 class="px-2 mb-2 text-xs font-semibold text-white/40 uppercase tracking-wider">Account</h3>
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-blue-500/20 rounded-lg text-blue-400"><i data-lucide="mail" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Email</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-white/40">alex@vayv.app</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-green-500/20 rounded-lg text-green-400"><i data-lucide="lock" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Password</span>
|
||||
</div>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</button>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-purple-500/20 rounded-lg text-purple-400"><i data-lucide="shield-check"
|
||||
class="w-4 h-4"></i></div>
|
||||
<span class="text-sm font-medium text-white/90">Two-Factor Auth</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-[10px] bg-green-500/20 text-green-400 px-2 py-0.5 rounded font-medium border border-green-500/20">Enabled</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Section -->
|
||||
<div class="mb-6">
|
||||
<h3 class="px-2 mb-2 text-xs font-semibold text-white/40 uppercase tracking-wider">Billing & Plan</h3>
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-yellow-500/20 rounded-lg text-yellow-400"><i data-lucide="zap" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Plan</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] bg-white text-black px-2 py-0.5 rounded-full font-bold">PRO</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<button class="w-full px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-gray-500/20 rounded-lg text-gray-400"><i data-lucide="credit-card"
|
||||
class="w-4 h-4"></i></div>
|
||||
<span class="text-sm font-medium text-white/90">Billing Method</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-white/40">•••• 4242</span>
|
||||
<i data-lucide="chevron-right"
|
||||
class="w-4 h-4 text-white/20 group-hover:text-white/60 transition-colors"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences -->
|
||||
<div class="mb-8">
|
||||
<h3 class="px-2 mb-2 text-xs font-semibold text-white/40 uppercase tracking-wider">Preferences</h3>
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<div class="w-full px-4 py-3.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-pink-500/20 rounded-lg text-pink-400"><i data-lucide="bell" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Notifications</span>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 h-6 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="toggle" id="toggle"
|
||||
class="toggle-checkbox absolute block w-4 h-4 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-300 top-1 right-5 border-[#333]">
|
||||
<label for="toggle"
|
||||
class="toggle-label block overflow-hidden h-6 rounded-full bg-white/10 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[1px] bg-white/5 w-full ml-12"></div>
|
||||
<div class="w-full px-4 py-3.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 bg-orange-500/20 rounded-lg text-orange-400"><i data-lucide="moon" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white/90">Dark Mode</span>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 h-6 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="toggle2" id="toggle2"
|
||||
class="toggle-checkbox absolute block w-4 h-4 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-300 right-0 border-[#2563eb]"
|
||||
checked="">
|
||||
<label for="toggle2"
|
||||
class="toggle-label block overflow-hidden h-6 rounded-full bg-[#2563eb] cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<button
|
||||
class="w-full bg-red-500/10 border border-red-500/20 rounded-2xl py-3.5 flex items-center justify-center gap-2 text-red-400 font-medium hover:bg-red-500/20 transition-colors">
|
||||
<i data-lucide="log-out" class="w-4 h-4"></i>
|
||||
Log Out
|
||||
</button>
|
||||
|
||||
<p class="text-center text-[10px] text-white/20 mt-6">Version 2.4.0 (Build 302)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Sign Up</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<!-- Fonts included similarly to login.html -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-16 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 2: Registration -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span class="">9:42</span>
|
||||
<div class="flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" data-lucide="signal" class="lucide lucide-signal w-3.5 h-3.5">
|
||||
<path d="M2 20h.01"></path>
|
||||
<path d="M7 20v-4"></path>
|
||||
<path d="M12 20v-8"></path>
|
||||
<path d="M17 20V8"></path>
|
||||
<path d="M22 4v16"></path>
|
||||
</svg><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" data-lucide="wifi"
|
||||
class="lucide lucide-wifi w-3.5 h-3.5">
|
||||
<path d="M12 20h.01"></path>
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0"></path>
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0"></path>
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0"></path>
|
||||
</svg>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex z-30 pt-3 pr-4 pb-3 pl-4 items-center">
|
||||
<a href="login.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="arrow-left" class="lucide lucide-arrow-left w-6 h-6">
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
<path d="M19 12H5"></path>
|
||||
</svg></a>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-8 pb-8 no-scrollbar">
|
||||
<div class="mb-8">
|
||||
<!-- Added Logo based on image provided -->
|
||||
<img
|
||||
src="https://hoirqrkdgbmvpwutwuwj.supabase.co/storage/v1/object/public/assets/assets/32e790c1-060e-4820-bca4-b639b2d815b0_320w.png"
|
||||
class="w-20 h-auto mb-6 drop-shadow-lg object-cover" alt="VΛYV Logo">
|
||||
<h1 class="font-['Inter'] text-2xl tracking-tight font-semibold text-white mb-2">Create Account</h1>
|
||||
<p class="text-white/40 text-sm">Join VΛYV to start capturing your journey.</p>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-5">
|
||||
<!-- Names -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">First Name</label>
|
||||
<input type="text"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all font-medium placeholder-white/20">
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Last Name</label>
|
||||
<input type="text"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all font-medium placeholder-white/20">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username (Mandatory) -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Username <span class="text-blue-400">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><span
|
||||
class="text-white/40 text-sm font-medium">@</span></div>
|
||||
<input type="text" placeholder="username"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-8 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gender & DOB -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Gender</label>
|
||||
<div class="relative">
|
||||
<select
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-4 pr-8 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all appearance-none font-medium">
|
||||
<option>Select</option>
|
||||
<option>Male</option>
|
||||
<option>Female</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="chevron-down" class="lucide lucide-chevron-down w-4 h-4 text-white/40">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Date of Birth</label>
|
||||
<input type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all font-medium text-white/60">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email (Mandatory) -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Email <span class="text-blue-400">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="mail" class="lucide lucide-mail w-4 h-4 text-white/40">
|
||||
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||
</svg></div>
|
||||
<input type="email" placeholder="name@example.com"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password (Mandatory) -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium text-white/60 ml-1">Password <span class="text-blue-400">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
data-lucide="lock" class="lucide lucide-lock w-4 h-4 text-white/40">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg></div>
|
||||
<input type="password" placeholder="Create a strong password"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-all placeholder-white/20 font-medium">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full bg-white text-black font-semibold py-3.5 rounded-xl mt-4 shadow-[0_0_20px_rgba(255,255,255,0.1)] active:scale-[0.98] transition-all hover:bg-gray-100 flex items-center justify-center gap-2">
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-white/40 leading-relaxed px-4">By creating an account, you agree to our <a href="#"
|
||||
class="text-white/60 underline">Terms of Service</a> and <a href="#" class="text-white/60 underline">Privacy
|
||||
Policy</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-xs text-white/40">Already have an account? <a href="login.html"
|
||||
class="text-white font-medium cursor-pointer hover:underline">Log in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VΛYV App - Theme Settings</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
.bg-app-gradient {
|
||||
background: linear-gradient(180deg, #2e1065 0%, #2563eb 45%, #86efac 100%);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Custom Range Slider Styling */
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist">
|
||||
.font-geist {
|
||||
font-family: 'Geist', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-roboto" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-roboto">
|
||||
.font-roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-montserrat" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-montserrat">
|
||||
.font-montserrat {
|
||||
font-family: 'Montserrat', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-poppins" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-poppins">
|
||||
.font-poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-playfair" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-playfair">
|
||||
.font-playfair {
|
||||
font-family: 'Playfair Display', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-instrument-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:wght@400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-instrument-serif">
|
||||
.font-instrument-serif {
|
||||
font-family: 'Instrument Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-merriweather" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap">
|
||||
<style id="all-fonts-style-font-merriweather">
|
||||
.font-merriweather {
|
||||
font-family: 'Merriweather', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-bricolage" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-bricolage">
|
||||
.font-bricolage {
|
||||
font-family: 'Bricolage Grotesque', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-jakarta" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-jakarta">
|
||||
.font-jakarta {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-manrope" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-manrope">
|
||||
.font-manrope {
|
||||
font-family: 'Manrope', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-grotesk" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-grotesk">
|
||||
.font-space-grotesk {
|
||||
font-family: 'Space Grotesk', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-work-sans" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-work-sans">
|
||||
.font-work-sans {
|
||||
font-family: 'Work Sans', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-pt-serif" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=PT+Serif:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-pt-serif">
|
||||
.font-pt-serif {
|
||||
font-family: 'PT Serif', serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-geist-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-geist-mono">
|
||||
.font-geist-mono {
|
||||
font-family: 'Geist Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-space-mono" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap">
|
||||
<style id="all-fonts-style-font-space-mono">
|
||||
.font-space-mono {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-quicksand" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap">
|
||||
<style id="all-fonts-style-font-quicksand">
|
||||
.font-quicksand {
|
||||
font-family: 'Quicksand', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
<link id="all-fonts-link-font-nunito" rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&display=swap">
|
||||
<style id="all-fonts-style-font-nunito">
|
||||
.font-nunito {
|
||||
font-family: 'Nunito', sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="flex flex-wrap items-center justify-center min-h-screen gap-12 antialiased selection:bg-blue-500/30 text-white font-['Inter'] bg-gray-100 p-8">
|
||||
|
||||
<!-- Screen 6: Theme Settings -->
|
||||
<div
|
||||
class="relative w-full max-w-[390px] h-[844px] bg-[#09090b] shadow-2xl overflow-hidden sm:rounded-[3rem] flex flex-col ring-8 ring-black/5">
|
||||
<div
|
||||
class="flex justify-between items-center px-6 pt-4 pb-2 text-xs font-medium z-20 text-white mix-blend-difference">
|
||||
<span>9:46</span>
|
||||
<div class="flex items-center gap-1.5"><i data-lucide="signal" class="w-3.5 h-3.5"></i><i data-lucide="wifi"
|
||||
class="w-3.5 h-3.5"></i>
|
||||
<div class="w-5 h-2.5 border border-white/40 rounded-[3px] relative">
|
||||
<div class="absolute inset-0.5 bg-white rounded-[1px] w-[70%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center px-4 py-3 sticky top-0 z-30">
|
||||
<a href="profile.html"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/90 bg-black/20 backdrop-blur-md rounded-full hover:bg-black/40 transition-colors"><i
|
||||
data-lucide="chevron-left" class="w-6 h-6"></i></a>
|
||||
|
||||
<span class="text-sm font-semibold tracking-tight text-white">Theme</span>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors hover:bg-white/10 rounded-full"><i
|
||||
data-lucide="rotate-ccw" class="w-5 h-5"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-12 no-scrollbar">
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div
|
||||
class="w-full h-40 bg-gradient-to-b from-white/5 to-transparent border border-white/10 rounded-2xl mt-4 mb-8 flex flex-col items-center justify-center relative overflow-hidden group">
|
||||
<div
|
||||
class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-500 via-transparent to-transparent transition-colors duration-500">
|
||||
</div>
|
||||
<!-- Mini Chart Preview -->
|
||||
<svg viewBox="0 0 200 100"
|
||||
class="w-full h-full text-blue-500 fill-none px-4 pt-8 transition-colors duration-300">
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.5"></stop>
|
||||
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Main Line -->
|
||||
<path d="M0,80 C20,80 40,30 60,40 C80,50 100,80 120,60 C140,40 160,20 200,30" class="stroke-current stroke-2"
|
||||
style="filter: drop-shadow(0px 4px 6px rgba(59, 130, 246, 0.5));"></path>
|
||||
<!-- Secondary Lines for 'Count' preview -->
|
||||
<path d="M0,85 C20,85 40,35 60,45 C80,55 100,85 120,65 C140,45 160,25 200,35"
|
||||
class="stroke-current stroke-[1.5] opacity-50"></path>
|
||||
<path d="M0,90 C20,90 40,40 60,50 C80,60 100,90 120,70 C140,50 160,30 200,40"
|
||||
class="stroke-current stroke-1 opacity-20"></path>
|
||||
|
||||
<path d="M0,80 C20,80 40,30 60,40 C80,50 100,80 120,60 C140,40 160,20 200,30 V100 H0 Z"
|
||||
class="stroke-none fill-blue-500/10"></path>
|
||||
</svg>
|
||||
<div class="absolute bottom-3 left-4 text-[10px] font-medium text-white/40 uppercase tracking-widest">Preview
|
||||
</div>
|
||||
|
||||
<!-- Floating Thumbnail Preview -->
|
||||
<div
|
||||
class="absolute top-6 left-1/2 -translate-x-1/2 w-8 h-8 rounded-full bg-white shadow-lg overflow-hidden border border-white/20">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?ixlib=rb-4.0.3&auto=format&fit=crop&w=300&q=80"
|
||||
class="w-full h-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background Color Selection -->
|
||||
<div class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-4">
|
||||
<h3 class="text-xs font-semibold text-white/90 tracking-tight">Background Color</h3>
|
||||
<span class="text-[10px] text-white/40">10 presets</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-5 gap-3">
|
||||
<button
|
||||
class="group relative aspect-square rounded-xl bg-[#09090b] border-2 border-white/20 hover:border-white/40 transition-all ring-2 ring-white ring-offset-2 ring-offset-[#09090b] shadow-lg">
|
||||
<div class="absolute inset-0 flex items-center justify-center opacity-100 transition-opacity">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-white"></i>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#0f172a] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#171717] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#18181b] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#172554] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#1e1b4b] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-[#2e1065] border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-gradient-to-br from-cyan-500 via-blue-500 to-emerald-500 border border-white/10 hover:scale-105 transition-all"></button>
|
||||
<button
|
||||
class="relative aspect-square rounded-xl bg-gradient-to-br from-rose-500 via-orange-500 to-amber-500 border border-white/10 hover:scale-105 transition-all"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wave & Appearance Settings -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold text-white/90 tracking-tight">Wave & Appearance</h3>
|
||||
<button class="text-[10px] text-blue-400 font-medium hover:text-blue-300">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl p-5 space-y-6">
|
||||
|
||||
<!-- Wave Color Selection -->
|
||||
<div>
|
||||
<label class="text-xs font-medium text-white/60 block mb-3">Wave Color</label>
|
||||
<div class="grid grid-cols-10 gap-2">
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-blue-500 ring-2 ring-white ring-offset-2 ring-offset-[#09090b] cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-purple-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-emerald-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-rose-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-white hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-amber-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-cyan-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-lime-400 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-fuchsia-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-orange-500 hover:ring-2 hover:ring-white/20 hover:ring-offset-2 hover:ring-offset-[#09090b] transition-all cursor-pointer"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Thumbnail Image Radius -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="text-xs font-medium text-white/60">Thumbnail Radius</label>
|
||||
<span class="text-xs font-mono text-white">50%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<i data-lucide="square" class="w-4 h-4 text-white/20"></i>
|
||||
<div class="relative w-full h-6 flex items-center">
|
||||
<input type="range" min="0" max="100" value="50"
|
||||
class="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer z-20">
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-blue-500 rounded-l-lg pointer-events-none"
|
||||
style="width: 50%"></div>
|
||||
</div>
|
||||
<i data-lucide="circle" class="w-4 h-4 text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-[1px] bg-white/5 w-full"></div>
|
||||
|
||||
<!-- Curvyness -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="text-xs font-medium text-white/60">Curvyness</label>
|
||||
<span class="text-xs font-mono text-white">75%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<i data-lucide="activity" class="w-4 h-4 text-white/20"></i>
|
||||
<div class="relative w-full h-6 flex items-center">
|
||||
<input type="range" min="0" max="100" value="75"
|
||||
class="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer z-20">
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-blue-500 rounded-l-lg pointer-events-none"
|
||||
style="width: 75%"></div>
|
||||
</div>
|
||||
<i data-lucide="waves" class="w-4 h-4 text-white/80"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line Count -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="text-xs font-medium text-white/60">Count of Lines</label>
|
||||
<span class="text-xs font-mono text-white">3</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-medium text-white/20 w-4 text-center">1</span>
|
||||
<div class="relative w-full h-6 flex items-center">
|
||||
<input type="range" min="1" max="10" value="3"
|
||||
class="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer z-20">
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-blue-500 rounded-l-lg pointer-events-none"
|
||||
style="width: 30%"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white/80 w-4 text-center">10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show Grid Lines Toggle -->
|
||||
<div class="bg-white/5 border border-white/10 rounded-2xl p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-white/5 rounded-lg text-white/80"><i data-lucide="grid" class="w-4 h-4"></i></div>
|
||||
<span class="text-xs font-medium text-white/80">Show Grid Lines</span>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 h-6 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="toggle3" id="toggle3"
|
||||
class="toggle-checkbox absolute block w-4 h-4 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-300 top-1 right-5 border-[#333]">
|
||||
<label for="toggle3"
|
||||
class="toggle-label block overflow-hidden h-6 rounded-full bg-white/10 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -2,12 +2,6 @@
|
|||
|
||||
Thats me Quasar Project
|
||||
|
||||
## Version
|
||||
|
||||
Aktuelle Frontend-Version: `0.0.1`
|
||||
|
||||
Die sichtbare App-Version wird in `src/config/appVersion.js` gepflegt und muss bei weiteren Entwicklungsschritten zusammen mit `package.json` und relevanten Dokumenten aktualisiert werden.
|
||||
|
||||
## Install the dependencies
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<template>
|
||||
<button class="add-event-btn glass--button" @click="$emit('click')">
|
||||
<q-icon name="add" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const $q = useQuasar()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-event-btn {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
<template>
|
||||
<ModalCard
|
||||
:open="open"
|
||||
title="Allgemein"
|
||||
:tabs="tabs"
|
||||
v-model="activeTab"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<!-- Allgemein -->
|
||||
<div v-if="activeTab === 'general'">
|
||||
<div class="settings-section__title">Allgemein</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Aussehen -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">Aussehen</span>
|
||||
<div class="settings-row__control">
|
||||
<select
|
||||
:value="settingsStore.appearance"
|
||||
@change="onAppearanceChange"
|
||||
class="settings-select"
|
||||
:class="{ 'settings-select--dark': isDark }"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Hell</option>
|
||||
<option value="dark">Dunkel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Akzentfarbe -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">Akzentfarbe</span>
|
||||
<div class="settings-row__control">
|
||||
<button
|
||||
class="settings-accent-btn"
|
||||
:class="{ 'settings-accent-btn--dark': isDark }"
|
||||
@click="accentDropdownOpen = !accentDropdownOpen"
|
||||
>
|
||||
<span
|
||||
class="settings-accent-dot"
|
||||
:style="{ background: currentAccentHex }"
|
||||
/>
|
||||
<span>{{ currentAccentLabel }}</span>
|
||||
<q-icon name="expand_more" size="16px" />
|
||||
</button>
|
||||
|
||||
<!-- Accent dropdown -->
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="accentDropdownOpen"
|
||||
class="settings-dropdown"
|
||||
:class="{ 'settings-dropdown--dark': isDark }"
|
||||
>
|
||||
<button
|
||||
v-for="color in ACCENT_COLORS"
|
||||
:key="color.value"
|
||||
class="settings-dropdown__item"
|
||||
@click="selectAccent(color.value)"
|
||||
>
|
||||
<span class="settings-accent-dot" :style="{ background: color.hex }" />
|
||||
<span>{{ color.label }}</span>
|
||||
<q-icon
|
||||
v-if="settingsStore.accentColor === color.value"
|
||||
name="check"
|
||||
size="18px"
|
||||
class="settings-dropdown__check"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Sprache -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">Sprache</span>
|
||||
<div class="settings-row__control">
|
||||
<select
|
||||
:value="settingsStore.language"
|
||||
@change="e => { settingsStore.language = e.target.value }"
|
||||
class="settings-select"
|
||||
:class="{ 'settings-select--dark': isDark }"
|
||||
>
|
||||
<option v-for="lang in LANGUAGES" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- FPS-Anzeige -->
|
||||
<div class="settings-row">
|
||||
<span class="settings-row__label">FPS-Anzeige</span>
|
||||
<div class="settings-row__control">
|
||||
<q-toggle
|
||||
:model-value="settingsStore.showFps"
|
||||
@update:model-value="v => { settingsStore.showFps = v }"
|
||||
dense
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benachrichtigungen (placeholder) -->
|
||||
<div v-else-if="activeTab === 'notifications'">
|
||||
<div class="settings-section__title">Benachrichtigungen</div>
|
||||
<div class="settings-section__divider" />
|
||||
<p class="settings-placeholder">Kommt bald.</p>
|
||||
</div>
|
||||
|
||||
<!-- Personalisierung (placeholder) -->
|
||||
<div v-else-if="activeTab === 'personalize'">
|
||||
<div class="settings-section__title">Personalisierung</div>
|
||||
<div class="settings-section__divider" />
|
||||
<p class="settings-placeholder">Kommt bald.</p>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import ModalCard from 'components/ModalCard.vue'
|
||||
import { useSettingsStore, ACCENT_COLORS, LANGUAGES } from 'stores/settings'
|
||||
|
||||
defineProps({ open: { type: Boolean, default: false } })
|
||||
defineEmits(['close'])
|
||||
|
||||
const $q = useQuasar()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
|
||||
const activeTab = ref('general')
|
||||
const accentDropdownOpen = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ value: 'general', label: 'Allgemein', icon: 'settings' },
|
||||
{ value: 'notifications', label: 'Benachrichtigungen', icon: 'notifications' },
|
||||
{ value: 'personalize', label: 'Personalisierung', icon: 'history' }
|
||||
]
|
||||
|
||||
const currentAccentHex = computed(() => {
|
||||
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
||||
return found?.hex ?? '#9e9e9e'
|
||||
})
|
||||
|
||||
const currentAccentLabel = computed(() => {
|
||||
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
|
||||
return found?.label ?? 'Standard'
|
||||
})
|
||||
|
||||
function selectAccent(value) {
|
||||
settingsStore.accentColor = value
|
||||
accentDropdownOpen.value = false
|
||||
}
|
||||
|
||||
function onAppearanceChange(e) {
|
||||
const value = e.target.value
|
||||
settingsStore.appearance = value
|
||||
applyAppearance(value)
|
||||
}
|
||||
|
||||
function applyAppearance(mode) {
|
||||
if (mode === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
$q.dark.set(prefersDark)
|
||||
} else {
|
||||
$q.dark.set(mode === 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
// Apply appearance on mount and when it changes externally
|
||||
watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-section__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-section__divider {
|
||||
height: 1px;
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.settings-row__label {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.settings-row__control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Native select styled */
|
||||
.settings-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 6px 28px 6px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'%3E%3Cpath fill='%23999' d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
}
|
||||
|
||||
.settings-select--dark {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Accent button */
|
||||
.settings-accent-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-accent-btn--dark {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-accent-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Accent Dropdown */
|
||||
.settings-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
min-width: 180px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.settings-dropdown--dark {
|
||||
background: rgba(40, 40, 40, 0.92);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-dropdown__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.settings-dropdown__item:hover {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.settings-dropdown__check {
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Placeholder text */
|
||||
.settings-placeholder {
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,546 +0,0 @@
|
|||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="eventsStore.panelOpen"
|
||||
class="event-panel glass--panel"
|
||||
:style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}"
|
||||
:class="{ 'event-panel--dragging': isDragging }"
|
||||
>
|
||||
<!-- Drag Handle — drag to resize, tap to close -->
|
||||
<div
|
||||
class="event-panel__handle"
|
||||
v-on="handleListeners"
|
||||
>
|
||||
<div class="event-panel__handle-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="event-panel__scroll">
|
||||
<!-- Key Image -->
|
||||
<div class="event-panel__image-section">
|
||||
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
|
||||
<img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" />
|
||||
<span class="event-panel__image-badge">Key Image</span>
|
||||
</div>
|
||||
<div v-else class="event-panel__image-placeholder" @click="onAddImage">
|
||||
<q-icon name="add_photo_alternate" size="32px" color="grey-5" />
|
||||
<span>Key Image hinzufügen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title — large, editable inline -->
|
||||
<q-input
|
||||
v-model="eventsStore.ghostTitle"
|
||||
placeholder="Was ist passiert?"
|
||||
borderless
|
||||
class="event-panel__title"
|
||||
input-class="event-panel__title-input"
|
||||
:dark="isDark"
|
||||
/>
|
||||
|
||||
<!-- Date row — tap to open QDate picker -->
|
||||
<div class="event-panel__date-row">
|
||||
<q-icon name="event" size="18px" class="event-panel__date-icon" />
|
||||
<span class="event-panel__date-label">{{ formattedDate }}</span>
|
||||
<q-popup-proxy transition-show="scale" transition-hide="scale">
|
||||
<q-date
|
||||
v-model="ghostDateSlash"
|
||||
mask="YYYY/MM/DD"
|
||||
:dark="isDark"
|
||||
:locale="dateLocale"
|
||||
minimal
|
||||
/>
|
||||
</q-popup-proxy>
|
||||
</div>
|
||||
|
||||
<!-- Emotional Level — card style with gradient track -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<div class="event-panel__card-header">
|
||||
<span class="event-panel__card-label">Emotional Level</span>
|
||||
<span class="event-panel__emotion-value" :style="{ color: emotionColor }">
|
||||
{{ emotionLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Gradient slider -->
|
||||
<div class="event-panel__slider-wrap">
|
||||
<q-slider
|
||||
v-model="eventsStore.ghostEmotion"
|
||||
:min="-1"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
track-size="8px"
|
||||
thumb-size="22px"
|
||||
class="event-panel__slider"
|
||||
:style="{
|
||||
'--gradient-bg': sliderGradientCSS,
|
||||
'--thumb-color': emotionColor
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="event-panel__slider-hints">
|
||||
<span>Sehr negativ</span>
|
||||
<span>Neutral</span>
|
||||
<span>Sehr positiv</span>
|
||||
</div>
|
||||
|
||||
<!-- Gradient Preset Selector -->
|
||||
<div class="event-panel__presets">
|
||||
<span class="event-panel__presets-label">Farbverlauf</span>
|
||||
<div class="event-panel__presets-grid">
|
||||
<div
|
||||
v-for="(preset, index) in gradientPresets"
|
||||
:key="index"
|
||||
class="event-panel__preset"
|
||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === index }"
|
||||
:style="{ background: presetGradientCSS(preset.colors) }"
|
||||
:title="preset.name"
|
||||
@click="selectPreset(index)"
|
||||
></div>
|
||||
<!-- "None" option to clear preset -->
|
||||
<div
|
||||
class="event-panel__preset event-panel__preset--none"
|
||||
:class="{ 'event-panel__preset--active': eventsStore.ghostGradientPreset === null }"
|
||||
title="Standard"
|
||||
@click="selectPreset(null)"
|
||||
>
|
||||
<q-icon name="auto_awesome" size="12px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beschreibung — card style -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<span class="event-panel__card-label">Beschreibung</span>
|
||||
<q-input
|
||||
v-model="eventsStore.ghostNote"
|
||||
type="textarea"
|
||||
placeholder="Ein neutraler, aber wichtiger Tag"
|
||||
borderless
|
||||
autogrow
|
||||
class="event-panel__note"
|
||||
:dark="isDark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Weitere Medien -->
|
||||
<div class="event-panel__card" :class="{ 'event-panel__card--dark': isDark }">
|
||||
<span class="event-panel__card-label">Weitere Medien</span>
|
||||
<div class="event-panel__media-grid">
|
||||
<div class="event-panel__media-add" @click="onAddMedia">
|
||||
<q-icon name="add_photo_alternate" size="24px" color="grey-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete (edit mode only) -->
|
||||
<div v-if="eventsStore.editingEventId" class="event-panel__delete">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
label="Event löschen"
|
||||
icon="delete_outline"
|
||||
color="negative"
|
||||
size="sm"
|
||||
@click="eventsStore.deleteEvent(eventsStore.editingEventId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useEventsStore, emotionToColor, GRADIENT_PRESETS } from 'stores/events'
|
||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||
import { resolveFullRes } from 'composables/useImageCache'
|
||||
|
||||
const $q = useQuasar()
|
||||
const eventsStore = useEventsStore()
|
||||
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
|
||||
|
||||
// Resolve key image: full-res when online, cached thumbnail when offline
|
||||
const keyImageSrc = ref(null)
|
||||
watch(
|
||||
() => eventsStore.ghostImage,
|
||||
async (img) => {
|
||||
keyImageSrc.value = await resolveFullRes(img)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Reset height when panel opens
|
||||
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const gradientPresets = GRADIENT_PRESETS
|
||||
|
||||
// Current glow color based on emotion + gradient
|
||||
const emotionColor = computed(() => {
|
||||
if (eventsStore.ghostCustomColor) return eventsStore.ghostCustomColor
|
||||
return emotionToColor(eventsStore.ghostEmotion, eventsStore.ghostGradientPreset)
|
||||
})
|
||||
|
||||
// Date: store uses YYYY-MM-DD, QDate uses YYYY/MM/DD
|
||||
const ghostDateSlash = computed({
|
||||
get: () => eventsStore.ghostDate.replace(/-/g, '/'),
|
||||
set: (val) => { eventsStore.ghostDate = val.replace(/\//g, '-') }
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const d = new Date(eventsStore.ghostDate)
|
||||
if (isNaN(d.getTime())) return eventsStore.ghostDate
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
const dateLocale = {
|
||||
days: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
|
||||
daysShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
|
||||
months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
|
||||
monthsShort: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
}
|
||||
|
||||
const emotionLabel = computed(() => {
|
||||
const e = eventsStore.ghostEmotion
|
||||
if (e > 0.5) return 'Sehr positiv'
|
||||
if (e > 0.15) return 'Positiv'
|
||||
if (e > -0.15) return 'Neutral'
|
||||
if (e > -0.5) return 'Negativ'
|
||||
return 'Sehr negativ'
|
||||
})
|
||||
|
||||
// CSS gradient for the slider track
|
||||
const sliderGradientCSS = computed(() => {
|
||||
const idx = eventsStore.ghostGradientPreset
|
||||
if (idx !== null && GRADIENT_PRESETS[idx]) {
|
||||
const [neg, mid, pos] = GRADIENT_PRESETS[idx].colors
|
||||
return `linear-gradient(90deg, ${neg} 0%, ${mid} 50%, ${pos} 100%)`
|
||||
}
|
||||
// Default gradient matching the default emotionToColor
|
||||
return 'linear-gradient(90deg, #E91E63 0%, #9C27B0 20%, #2196F3 35%, #FFD700 50%, #FF6B35 65%, #FFD700 80%, #4CAF50 100%)'
|
||||
})
|
||||
|
||||
// CSS gradient for a preset swatch
|
||||
function presetGradientCSS(colors) {
|
||||
return `linear-gradient(90deg, ${colors[0]}, ${colors[1]}, ${colors[2]})`
|
||||
}
|
||||
|
||||
function selectPreset(index) {
|
||||
eventsStore.ghostGradientPreset = index
|
||||
// Clear custom color when selecting a gradient
|
||||
eventsStore.ghostCustomColor = null
|
||||
}
|
||||
|
||||
function onAddImage() {
|
||||
// TODO: File picker for key image
|
||||
}
|
||||
|
||||
function onAddMedia() {
|
||||
// TODO: File picker for additional media
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 75dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
|
||||
.event-panel--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.event-panel__handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.event-panel__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.event-panel__handle-bar {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.event-panel__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 32px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Key Image */
|
||||
.event-panel__image-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-panel__image-wrap {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-panel__image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.event-panel__image-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.event-panel__image-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 120px;
|
||||
border-radius: 16px;
|
||||
border: 2px dashed rgba(128, 128, 128, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__image-placeholder:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.event-panel__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-panel__title :deep(.event-panel__title-input) {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Date row */
|
||||
.event-panel__date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__date-row:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.event-panel__date-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-panel__date-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Card sections */
|
||||
.event-panel__card {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid rgba(128, 128, 128, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.event-panel__card--dark {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.event-panel__card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.event-panel__card-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.event-panel__emotion-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Slider with gradient track via Quasar deep styling */
|
||||
.event-panel__slider-wrap {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* The actual visible track bar — apply gradient here */
|
||||
.event-panel__slider :deep(.q-slider__track) {
|
||||
background: var(--gradient-bg) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Hide the default grey inner bar */
|
||||
.event-panel__slider :deep(.q-slider__inner) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Hide the blue selection bar */
|
||||
.event-panel__slider :deep(.q-slider__selection) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Thumb — SVG-based, use color for fill/stroke */
|
||||
.event-panel__slider :deep(.q-slider__thumb) {
|
||||
color: var(--thumb-color, #fff);
|
||||
}
|
||||
|
||||
.event-panel__slider :deep(.q-slider__thumb-shape) {
|
||||
filter: drop-shadow(0 0 6px var(--thumb-color, #fff));
|
||||
}
|
||||
|
||||
.event-panel__slider :deep(.q-slider__focus-ring) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.event-panel__slider-hints {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
opacity: 0.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Gradient Preset Selector */
|
||||
.event-panel__presets {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.event-panel__presets-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
opacity: 0.5;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-panel__presets-grid {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-panel__preset {
|
||||
width: 45px;
|
||||
height: 25px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 2px solid #eee;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.event-panel__preset:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.event-panel__preset--active {
|
||||
border-color: currentColor;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 0 1px rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.event-panel__preset--none {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Note */
|
||||
.event-panel__note {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Media grid */
|
||||
.event-panel__media-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-panel__media-add {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
border: 2px dashed rgba(128, 128, 128, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.event-panel__media-add:hover {
|
||||
border-color: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
|
||||
/* Delete */
|
||||
.event-panel__delete {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Slide-up transition */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,575 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="floating-lines-container"
|
||||
:style="containerStyle"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
WebGLRenderer,
|
||||
PlaneGeometry,
|
||||
Mesh,
|
||||
ShaderMaterial,
|
||||
Vector3,
|
||||
Vector2,
|
||||
Clock
|
||||
} from 'three'
|
||||
|
||||
const props = defineProps({
|
||||
lineCount: { type: [Array, Number], default: () => [10] },
|
||||
numPoints: { type: Number, default: 0 },
|
||||
pointXValues: { type: Array, default: () => [] },
|
||||
pointYValues: { type: Array, default: () => [] },
|
||||
pointColors: { type: Array, default: () => [] },
|
||||
lineSpread: { type: Number, default: 0.05 },
|
||||
fanSpread: { type: Number, default: 0.05 },
|
||||
lineSharpness: { type: Number, default: 8.0 },
|
||||
waveFrequency: { type: Number, default: 7.0 },
|
||||
bezierCurvature: { type: Number, default: 0.2 },
|
||||
circleRadiusPx: { type: Number, default: 75 },
|
||||
circleGlowSize: { type: Number, default: 18 },
|
||||
circleGlowStrength: { type: Number, default: 1.5 },
|
||||
lineBrightness: { type: Number, default: 1.0 },
|
||||
scrollContainer: { type: Object, default: null },
|
||||
scrollUvScale: { type: Number, default: 0 },
|
||||
animationSpeed: { type: Number, default: 1 },
|
||||
linesGradient: { type: Array, default: () => ['#e947f5', '#2f4ba2', '#0a0a12'] },
|
||||
bgColorCenter: { type: String, default: '#0a0514' },
|
||||
bgColorEdge: { type: String, default: '#000000' },
|
||||
backgroundImage: { type: String, default: '' },
|
||||
mixBlendMode: { type: String, default: 'screen' },
|
||||
parallax: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
// FPS display
|
||||
const fpsDisplay = ref(0)
|
||||
const dprDisplay = ref('0.0')
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
const style = {}
|
||||
if (props.backgroundImage) {
|
||||
style.backgroundImage = `url('${props.backgroundImage}')`
|
||||
style.backgroundSize = 'cover'
|
||||
style.backgroundPosition = 'center'
|
||||
style.backgroundRepeat = 'no-repeat'
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
// --- Shader Definitions ---
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
void main() {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragmentShader = `
|
||||
precision mediump float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float animationSpeed;
|
||||
|
||||
uniform int middleLineCount;
|
||||
uniform int numPoints;
|
||||
uniform float pointX[16];
|
||||
uniform float pointY[16];
|
||||
uniform float lineSpread;
|
||||
uniform float fanSpread;
|
||||
uniform float lineSharpness;
|
||||
uniform float waveFrequency;
|
||||
uniform float bezierCurvature;
|
||||
uniform float lineBrightness;
|
||||
uniform vec3 pointColor[16];
|
||||
|
||||
uniform bool parallax;
|
||||
uniform vec2 parallaxOffset;
|
||||
|
||||
uniform vec3 lineGradient[8];
|
||||
uniform int lineGradientCount;
|
||||
uniform vec3 bgColorCenter;
|
||||
uniform vec3 bgColorEdge;
|
||||
|
||||
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
||||
float bestT = 0.0;
|
||||
float bestD = 1e9;
|
||||
for (int k = 0; k <= 8; ++k) {
|
||||
float t = float(k) / 8.0;
|
||||
float mt = 1.0 - t;
|
||||
vec2 b = mt*mt*p0 + 2.0*mt*t*pc + t*t*p1;
|
||||
float d = dot(q - b, q - b);
|
||||
if (d < bestD) { bestD = d; bestT = t; }
|
||||
}
|
||||
|
||||
vec2 A = pc - p0;
|
||||
vec2 B = p0 - 2.0*pc + p1;
|
||||
vec2 D = p0 - q;
|
||||
float a = 2.0*dot(B,B);
|
||||
float bco = 6.0*dot(A,B);
|
||||
float c = 4.0*dot(A,A) + 2.0*dot(D,B);
|
||||
float dco = 2.0*dot(D,A);
|
||||
|
||||
float t = clamp(bestT, 0.001, 0.999);
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
float f = a*t*t*t + bco*t*t + c*t + dco;
|
||||
float fp = 3.0*a*t*t + 2.0*bco*t + c;
|
||||
if (abs(fp) > 1e-8) t -= f / fp;
|
||||
t = clamp(t, -0.08, 1.08);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// Accepts precomputed bezier values (t, curvePos, norm) — computed once per segment
|
||||
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
|
||||
float s = dot(uv - curvePos, norm);
|
||||
|
||||
float time = iTime * animationSpeed;
|
||||
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
|
||||
|
||||
float envelope = sin(t * 3.14159265359);
|
||||
float linePos = (normalizedI - 0.5) * fanSpread * envelope;
|
||||
float amp = lineSpread * 0.3 * envelope;
|
||||
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
|
||||
* sin(fi * 0.9 + time * 0.18);
|
||||
|
||||
float dist = s - linePos - waveDisp;
|
||||
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
|
||||
|
||||
return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003);
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
||||
baseUv.y *= -1.0;
|
||||
|
||||
if (parallax) {
|
||||
baseUv += parallaxOffset;
|
||||
}
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
const int MAX_PTS = 16;
|
||||
const int MAX_SEGS = 15;
|
||||
|
||||
for (int s = 0; s < MAX_SEGS; ++s) {
|
||||
if (s >= numPoints - 1) break;
|
||||
|
||||
vec2 sp = vec2(pointX[s], pointY[s]);
|
||||
vec2 ep = vec2(pointX[s + 1], pointY[s + 1]);
|
||||
|
||||
vec2 segD = ep - sp;
|
||||
float segL = length(segD);
|
||||
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||
|
||||
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
|
||||
vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
|
||||
|
||||
// bezierClosestT computed ONCE per segment — shared by fog + all lines
|
||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||
float bmt = 1.0 - bt;
|
||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||
vec2 bTang = normalize(bmt*(pc - sp) + bt*(ep - pc));
|
||||
vec2 bNorm = vec2(-bTang.y, bTang.x);
|
||||
|
||||
float bDist = length(baseUv - bPos);
|
||||
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
||||
float fogEnv = sin(bt * 3.14159265359);
|
||||
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
||||
col += lineCol * segFog;
|
||||
|
||||
for (int i = 0; i < middleLineCount; ++i) {
|
||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
||||
}
|
||||
}
|
||||
|
||||
col *= lineBrightness;
|
||||
|
||||
float dist = length(baseUv) / 1.8;
|
||||
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
||||
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = vec4(0.0);
|
||||
mainImage(color, gl_FragCoord.xy);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`
|
||||
|
||||
// --- Helpers ---
|
||||
const MAX_GRADIENT_STOPS = 8
|
||||
|
||||
function hexToVec3(hex) {
|
||||
let value = hex.trim()
|
||||
if (value.startsWith('#')) value = value.slice(1)
|
||||
let r = 255, g = 255, b = 255
|
||||
if (value.length === 3) {
|
||||
r = parseInt(value[0] + value[0], 16)
|
||||
g = parseInt(value[1] + value[1], 16)
|
||||
b = parseInt(value[2] + value[2], 16)
|
||||
} else if (value.length === 6) {
|
||||
r = parseInt(value.slice(0, 2), 16)
|
||||
g = parseInt(value.slice(2, 4), 16)
|
||||
b = parseInt(value.slice(4, 6), 16)
|
||||
}
|
||||
return new Vector3(r / 255, g / 255, b / 255)
|
||||
}
|
||||
|
||||
// --- Component Logic ---
|
||||
const containerRef = ref(null)
|
||||
|
||||
let scene = null
|
||||
let camera = null
|
||||
let renderer = null
|
||||
let material = null
|
||||
let geometry = null
|
||||
let mesh = null
|
||||
let clock = null
|
||||
let rafId = null
|
||||
let resizeObserver = null
|
||||
let uniforms = null
|
||||
let scrollHandler = null
|
||||
let scrollIdleTimer = null
|
||||
let visibilityHandler = null
|
||||
|
||||
// Parallax tracking
|
||||
let targetParallax = null
|
||||
let currentParallax = null
|
||||
const parallaxDamping = 0.05
|
||||
|
||||
function getLineCount() {
|
||||
return typeof props.lineCount === 'number' ? props.lineCount : (props.lineCount[0] ?? 6)
|
||||
}
|
||||
|
||||
function applyGradient() {
|
||||
if (!uniforms) return
|
||||
const lines = props.linesGradient.filter(s => s && s.trim().length > 0)
|
||||
const stops = lines.slice(0, MAX_GRADIENT_STOPS)
|
||||
uniforms.lineGradientCount.value = stops.length
|
||||
stops.forEach((hex, i) => {
|
||||
const c = hexToVec3(hex)
|
||||
uniforms.lineGradient.value[i].set(c.x, c.y, c.z)
|
||||
})
|
||||
}
|
||||
|
||||
function applyPointColors() {
|
||||
if (!uniforms) return
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const hex = props.pointColors[i]
|
||||
if (hex) {
|
||||
const c = hexToVec3(hex)
|
||||
uniforms.pointColor.value[i].set(c.x, c.y, c.z)
|
||||
} else {
|
||||
uniforms.pointColor.value[i].set(1, 1, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyBgColors() {
|
||||
if (!uniforms) return
|
||||
const center = hexToVec3(props.bgColorCenter)
|
||||
uniforms.bgColorCenter.value.set(center.x, center.y, center.z)
|
||||
const edge = hexToVec3(props.bgColorEdge)
|
||||
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
|
||||
}
|
||||
|
||||
// Watch all props for live updates
|
||||
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
|
||||
watch(() => props.lineCount, () => {
|
||||
if (!uniforms) return
|
||||
uniforms.middleLineCount.value = getLineCount()
|
||||
})
|
||||
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
|
||||
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
|
||||
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v })
|
||||
watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
|
||||
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
|
||||
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
|
||||
watch(() => props.numPoints, (v) => { if (uniforms) uniforms.numPoints.value = v })
|
||||
// No deep watches needed: these array props come from computeds that
|
||||
// produce fresh arrays on every change, so reference equality suffices
|
||||
watch(() => props.pointXValues, (values) => {
|
||||
if (!uniforms) return
|
||||
for (let i = 0; i < 16; i++) {
|
||||
uniforms.pointX.value[i] = values[i] ?? 0
|
||||
}
|
||||
})
|
||||
watch(() => props.pointYValues, (values) => {
|
||||
if (!uniforms) return
|
||||
for (let i = 0; i < 16; i++) {
|
||||
uniforms.pointY.value[i] = values[i] ?? 0
|
||||
}
|
||||
})
|
||||
watch(() => props.pointColors, applyPointColors)
|
||||
watch(() => props.linesGradient, applyGradient)
|
||||
watch(() => props.bgColorCenter, applyBgColors)
|
||||
watch(() => props.bgColorEdge, applyBgColors)
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
targetParallax = new Vector2(0, 0)
|
||||
currentParallax = new Vector2(0, 0)
|
||||
|
||||
scene = new Scene()
|
||||
camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||
camera.position.z = 1
|
||||
|
||||
// Adaptive DPR: full quality when idle, reduced during scroll
|
||||
const nativeDpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
const DPR_IDLE = isMobile ? Math.min(nativeDpr, 1.6) : nativeDpr
|
||||
const DPR_SCROLL = isMobile ? 0.75 : nativeDpr // reduced on mobile during scroll
|
||||
const DPR_RESTORE_DELAY = 100 // ms after scroll stops to restore quality
|
||||
let currentDpr = DPR_IDLE
|
||||
let scrolling = false
|
||||
|
||||
// No antialias: we render a single fullscreen quad — MSAA only smooths
|
||||
// geometry edges (there are none); line edges are smoothed in the shader
|
||||
renderer = new WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'high-performance' })
|
||||
renderer.setPixelRatio(currentDpr)
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.display = 'block'
|
||||
renderer.domElement.style.mixBlendMode = props.mixBlendMode
|
||||
containerRef.value.appendChild(renderer.domElement)
|
||||
|
||||
const middleLineCount = getLineCount()
|
||||
|
||||
// Initial point positions (UV space, no flip)
|
||||
const initX = [...props.pointXValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
|
||||
const initY = [...props.pointYValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
|
||||
|
||||
uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new Vector3(1, 1, 1) },
|
||||
animationSpeed: { value: props.animationSpeed },
|
||||
|
||||
middleLineCount: { value: middleLineCount },
|
||||
numPoints: { value: props.numPoints },
|
||||
pointX: { value: initX },
|
||||
pointY: { value: initY },
|
||||
lineSpread: { value: props.lineSpread },
|
||||
fanSpread: { value: props.fanSpread },
|
||||
lineSharpness: { value: props.lineSharpness },
|
||||
waveFrequency: { value: props.waveFrequency },
|
||||
bezierCurvature: { value: props.bezierCurvature },
|
||||
lineBrightness: { value: props.lineBrightness },
|
||||
pointColor: {
|
||||
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
|
||||
},
|
||||
|
||||
parallax: { value: props.parallax },
|
||||
parallaxOffset: { value: new Vector2(0, 0) },
|
||||
|
||||
lineGradient: {
|
||||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1))
|
||||
},
|
||||
lineGradientCount: { value: 0 },
|
||||
bgColorCenter: { value: new Vector3(0, 0, 0) },
|
||||
bgColorEdge: { value: new Vector3(0, 0, 0) }
|
||||
}
|
||||
|
||||
// Apply initial values
|
||||
applyGradient()
|
||||
applyBgColors()
|
||||
applyPointColors()
|
||||
|
||||
material = new ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
fragmentShader
|
||||
})
|
||||
|
||||
geometry = new PlaneGeometry(2, 2)
|
||||
mesh = new Mesh(geometry, material)
|
||||
scene.add(mesh)
|
||||
|
||||
clock = new Clock()
|
||||
|
||||
// Resize
|
||||
const setSize = () => {
|
||||
if (!containerRef.value || !renderer) return
|
||||
const width = containerRef.value.clientWidth || 1
|
||||
const height = containerRef.value.clientHeight || 1
|
||||
renderer.setSize(width, height, false)
|
||||
const canvasWidth = renderer.domElement.width
|
||||
const canvasHeight = renderer.domElement.height
|
||||
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
|
||||
}
|
||||
setSize()
|
||||
|
||||
resizeObserver = new ResizeObserver(setSize)
|
||||
resizeObserver.observe(containerRef.value)
|
||||
|
||||
// Pointer events (parallax only)
|
||||
if (props.parallax) {
|
||||
const handlePointerMove = (event) => {
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
targetParallax.set(
|
||||
((x - centerX) / rect.width) * 0.2,
|
||||
(-(y - centerY) / rect.height) * 0.2
|
||||
)
|
||||
}
|
||||
renderer.domElement.addEventListener('pointermove', handlePointerMove)
|
||||
}
|
||||
|
||||
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
|
||||
let cachedScrollLeft = 0
|
||||
|
||||
function setDpr(dpr) {
|
||||
if (dpr === currentDpr) return
|
||||
currentDpr = dpr
|
||||
renderer.setPixelRatio(dpr)
|
||||
// Re-apply size so resolution updates
|
||||
if (containerRef.value) {
|
||||
const w = containerRef.value.clientWidth || 1
|
||||
const h = containerRef.value.clientHeight || 1
|
||||
renderer.setSize(w, h, false)
|
||||
uniforms.iResolution.value.set(renderer.domElement.width, renderer.domElement.height, 1)
|
||||
}
|
||||
}
|
||||
|
||||
scrollHandler = () => {
|
||||
if (props.scrollContainer) {
|
||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||
}
|
||||
// Drop DPR while scrolling on mobile
|
||||
if (!scrolling && DPR_SCROLL < DPR_IDLE) {
|
||||
scrolling = true
|
||||
setDpr(DPR_SCROLL)
|
||||
}
|
||||
clearTimeout(scrollIdleTimer)
|
||||
scrollIdleTimer = setTimeout(() => {
|
||||
scrolling = false
|
||||
setDpr(DPR_IDLE)
|
||||
}, DPR_RESTORE_DELAY)
|
||||
}
|
||||
|
||||
if (props.scrollContainer) {
|
||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
|
||||
}
|
||||
|
||||
// Fast inline scroll sync — reads cached scrollLeft instead of DOM during render
|
||||
function syncScrollFromCache() {
|
||||
if (props.scrollUvScale > 0) {
|
||||
const sl = cachedScrollLeft
|
||||
for (let i = 0; i < 16; i++) {
|
||||
uniforms.pointX.value[i] = (props.pointXValues[i] ?? 0) - sl * props.scrollUvScale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FPS tracking + adaptive DPR
|
||||
let frameCount = 0
|
||||
let fpsLastTime = performance.now()
|
||||
|
||||
// Adaptive DPR steps based on measured FPS (mobile only)
|
||||
// Steps are tried from lowest to highest – first step where fps >= threshold wins
|
||||
const DPR_STEPS = isMobile ? [
|
||||
{ threshold: 50, dpr: DPR_IDLE }, // good fps → full quality
|
||||
{ threshold: 35, dpr: Math.max(DPR_IDLE * 0.75, 0.75) }, // mid fps → 75%
|
||||
{ threshold: 0, dpr: Math.max(DPR_IDLE * 0.5, 0.5) }, // low fps → 50%
|
||||
] : null
|
||||
let adaptiveDprTarget = DPR_IDLE
|
||||
|
||||
// Pause rendering when app/tab is hidden (e.g. iPhone home screen)
|
||||
visibilityHandler = () => {
|
||||
if (document.hidden) {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
} else if (!rafId) {
|
||||
fpsLastTime = performance.now()
|
||||
frameCount = 0
|
||||
renderLoop()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
|
||||
// Render loop
|
||||
const renderLoop = () => {
|
||||
// FPS counter + adaptive DPR
|
||||
frameCount++
|
||||
const now = performance.now()
|
||||
if (now - fpsLastTime >= 1000) {
|
||||
const fps = Math.round(frameCount / ((now - fpsLastTime) / 1000))
|
||||
fpsDisplay.value = fps
|
||||
frameCount = 0
|
||||
fpsLastTime = now
|
||||
|
||||
// Adapt DPR based on measured FPS (only when not scroll-throttled)
|
||||
if (DPR_STEPS && !scrolling) {
|
||||
const step = DPR_STEPS.find(s => fps >= s.threshold) ?? DPR_STEPS[DPR_STEPS.length - 1]
|
||||
if (step.dpr !== adaptiveDprTarget) {
|
||||
adaptiveDprTarget = step.dpr
|
||||
setDpr(adaptiveDprTarget)
|
||||
}
|
||||
}
|
||||
|
||||
dprDisplay.value = currentDpr.toFixed(2)
|
||||
}
|
||||
|
||||
// Read latest scrollLeft from DOM in case scroll event was missed
|
||||
if (props.scrollContainer) {
|
||||
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
|
||||
}
|
||||
|
||||
uniforms.iTime.value = clock.getElapsedTime()
|
||||
|
||||
if (props.parallax) {
|
||||
currentParallax.lerp(targetParallax, parallaxDamping)
|
||||
uniforms.parallaxOffset.value.copy(currentParallax)
|
||||
}
|
||||
|
||||
syncScrollFromCache()
|
||||
|
||||
renderer.render(scene, camera)
|
||||
rafId = requestAnimationFrame(renderLoop)
|
||||
}
|
||||
renderLoop()
|
||||
})
|
||||
|
||||
defineExpose({ fpsDisplay, dprDisplay })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
if (resizeObserver) resizeObserver.disconnect()
|
||||
if (props.scrollContainer && scrollHandler) {
|
||||
props.scrollContainer.removeEventListener('scroll', scrollHandler)
|
||||
}
|
||||
clearTimeout(scrollIdleTimer)
|
||||
if (geometry) geometry.dispose()
|
||||
if (material) material.dispose()
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
if (renderer.domElement && renderer.domElement.parentNode) {
|
||||
renderer.domElement.parentNode.removeChild(renderer.domElement)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-lines-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
class="glow-dot"
|
||||
:class="{
|
||||
'glow-dot--ghost': isGhost,
|
||||
'glow-dot--selected': selected,
|
||||
'glow-dot--dimmed': isDimmed,
|
||||
'glow-dot--label-above': labelAbove
|
||||
}"
|
||||
:style="dotStyle"
|
||||
@click.stop="onSelect"
|
||||
>
|
||||
<div class="glow-dot__inner" :style="{ boxShadow: glowShadow }">
|
||||
<img
|
||||
v-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
class="glow-dot__image"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle">
|
||||
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span>
|
||||
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useEventsStore } from 'stores/events'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { resolveImage } from 'composables/useImageCache'
|
||||
|
||||
const props = defineProps({
|
||||
event: { type: Object, required: true },
|
||||
x: { type: Number, default: 0 },
|
||||
isGhost: { type: Boolean, default: false },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
const eventsStore = useEventsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Resolve image: cached thumbnail from IndexedDB or fetch & cache.
|
||||
// Watched so the dot updates when the event's image changes while mounted.
|
||||
const imageSrc = ref(null)
|
||||
watch(
|
||||
() => props.event.image,
|
||||
async (img) => {
|
||||
const src = await resolveImage(img, props.event.id)
|
||||
// Guard against stale async result after the image changed again
|
||||
if (props.event.image === img) imageSrc.value = src
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const fl = computed(() => settingsStore.floatingLines)
|
||||
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
||||
|
||||
// CSS diameter derived from settings (circleRadius is half-size in px)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
|
||||
|
||||
// Y position: emotion +1 → top (20%), 0 → middle (47%), -1 → bottom (74%)
|
||||
|
||||
const yPercent = computed(() => 48 - props.event.emotion * 30)
|
||||
|
||||
// Label above dot when dot is in lower half, below when in upper half
|
||||
const labelAbove = computed(() => yPercent.value > 48)
|
||||
|
||||
// Format date as "12. Feb 2024" (parsed from ISO string, no Date allocation)
|
||||
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
const formattedDate = computed(() => {
|
||||
const [year, month, day] = props.event.date.split('-')
|
||||
return `${Number(day)}. ${MONTH_SHORT[Number(month) - 1]} ${year}`
|
||||
})
|
||||
|
||||
// Label font sizes per setting
|
||||
const LABEL_FONT = { small: { title: 10, date: 9 }, medium: { title: 12, date: 11 }, large: { title: 14, date: 13 } }
|
||||
const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
|
||||
const labelColor = computed(() => fl.value.labelColor ?? '#ffffff')
|
||||
|
||||
const labelStyle = computed(() => ({
|
||||
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
|
||||
}))
|
||||
const titleStyle = computed(() => ({
|
||||
fontSize: `${labelFont.value.title}px`,
|
||||
color: labelColor.value,
|
||||
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
|
||||
}))
|
||||
const dateStyle = computed(() => ({
|
||||
fontSize: `${labelFont.value.date}px`,
|
||||
color: labelColor.value
|
||||
}))
|
||||
|
||||
const dotStyle = computed(() => ({
|
||||
left: `${props.x}px`,
|
||||
top: `${yPercent.value}%`,
|
||||
width: `${dotSize.value}px`,
|
||||
height: `${dotSize.value}px`
|
||||
}))
|
||||
|
||||
// Two-layer box-shadow: tight bright core + wide soft halo
|
||||
const glowShadow = computed(() => {
|
||||
const size = fl.value.glowSize
|
||||
const strength = fl.value.glowStrength
|
||||
const color = glowColor.value
|
||||
const core = alphaHex(Math.min(strength / 3, 1))
|
||||
const halo = alphaHex(Math.min(strength / 7, 1))
|
||||
return `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
|
||||
})
|
||||
|
||||
function alphaHex(a) {
|
||||
return Math.round(Math.min(1, Math.max(0, a)) * 255).toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
const isDimmed = computed(() =>
|
||||
eventsStore.selectedEventId !== null && !props.selected && !props.isGhost
|
||||
)
|
||||
|
||||
function onSelect() {
|
||||
if (!props.isGhost) emit('select')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glow-dot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: opacity 0.3s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.glow-dot__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.glow-dot__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.glow-dot--ghost {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.glow-dot--selected {
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.glow-dot--dimmed {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Event label (title + date) */
|
||||
.glow-dot__label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: calc(100% + 6px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
max-width: 90px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* When dot is in lower half, show label above */
|
||||
.glow-dot--label-above .glow-dot__label {
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.glow-dot__title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 90px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.glow-dot__date {
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
opacity: 0.4;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,482 +0,0 @@
|
|||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="open"
|
||||
class="lw-settings glass--panel"
|
||||
:style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}"
|
||||
:class="{ 'lw-settings--dragging': isDragging }"
|
||||
>
|
||||
<!-- Handle — drag to resize, tap to close -->
|
||||
<div
|
||||
class="lw-settings__handle"
|
||||
v-on="handleListeners"
|
||||
>
|
||||
<div class="lw-settings__handle-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="lw-settings__scroll">
|
||||
<div class="lw-settings__title">Einstellungen</div>
|
||||
|
||||
<!-- Linien -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Linien</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Speed</span>
|
||||
<span class="lw-settings__value">{{ fl.speed.toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.speed"
|
||||
@update:model-value="v => update({ speed: v })"
|
||||
:min="0.1" :max="3" :step="0.05"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Anzahl</span>
|
||||
<span class="lw-settings__value">{{ fl.lineCount }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.lineCount"
|
||||
@update:model-value="v => update({ lineCount: v })"
|
||||
:min="1" :max="40" :step="1"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Wellen-Amp</span>
|
||||
<span class="lw-settings__value">{{ fl.spread.toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.spread"
|
||||
@update:model-value="v => update({ spread: v })"
|
||||
:min="0.01" :max="1" :step="0.01"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Fächerbreite</span>
|
||||
<span class="lw-settings__value">{{ fl.fanSpread.toFixed(3) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.fanSpread"
|
||||
@update:model-value="v => update({ fanSpread: v })"
|
||||
:min="0.01" :max="0.5" :step="0.005"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Feinheit</span>
|
||||
<span class="lw-settings__value">{{ fl.lineSharpness.toFixed(1) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.lineSharpness"
|
||||
@update:model-value="v => update({ lineSharpness: v })"
|
||||
:min="0.3" :max="10" :step="0.1"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Welligkeit</span>
|
||||
<span class="lw-settings__value">{{ fl.waveFrequency.toFixed(1) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.waveFrequency"
|
||||
@update:model-value="v => update({ waveFrequency: v })"
|
||||
:min="1" :max="30" :step="0.5"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Kurve</span>
|
||||
<span class="lw-settings__value">{{ fl.bezierCurvature.toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.bezierCurvature"
|
||||
@update:model-value="v => update({ bezierCurvature: v })"
|
||||
:min="-1" :max="1" :step="0.05"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Kreis</span>
|
||||
<span class="lw-settings__value">{{ fl.circleRadius }}px</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.circleRadius"
|
||||
@update:model-value="v => update({ circleRadius: v })"
|
||||
:min="10" :max="200" :step="5"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Glow Größe</span>
|
||||
<span class="lw-settings__value">{{ fl.glowSize }}px</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.glowSize"
|
||||
@update:model-value="v => update({ glowSize: v })"
|
||||
:min="5" :max="100" :step="1"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Glow Stärke</span>
|
||||
<span class="lw-settings__value">{{ fl.glowStrength.toFixed(1) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.glowStrength"
|
||||
@update:model-value="v => update({ glowStrength: v })"
|
||||
:min="0.5" :max="12" :step="0.5"
|
||||
/>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Helligkeit</span>
|
||||
<span class="lw-settings__value">{{ (fl.lineBrightness ?? 1).toFixed(2) }}</span>
|
||||
</div>
|
||||
<q-slider
|
||||
:model-value="fl.lineBrightness ?? 1"
|
||||
@update:model-value="v => update({ lineBrightness: v })"
|
||||
:min="0.05" :max="2" :step="0.05"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Labels</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Schriftgröße</span>
|
||||
<div class="lw-settings__segmented">
|
||||
<button
|
||||
v-for="size in LABEL_SIZES"
|
||||
:key="size.value"
|
||||
class="lw-settings__seg-btn"
|
||||
:class="{ 'lw-settings__seg-btn--active': fl.labelSize === size.value }"
|
||||
@click="update({ labelSize: size.value })"
|
||||
>
|
||||
{{ size.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>Schriftfarbe</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="fl.labelColor ?? '#ffffff'"
|
||||
@input="e => update({ labelColor: e.target.value })"
|
||||
class="lw-settings__color-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hintergrundbild -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Hintergrundbild</span>
|
||||
|
||||
<div class="lw-settings__img-grid">
|
||||
<button
|
||||
class="lw-settings__img-btn"
|
||||
:class="{ 'lw-settings__img-btn--active': fl.backgroundImage === '' }"
|
||||
@click="update({ backgroundImage: '' })"
|
||||
>
|
||||
Keins
|
||||
</button>
|
||||
<button
|
||||
v-for="n in 10"
|
||||
:key="'bg' + n"
|
||||
class="lw-settings__img-btn"
|
||||
:class="{ 'lw-settings__img-btn--active': fl.backgroundImage === `/images/bg-image-${n}.jpg` }"
|
||||
@click="update({ backgroundImage: `/images/bg-image-${n}.jpg` })"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hintergrundfarbe -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Hintergrundfarbe</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>BG Mitte</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="fl.bgCenter"
|
||||
@input="e => update({ bgCenter: e.target.value })"
|
||||
class="lw-settings__color-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>BG Rand</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="fl.bgEdge"
|
||||
@input="e => update({ bgEdge: e.target.value })"
|
||||
class="lw-settings__color-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Farbstopps -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Farbverlauf (je Zeile ein Hex)</span>
|
||||
|
||||
<textarea
|
||||
:value="fl.gradientStops"
|
||||
@input="e => update({ gradientStops: e.target.value })"
|
||||
class="lw-settings__gradient-input"
|
||||
rows="4"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Extras -->
|
||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||
<span class="lw-settings__card-label">Extras</span>
|
||||
|
||||
<div class="lw-settings__row">
|
||||
<span>{{ isDark ? 'Hell-Modus' : 'Dunkel-Modus' }}</span>
|
||||
<q-toggle
|
||||
:model-value="isDark"
|
||||
@update:model-value="toggleDark"
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset -->
|
||||
<div class="lw-settings__reset">
|
||||
<q-btn
|
||||
flat dense no-caps
|
||||
label="Zurücksetzen"
|
||||
icon="restart_alt"
|
||||
size="sm"
|
||||
@click="settingsStore.resetFloatingLines()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||
|
||||
const props = defineProps({ open: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['close'])
|
||||
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => emit('close'))
|
||||
|
||||
watch(() => props.open, (open) => { if (open) resetHeight() })
|
||||
|
||||
const $q = useQuasar()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const fl = computed(() => settingsStore.floatingLines)
|
||||
|
||||
const LABEL_SIZES = [
|
||||
{ label: 'Klein', value: 'small' },
|
||||
{ label: 'Mittel', value: 'medium' },
|
||||
{ label: 'Groß', value: 'large' }
|
||||
]
|
||||
|
||||
function update(changes) {
|
||||
settingsStore.updateFloatingLines(changes)
|
||||
}
|
||||
|
||||
function toggleDark() {
|
||||
$q.dark.toggle()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lw-settings {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
height: 75dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
|
||||
.lw-settings--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.lw-settings__handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.lw-settings__handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.lw-settings__handle-bar {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.lw-settings__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 32px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.lw-settings__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.lw-settings__card {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid rgba(128, 128, 128, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.lw-settings__card--dark {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.lw-settings__card-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.lw-settings__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.lw-settings__row:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.lw-settings__value {
|
||||
font-weight: 600;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Segmented control */
|
||||
.lw-settings__segmented {
|
||||
display: flex;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.lw-settings__seg-btn {
|
||||
flex: 1;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lw-settings__seg-btn--active {
|
||||
background: rgba(168, 85, 247, 0.25);
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lw-settings__img-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lw-settings__img-btn {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.lw-settings__img-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: #a855f7;
|
||||
}
|
||||
|
||||
.lw-settings__img-btn--active {
|
||||
border-color: #a855f7;
|
||||
color: #a855f7;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lw-settings__color-input {
|
||||
width: 36px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.lw-settings__gradient-input {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
color: inherit;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
resize: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.body--dark .lw-settings__gradient-input {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.lw-settings__reset {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Slide-up transition */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
<template>
|
||||
<Transition name="modal-card">
|
||||
<div v-if="open" class="modal-card glass--panel" :class="{ 'modal-card--dark': isDark }">
|
||||
<!-- Header: title + close -->
|
||||
<div class="modal-card__header">
|
||||
<span class="modal-card__title">{{ title }}</span>
|
||||
<button class="modal-card__close" @click="$emit('close')">
|
||||
<q-icon name="close" size="22px" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs (optional) -->
|
||||
<div v-if="tabs.length" class="modal-card__tabs">
|
||||
<div class="modal-card__tabs-scroll">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="modal-card__tab"
|
||||
:class="{ 'modal-card__tab--active': modelValue === tab.value }"
|
||||
@click="$emit('update:modelValue', tab.value)"
|
||||
>
|
||||
<q-icon v-if="tab.icon" :name="tab.icon" size="16px" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
tabs: { type: Array, default: () => [] },
|
||||
modelValue: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['close', 'update:modelValue'])
|
||||
|
||||
const $q = useQuasar()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-card {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-card__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-card__close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.modal-card__close:hover {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.modal-card__tabs {
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.modal-card__tabs-scroll {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-card__tabs-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-card__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.modal-card__tab:hover {
|
||||
opacity: 0.85;
|
||||
background: rgba(128, 128, 128, 0.14);
|
||||
}
|
||||
|
||||
.modal-card__tab--active {
|
||||
opacity: 1;
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.modal-card--dark .modal-card__tab--active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.modal-card__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.modal-card-enter-active,
|
||||
.modal-card-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-card-enter-from,
|
||||
.modal-card-leave-to {
|
||||
transform: translateY(30px) scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,552 +0,0 @@
|
|||
<template>
|
||||
<div class="timeline-container">
|
||||
<div
|
||||
class="timeline"
|
||||
ref="timelineRef"
|
||||
@scroll="onScroll"
|
||||
@wheel="onWheel"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove.passive="onTouchMove"
|
||||
@touchend.passive="onTouchEnd"
|
||||
>
|
||||
<div class="timeline__track" :style="{ width: trackWidth + 'px' }">
|
||||
<!-- GlowDots — only visible ones rendered -->
|
||||
<GlowDot
|
||||
v-for="{ event, globalIndex } in visibleEvents"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:x="getEventX(globalIndex)"
|
||||
:is-ghost="event.id === '__ghost__'"
|
||||
:selected="eventsStore.selectedEventId === event.id"
|
||||
@select="$emit('dotSelect', event.id)"
|
||||
/>
|
||||
|
||||
<!-- Month labels — only visible -->
|
||||
<div class="timeline__labels">
|
||||
<div
|
||||
v-for="{ label, globalIndex } in visibleLabels"
|
||||
:key="label.key"
|
||||
class="timeline__month"
|
||||
:style="{ left: getEventX(globalIndex) + 'px' }"
|
||||
:class="{ 'timeline__month--active': label.key === activeLabel }"
|
||||
@click="scrollToIndex(globalIndex)"
|
||||
>
|
||||
{{ label.month }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky year labels with nav arrows (outside scroll container) -->
|
||||
<div class="timeline__sticky-years">
|
||||
<TransitionGroup name="year-fade">
|
||||
<div
|
||||
v-for="label in stickyYearLabels"
|
||||
:key="label.year"
|
||||
class="timeline__sticky-year-group"
|
||||
:style="{ left: label.left + 'px' }"
|
||||
>
|
||||
<button
|
||||
v-if="label.hasPrev"
|
||||
class="timeline__year-arrow timeline__year-arrow--prev"
|
||||
@click="scrollToYearCenter(label.year - 1)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M7 1L3 5l4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<span class="timeline__sticky-year" @click="scrollToYearCenter(label.year)">
|
||||
{{ label.year }}
|
||||
</span>
|
||||
<button
|
||||
v-if="label.hasNext"
|
||||
class="timeline__year-arrow timeline__year-arrow--next"
|
||||
@click="scrollToYearCenter(label.year + 1)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useEventsStore } from 'stores/events'
|
||||
import GlowDot from 'components/GlowDot.vue'
|
||||
|
||||
const emit = defineEmits(['dotSelect', 'viewUpdate'])
|
||||
const eventsStore = useEventsStore()
|
||||
const timelineRef = ref(null)
|
||||
const scrollLeft = ref(0)
|
||||
const viewportWidth = ref(400)
|
||||
const containerHeight = ref(400)
|
||||
|
||||
// Zoom: 1.0 = default, range 0.4–3.0
|
||||
const zoomLevel = ref(1)
|
||||
const MIN_ZOOM = 0.4
|
||||
const MAX_ZOOM = 3.0
|
||||
const ZOOM_STEP = 0.08
|
||||
|
||||
// Spacing: ~4 events visible at a time, scaled by zoom
|
||||
const BASE_SPACING = computed(() => viewportWidth.value / 2.5)
|
||||
const EVENT_SPACING = computed(() => BASE_SPACING.value * zoomLevel.value)
|
||||
const PADDING = computed(() => viewportWidth.value / 2)
|
||||
|
||||
// Display events: sorted events + ghost when creating
|
||||
const showGhost = computed(() => eventsStore.panelOpen && !eventsStore.editingEventId)
|
||||
const displayEvents = computed(() => {
|
||||
const sorted = [...eventsStore.sortedEvents]
|
||||
if (!showGhost.value) return sorted
|
||||
const ghost = eventsStore.ghostEvent
|
||||
// Dates are ISO strings (YYYY-MM-DD) — string comparison is chronological
|
||||
const insertIdx = sorted.findIndex((e) => e.date > ghost.date)
|
||||
if (insertIdx === -1) {
|
||||
sorted.push(ghost)
|
||||
} else {
|
||||
sorted.splice(insertIdx, 0, ghost)
|
||||
}
|
||||
return sorted
|
||||
})
|
||||
|
||||
// Track width
|
||||
const trackWidth = computed(() => {
|
||||
const count = displayEvents.value.length
|
||||
if (count === 0) return viewportWidth.value
|
||||
return PADDING.value * 2 + (count - 1) * EVENT_SPACING.value
|
||||
})
|
||||
|
||||
// X position for event at given index
|
||||
function getEventX(index) {
|
||||
return PADDING.value + index * EVENT_SPACING.value
|
||||
}
|
||||
|
||||
// Month names
|
||||
const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
|
||||
// One label per event showing its month (parsed from ISO string, no Date allocation)
|
||||
const eventLabels = computed(() => {
|
||||
return displayEvents.value.map((event, index) => {
|
||||
const year = event.date.slice(0, 4)
|
||||
const monthIdx = Number(event.date.slice(5, 7)) - 1
|
||||
return {
|
||||
key: `${event.id}-${index}`,
|
||||
month: MONTHS[monthIdx],
|
||||
year: Number(year),
|
||||
fullMonth: `${year}-${monthIdx}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Year ranges — for each year, the world-space X range of its events
|
||||
const yearRanges = computed(() => {
|
||||
const ranges = []
|
||||
const events = displayEvents.value
|
||||
if (events.length === 0) return ranges
|
||||
|
||||
let currentYear = Number(events[0].date.slice(0, 4))
|
||||
let startIdx = 0
|
||||
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
const year = Number(events[i].date.slice(0, 4))
|
||||
if (year !== currentYear) {
|
||||
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
|
||||
currentYear = year
|
||||
startIdx = i
|
||||
}
|
||||
}
|
||||
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(events.length - 1) })
|
||||
return ranges
|
||||
})
|
||||
|
||||
// All years that exist in the data (for prev/next navigation)
|
||||
const allYears = computed(() => yearRanges.value.map(r => r.year))
|
||||
|
||||
// Sticky year labels — positioned relative to viewport, clamped to edges
|
||||
const YEAR_MARGIN = 24
|
||||
const stickyYearLabels = computed(() => {
|
||||
const sl = scrollLeft.value
|
||||
const vw = viewportWidth.value
|
||||
const viewLeft = sl
|
||||
const viewRight = sl + vw
|
||||
const years = allYears.value
|
||||
|
||||
// Find years whose event range overlaps the viewport
|
||||
const visible = yearRanges.value.filter(r => r.endX >= viewLeft && r.startX <= viewRight)
|
||||
if (visible.length === 0) return []
|
||||
|
||||
function makeLabel(year, left) {
|
||||
const idx = years.indexOf(year)
|
||||
return { year, left, hasPrev: idx > 0, hasNext: idx < years.length - 1 }
|
||||
}
|
||||
|
||||
if (visible.length === 1) {
|
||||
const r = visible[0]
|
||||
const visStart = Math.max(r.startX, viewLeft)
|
||||
const visEnd = Math.min(r.endX, viewRight)
|
||||
const center = (visStart + visEnd) / 2 - sl
|
||||
const clamped = Math.max(YEAR_MARGIN, Math.min(vw - YEAR_MARGIN, center))
|
||||
return [makeLabel(r.year, clamped)]
|
||||
}
|
||||
|
||||
// Multiple years: first pins left, last pins right, middles float naturally
|
||||
const result = []
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const r = visible[i]
|
||||
const visStart = Math.max(r.startX, viewLeft)
|
||||
const visEnd = Math.min(r.endX, viewRight)
|
||||
const center = (visStart + visEnd) / 2 - sl
|
||||
|
||||
let pos
|
||||
if (i === 0) {
|
||||
pos = Math.max(YEAR_MARGIN, Math.min(vw / 2 - 30, center))
|
||||
} else if (i === visible.length - 1) {
|
||||
pos = Math.min(vw - YEAR_MARGIN, Math.max(vw / 2 + 30, center))
|
||||
} else {
|
||||
pos = Math.max(YEAR_MARGIN + 60, Math.min(vw - YEAR_MARGIN - 60, center))
|
||||
}
|
||||
result.push(makeLabel(r.year, pos))
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Virtualization: only render events near the viewport
|
||||
const VIS_BUFFER = 2
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const total = displayEvents.value.length
|
||||
if (total === 0) return { start: 0, end: -1 }
|
||||
const spacing = EVENT_SPACING.value
|
||||
if (spacing <= 0) return { start: 0, end: total - 1 }
|
||||
const start = Math.max(0,
|
||||
Math.floor((scrollLeft.value - PADDING.value) / spacing) - VIS_BUFFER
|
||||
)
|
||||
const end = Math.min(total - 1,
|
||||
Math.ceil((scrollLeft.value + viewportWidth.value - PADDING.value) / spacing) + VIS_BUFFER
|
||||
)
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const visibleEvents = computed(() => {
|
||||
const { start, end } = visibleRange.value
|
||||
if (end < start) return []
|
||||
return displayEvents.value.slice(start, end + 1).map((event, i) => ({
|
||||
event,
|
||||
globalIndex: start + i
|
||||
}))
|
||||
})
|
||||
|
||||
const visibleLabels = computed(() => {
|
||||
const { start, end } = visibleRange.value
|
||||
if (end < start) return []
|
||||
return eventLabels.value.slice(start, end + 1).map((label, i) => ({
|
||||
label,
|
||||
globalIndex: start + i
|
||||
}))
|
||||
})
|
||||
|
||||
// Active label — closest event to center of viewport (O(1) via index)
|
||||
const activeLabel = computed(() => {
|
||||
const total = displayEvents.value.length
|
||||
if (total === 0) return null
|
||||
const centerX = scrollLeft.value + viewportWidth.value / 2
|
||||
const spacing = EVENT_SPACING.value
|
||||
if (spacing <= 0) return null
|
||||
const index = Math.round((centerX - PADDING.value) / spacing)
|
||||
const clamped = Math.max(0, Math.min(total - 1, index))
|
||||
return eventLabels.value[clamped]?.key ?? null
|
||||
})
|
||||
|
||||
function onScroll() {
|
||||
if (timelineRef.value) {
|
||||
scrollLeft.value = timelineRef.value.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToIndex(index) {
|
||||
if (!timelineRef.value) return
|
||||
const x = getEventX(index)
|
||||
timelineRef.value.scrollTo({
|
||||
left: x - viewportWidth.value / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToYearCenter(year) {
|
||||
if (!timelineRef.value) return
|
||||
// Find exact year, or nearest in the requested direction
|
||||
let range = yearRanges.value.find(r => r.year === year)
|
||||
if (!range) {
|
||||
// Find closest year
|
||||
const sorted = [...yearRanges.value].sort((a, b) => Math.abs(a.year - year) - Math.abs(b.year - year))
|
||||
range = sorted[0]
|
||||
}
|
||||
if (!range) return
|
||||
const centerX = (range.startX + range.endX) / 2
|
||||
timelineRef.value.scrollTo({
|
||||
left: centerX - viewportWidth.value / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
function updateViewportWidth() {
|
||||
if (timelineRef.value) {
|
||||
viewportWidth.value = timelineRef.value.clientWidth || 400
|
||||
containerHeight.value = timelineRef.value.clientHeight || 400
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom while keeping the viewport center stable
|
||||
function applyZoom(newZoom, centerClientX) {
|
||||
const el = timelineRef.value
|
||||
if (!el) return
|
||||
|
||||
// Default center to viewport middle
|
||||
const rect = el.getBoundingClientRect()
|
||||
const cx = centerClientX !== undefined ? centerClientX - rect.left : viewportWidth.value / 2
|
||||
|
||||
// World-space X under the center point before zoom
|
||||
const worldXBefore = el.scrollLeft + cx
|
||||
|
||||
// Ratio of old spacing to new
|
||||
const oldZoom = zoomLevel.value
|
||||
const ratio = newZoom / oldZoom
|
||||
|
||||
zoomLevel.value = newZoom
|
||||
|
||||
// After Vue updates, restore scroll so the same world point stays under center
|
||||
nextTick(() => {
|
||||
el.scrollLeft = worldXBefore * ratio - cx
|
||||
scrollLeft.value = el.scrollLeft
|
||||
})
|
||||
}
|
||||
|
||||
// Desktop: Ctrl+wheel or pinch on trackpad (both fire wheel with ctrlKey)
|
||||
function onWheel(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Pinch / Ctrl+scroll → zoom (prevent browser page zoom)
|
||||
e.preventDefault()
|
||||
const delta = -e.deltaY * ZOOM_STEP * 0.1
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoomLevel.value + delta))
|
||||
if (newZoom !== zoomLevel.value) {
|
||||
applyZoom(newZoom, e.clientX)
|
||||
}
|
||||
} else if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
// Redirect vertical wheel to horizontal scroll; horizontal deltas
|
||||
// (trackpads) scroll natively with momentum — onScroll syncs the ref
|
||||
timelineRef.value?.scrollBy({ left: e.deltaY })
|
||||
}
|
||||
}
|
||||
|
||||
// Touch: pinch-to-zoom
|
||||
let touchStartDist = 0
|
||||
let touchStartZoom = 1
|
||||
|
||||
function getTouchDist(touches) {
|
||||
const dx = touches[0].clientX - touches[1].clientX
|
||||
const dy = touches[0].clientY - touches[1].clientY
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (e.touches.length === 2) {
|
||||
touchStartDist = getTouchDist(e.touches)
|
||||
touchStartZoom = zoomLevel.value
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (e.touches.length === 2) {
|
||||
const dist = getTouchDist(e.touches)
|
||||
const scale = dist / touchStartDist
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, touchStartZoom * scale))
|
||||
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2
|
||||
applyZoom(newZoom, cx)
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
touchStartDist = 0
|
||||
}
|
||||
|
||||
// Scroll to center on the last event on mount
|
||||
let resizeObserver = null
|
||||
|
||||
// Shader point data — only recomputed when events or spacing change,
|
||||
// NOT on every scroll tick (scroll only changes which slice is visible)
|
||||
const shaderEvents = computed(() => {
|
||||
return displayEvents.value.map((e, i) => ({
|
||||
emotion: e.emotion,
|
||||
x: getEventX(i),
|
||||
color: eventsStore.getGlowColor(e)
|
||||
}))
|
||||
})
|
||||
|
||||
// Emit timeline state so the layout can position shader points
|
||||
function emitViewState() {
|
||||
const { start, end } = visibleRange.value
|
||||
emit('viewUpdate', {
|
||||
scrollLeft: scrollLeft.value,
|
||||
viewportWidth: viewportWidth.value,
|
||||
containerHeight: containerHeight.value,
|
||||
visibleStart: start,
|
||||
visibleEnd: end,
|
||||
events: shaderEvents.value
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[scrollLeft, viewportWidth, containerHeight, shaderEvents],
|
||||
emitViewState
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (!timelineRef.value) return
|
||||
updateViewportWidth()
|
||||
|
||||
const events = displayEvents.value
|
||||
if (events.length === 0) return
|
||||
const lastX = getEventX(events.length - 1)
|
||||
timelineRef.value.scrollLeft = lastX - viewportWidth.value / 2
|
||||
scrollLeft.value = timelineRef.value.scrollLeft
|
||||
|
||||
// Update viewport width on resize
|
||||
resizeObserver = new ResizeObserver(updateViewportWidth)
|
||||
resizeObserver.observe(timelineRef.value)
|
||||
|
||||
// Emit initial state
|
||||
emitViewState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
function zoomIn() {
|
||||
const newZoom = Math.min(MAX_ZOOM, zoomLevel.value + ZOOM_STEP * 2)
|
||||
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
const newZoom = Math.max(MIN_ZOOM, zoomLevel.value - ZOOM_STEP * 2)
|
||||
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
|
||||
}
|
||||
|
||||
defineExpose({ timelineRef, zoomIn, zoomOut, zoomLevel, MIN_ZOOM, MAX_ZOOM })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-container {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 60px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.timeline::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline__track {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Month labels */
|
||||
.timeline__labels {
|
||||
position: absolute;
|
||||
bottom: 44px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.timeline__month {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.3s ease, font-size 0.3s ease, font-weight 0.3s ease;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline__month--active {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Sticky year labels — positioned outside the scroll container */
|
||||
.timeline__sticky-years {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.timeline__sticky-year-group {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: auto;
|
||||
transition: left 0.15s ease-out;
|
||||
}
|
||||
|
||||
.timeline__sticky-year {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline__year-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: inherit;
|
||||
opacity: 0.45;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.timeline__year-arrow:active {
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* TransitionGroup animations */
|
||||
.year-fade-enter-active,
|
||||
.year-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.year-fade-enter-from,
|
||||
.year-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
<template>
|
||||
<Transition name="slide-right">
|
||||
<div v-if="open" class="user-menu glass--panel">
|
||||
<!-- User header -->
|
||||
<div class="user-menu__header">
|
||||
<div class="user-menu__avatar">
|
||||
<span>K</span>
|
||||
</div>
|
||||
<div class="user-menu__info">
|
||||
<div class="user-menu__name">k-adam</div>
|
||||
<div class="user-menu__handle">@k-adam</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider" />
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="user-menu__items">
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'upgrade')">
|
||||
<q-icon name="auto_awesome" size="20px" />
|
||||
<span>Plan upgraden</span>
|
||||
</button>
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'personalize')">
|
||||
<q-icon name="palette" size="20px" />
|
||||
<span>Personalisierung</span>
|
||||
</button>
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'settings')">
|
||||
<q-icon name="settings" size="20px" />
|
||||
<span>Einstellungen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider" />
|
||||
|
||||
<!-- Help with submenu -->
|
||||
<div class="user-menu__items">
|
||||
<button
|
||||
class="user-menu__item"
|
||||
:class="{ 'user-menu__item--active': helpOpen }"
|
||||
@click="helpOpen = !helpOpen"
|
||||
>
|
||||
<q-icon name="support_agent" size="20px" />
|
||||
<span>Hilfe</span>
|
||||
<q-icon
|
||||
name="chevron_right"
|
||||
size="18px"
|
||||
class="user-menu__chevron"
|
||||
:class="{ 'user-menu__chevron--open': helpOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Help sub-items -->
|
||||
<Transition name="expand">
|
||||
<div v-if="helpOpen" class="user-menu__sub">
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'help-center')">
|
||||
<q-icon name="help_outline" size="18px" />
|
||||
<span>Hilfecenter</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'release-notes')">
|
||||
<q-icon name="new_releases" size="18px" />
|
||||
<span>Release-Hinweise</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'terms')">
|
||||
<q-icon name="description" size="18px" />
|
||||
<span>AGB und Richtlinien</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'report-bug')">
|
||||
<q-icon name="bug_report" size="18px" />
|
||||
<span>Fehler melden</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'download')">
|
||||
<q-icon name="download" size="18px" />
|
||||
<span>Apps herunterladen</span>
|
||||
</button>
|
||||
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'shortcuts')">
|
||||
<q-icon name="keyboard" size="18px" />
|
||||
<span>Tastaturkuerzel</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider" />
|
||||
|
||||
<div class="user-menu__items">
|
||||
<button class="user-menu__item" @click="$emit('navigate', 'logout')">
|
||||
<q-icon name="logout" size="20px" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer: user + plan -->
|
||||
<div class="user-menu__footer">
|
||||
<div class="user-menu__avatar user-menu__avatar--sm">
|
||||
<span>K</span>
|
||||
</div>
|
||||
<div class="user-menu__info">
|
||||
<div class="user-menu__name user-menu__name--sm">Kevin Ada</div>
|
||||
<div class="user-menu__plan">Free</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({ open: { type: Boolean, default: false } })
|
||||
defineEmits(['close', 'navigate'])
|
||||
|
||||
const helpOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 25;
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 20px 0 0 20px;
|
||||
border-top: none;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* User header */
|
||||
.user-menu__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
|
||||
.user-menu__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #e05a33;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-menu__avatar--sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-menu__info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-menu__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-menu__name--sm {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-menu__handle {
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.user-menu__plan {
|
||||
font-size: 11px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.user-menu__divider {
|
||||
height: 1px;
|
||||
margin: 4px 16px;
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
/* Menu items */
|
||||
.user-menu__items {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.user-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-menu__item:hover,
|
||||
.user-menu__item--active {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.user-menu__item:active {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.user-menu__item--sub {
|
||||
padding-left: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-menu__chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.user-menu__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Help sub-items */
|
||||
.user-menu__sub {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.user-menu__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px 24px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
/* Slide-right transition */
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.slide-right-enter-from,
|
||||
.slide-right-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Expand transition for help sub-menu */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||
max-height: 300px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
<template>
|
||||
<div class="zoom-control glass--button">
|
||||
<button class="zoom-control__btn" @click="$emit('zoomIn')">
|
||||
<q-icon name="add" size="18px" />
|
||||
</button>
|
||||
<div class="zoom-control__track" ref="trackRef" @pointerdown="onTrackDown">
|
||||
<div class="zoom-control__fill" :style="{ height: fillPercent + '%' }" />
|
||||
<div class="zoom-control__thumb" :style="{ bottom: fillPercent + '%' }" />
|
||||
</div>
|
||||
<button class="zoom-control__btn" @click="$emit('zoomOut')">
|
||||
<q-icon name="remove" size="18px" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
zoom: { type: Number, default: 1 },
|
||||
min: { type: Number, default: 0.4 },
|
||||
max: { type: Number, default: 3.0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['zoomIn', 'zoomOut', 'zoomTo'])
|
||||
const trackRef = ref(null)
|
||||
|
||||
const fillPercent = computed(() => {
|
||||
return ((props.zoom - props.min) / (props.max - props.min)) * 100
|
||||
})
|
||||
|
||||
function onTrackDown(e) {
|
||||
if (!trackRef.value) return
|
||||
updateFromPointer(e)
|
||||
|
||||
const onMove = (ev) => updateFromPointer(ev)
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
function updateFromPointer(e) {
|
||||
if (!trackRef.value) return
|
||||
const rect = trackRef.value.getBoundingClientRect()
|
||||
// Bottom = min, top = max → invert Y
|
||||
const ratio = 1 - Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
const newZoom = props.min + ratio * (props.max - props.min)
|
||||
emit('zoomTo', newZoom)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.zoom-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 22px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.zoom-control__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.zoom-control__btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.zoom-control__track {
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.zoom-control__fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zoom-control__thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: translate(-50%, 50%);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
.body--light .zoom-control__track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.body--light .zoom-control__fill {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.body--light .zoom-control__thumb {
|
||||
background: #333;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.body--light .zoom-control__btn:active {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
import { ref } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
|
||||
const THUMB_SIZE = 200
|
||||
|
||||
// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation
|
||||
// Shared across all component instances. Bounded: oldest entries are evicted
|
||||
// and their blob URLs revoked once the cap is reached.
|
||||
const MEMORY_CACHE_MAX = 300
|
||||
const memoryCache = new Map()
|
||||
|
||||
// In-flight resolutions: parallel requests for the same URL share one promise
|
||||
// (many dots can mount at once referencing the same image)
|
||||
const inFlight = new Map()
|
||||
|
||||
function memoryCacheSet(url, blobUrl) {
|
||||
if (memoryCache.size >= MEMORY_CACHE_MAX) {
|
||||
// Map iterates in insertion order → first entry is the oldest
|
||||
const [oldestUrl, oldestBlobUrl] = memoryCache.entries().next().value
|
||||
URL.revokeObjectURL(oldestBlobUrl)
|
||||
memoryCache.delete(oldestUrl)
|
||||
}
|
||||
memoryCache.set(url, blobUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
|
||||
* Returns a new Blob (JPEG, quality 0.8).
|
||||
*/
|
||||
function createThumbnail(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(blob)
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = THUMB_SIZE
|
||||
canvas.height = THUMB_SIZE
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
// Cover crop: center the image
|
||||
const scale = Math.max(THUMB_SIZE / img.width, THUMB_SIZE / img.height)
|
||||
const w = img.width * scale
|
||||
const h = img.height * scale
|
||||
const x = (THUMB_SIZE - w) / 2
|
||||
const y = (THUMB_SIZE - h) / 2
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
|
||||
canvas.toBlob(
|
||||
(thumbBlob) => {
|
||||
URL.revokeObjectURL(url)
|
||||
if (thumbBlob) resolve(thumbBlob)
|
||||
else reject(new Error('Canvas toBlob failed'))
|
||||
},
|
||||
'image/jpeg',
|
||||
0.8
|
||||
)
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('Image load failed'))
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
|
||||
*/
|
||||
async function fetchAndCache(imageUrl, eventId) {
|
||||
const response = await fetch(imageUrl)
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
|
||||
// Create thumbnail
|
||||
const thumbBlob = await createThumbnail(blob)
|
||||
|
||||
// Store in IndexedDB
|
||||
await db.imageCache.put({
|
||||
url: imageUrl,
|
||||
eventId,
|
||||
type: 'thumbnail',
|
||||
blob: thumbBlob,
|
||||
cachedAt: Date.now()
|
||||
})
|
||||
|
||||
const blobUrl = URL.createObjectURL(thumbBlob)
|
||||
memoryCacheSet(imageUrl, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached thumbnail blob URL from IndexedDB.
|
||||
* Returns null if not cached.
|
||||
*/
|
||||
async function getCachedImage(imageUrl) {
|
||||
// Check memory first
|
||||
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl)
|
||||
|
||||
try {
|
||||
const entry = await db.imageCache.get(imageUrl)
|
||||
if (entry?.blob) {
|
||||
const blobUrl = URL.createObjectURL(entry.blob)
|
||||
memoryCacheSet(imageUrl, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Image cache read failed:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an image URL to a displayable src (memory → IndexedDB → fetch).
|
||||
* Concurrent calls for the same URL share a single in-flight promise.
|
||||
* Falls back to the original URL if caching fails.
|
||||
*/
|
||||
export function resolveImage(imageUrl, eventId) {
|
||||
if (!imageUrl) return Promise.resolve(null)
|
||||
|
||||
if (memoryCache.has(imageUrl)) {
|
||||
return Promise.resolve(memoryCache.get(imageUrl))
|
||||
}
|
||||
|
||||
if (inFlight.has(imageUrl)) {
|
||||
return inFlight.get(imageUrl)
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const cached = await getCachedImage(imageUrl)
|
||||
if (cached) return cached
|
||||
try {
|
||||
return await fetchAndCache(imageUrl, eventId)
|
||||
} catch (e) {
|
||||
// Fallback: use original URL directly (works when online)
|
||||
console.warn('Image cache failed, using direct URL:', e)
|
||||
return imageUrl
|
||||
}
|
||||
})().finally(() => inFlight.delete(imageUrl))
|
||||
|
||||
inFlight.set(imageUrl, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable: resolves an event's image to a displayable src.
|
||||
* - Checks memory cache → IndexedDB cache → fetches & caches thumbnail.
|
||||
* - Returns reactive `resolvedSrc` ref.
|
||||
*/
|
||||
export function useImageCache(imageUrl, eventId) {
|
||||
const resolvedSrc = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
resolveImage(imageUrl, eventId)
|
||||
.then((src) => { resolvedSrc.value = src })
|
||||
.finally(() => { loading.value = false })
|
||||
|
||||
return { resolvedSrc, loading }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve full-res image for EventPanel (no thumbnail, just cache check).
|
||||
* Returns the original URL — browser Cache-Control handles caching.
|
||||
* When offline, falls back to cached thumbnail.
|
||||
*/
|
||||
export async function resolveFullRes(imageUrl) {
|
||||
if (!imageUrl) return null
|
||||
|
||||
// If online, return original URL (browser caches via HTTP headers)
|
||||
if (navigator.onLine) return imageUrl
|
||||
|
||||
// Offline: try cached thumbnail as fallback
|
||||
const cached = await getCachedImage(imageUrl)
|
||||
return cached || imageUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached images for a specific event.
|
||||
*/
|
||||
export async function clearEventImages(eventId) {
|
||||
try {
|
||||
const entries = await db.imageCache.where('eventId').equals(eventId).toArray()
|
||||
for (const entry of entries) {
|
||||
if (memoryCache.has(entry.url)) {
|
||||
URL.revokeObjectURL(memoryCache.get(entry.url))
|
||||
memoryCache.delete(entry.url)
|
||||
}
|
||||
}
|
||||
await db.imageCache.where('eventId').equals(eventId).delete()
|
||||
} catch (e) {
|
||||
console.warn('Clear event images failed:', e)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable for draggable bottom-sheet panels with snap points.
|
||||
*
|
||||
* Snap stops (in dvh): 100, 75, 50
|
||||
* Close threshold: below 25dvh
|
||||
*
|
||||
* @param {Function} onClose - called when panel is dragged below threshold
|
||||
* @returns {{ panelHeight, handleListeners, resetHeight }}
|
||||
*/
|
||||
export function usePanelDrag(onClose) {
|
||||
const SNAP_POINTS = [100, 75, 50, 25] // dvh values
|
||||
const CLOSE_THRESHOLD = 15 // below this → close
|
||||
|
||||
// Current panel height in dvh (null = use CSS default)
|
||||
const panelHeight = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
let dragging = false
|
||||
let startY = 0
|
||||
let startHeight = 0
|
||||
|
||||
function getViewportHeight() {
|
||||
return window.innerHeight
|
||||
}
|
||||
|
||||
function pxToDvh(px) {
|
||||
return (px / getViewportHeight()) * 100
|
||||
}
|
||||
|
||||
function findNearestSnap(dvh) {
|
||||
let nearest = SNAP_POINTS[0]
|
||||
let minDist = Infinity
|
||||
for (const snap of SNAP_POINTS) {
|
||||
const dist = Math.abs(dvh - snap)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
nearest = snap
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
// Only primary button / single touch
|
||||
if (e.button && e.button !== 0) return
|
||||
dragging = true
|
||||
isDragging.value = true
|
||||
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
||||
startY = clientY
|
||||
|
||||
// Current height: if panelHeight is set use it, else measure from CSS
|
||||
const currentDvh = panelHeight.value ?? 75
|
||||
startHeight = currentDvh
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false })
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
|
||||
// Prevent text selection
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragging) return
|
||||
const clientY = e.clientY
|
||||
handleMove(clientY)
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!dragging) return
|
||||
if (e.touches.length !== 1) return
|
||||
handleMove(e.touches[0].clientY)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function handleMove(clientY) {
|
||||
const deltaY = clientY - startY
|
||||
const deltaDvh = pxToDvh(deltaY)
|
||||
const newHeight = Math.max(10, Math.min(100, startHeight - deltaDvh))
|
||||
panelHeight.value = newHeight
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
finishDrag()
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
finishDrag()
|
||||
}
|
||||
|
||||
function finishDrag() {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
isDragging.value = false
|
||||
|
||||
cleanup()
|
||||
|
||||
const currentHeight = panelHeight.value ?? 75
|
||||
if (currentHeight < CLOSE_THRESHOLD) {
|
||||
panelHeight.value = null
|
||||
onClose()
|
||||
} else {
|
||||
// Snap to nearest point
|
||||
panelHeight.value = findNearestSnap(currentHeight)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
function resetHeight() {
|
||||
panelHeight.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
|
||||
// Event listeners to bind on the handle element
|
||||
const handleListeners = {
|
||||
pointerdown: onPointerDown,
|
||||
touchstart: onPointerDown,
|
||||
}
|
||||
|
||||
return {
|
||||
panelHeight,
|
||||
isDragging,
|
||||
handleListeners,
|
||||
resetHeight,
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
.controls{display:flex;justify-content:space-between;width:100%;max-width:500px;margin-bottom:10px}.button{padding:6px 12px;background-color:#4f46e5;color:#fff;border:none;border-radius:4px;cursor:pointer}.visualization-container{position:relative;width:100%;height:calc(100vh - 86px);overflow:hidden}.gradient-bg{position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;background:linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);background-size:200% 200%;animation:gradientAnimation 20s ease infinite}@keyframes gradientAnimation{0%{background-position:0% 0%}25%{background-position:100% 0%}50%{background-position:100% 100%}75%{background-position:0% 100%}100%{background-position:0% 0%}}.median{position:absolute;top:51.2%;left:0;right:0;height:1px;background-color:rgba(255,255,255,.3);z-index:1}.scroll-container{position:relative;width:100%;height:100%;overflow-x:auto;overflow-y:hidden;min-height:400px;z-index:2;-ms-overflow-style:none;scrollbar-width:none}.scroll-container::-webkit-scrollbar{display:none}.smooth-scroll{scroll-behavior:smooth}.active{cursor:grabbing}.spacer{height:100vh}.dot-tooltip{pointer-events:none;opacity:1}.dot-tooltip .tooltip-background{fill:rgba(0,0,0,0)}.dot-tooltip .tooltip-content{display:flex;justify-content:center;align-items:center;flex-direction:column;width:100%;height:100%;color:#fff}.dot-tooltip .image_container{margin-top:8px;box-shadow:0 0 20px 0 rgba(255,255,255,.25);transition:box-shadow .25s ease-in-out;width:80px;height:80px;overflow:hidden;border-radius:50%;border:2px solid #fff;display:flex;justify-content:center}.dot-tooltip .image_container:hover{box-shadow:0 0 30px 0 rgba(255,255,255,.8)}.dot-tooltip .tooltip-image{width:100%;height:auto;display:block;pointer-events:auto}.dot-tooltip .tooltip-title{font-size:14px;font-weight:400;margin-bottom:2px;text-align:center;text-wrap:balance;-webkit-hyphens:auto;hyphens:auto;line-height:1.1}.dot-tooltip .tooltip-description{font-size:12px;font-weight:300}.dot-tooltip .tooltip-arrow{width:1px;height:30px;background:linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.5), transparent)}.dot{transition:r .2s ease,fill .2s ease;cursor:pointer}.dot:hover{fill:rgba(255,255,255,.9);filter:drop-shadow(0 0 5px rgba(255, 255, 255, 0.8))}.tooltip-img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:4px}.q-card{box-shadow:none !important}.q-card--bordered{box-shadow:none !important}.q-card--flat{box-shadow:none !important}.bg-white,.q-layout__section--marginal{background:rgba(0,0,0,0) !important}footer .text-primary,footer .text-grey{color:#fff !important}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
// Glass button style
|
||||
.glass--button {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// Glass panel style — strong blur for slide-up panels
|
||||
.glass--panel {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: #1a1a1a;
|
||||
|
||||
.body--dark & {
|
||||
background: rgba(30, 30, 30, 0.7);
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
// GlowDot animations — soft opacity pulse on the glow aura
|
||||
@keyframes glowPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ghostPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Quasar SCSS (& Sass) Variables
|
||||
// --------------------------------------------------
|
||||
// To customize the look and feel of this app, you can override
|
||||
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
|
||||
|
||||
// Check documentation for full list of Quasar variables
|
||||
|
||||
// Your own variables (that are declared here) and Quasar's own
|
||||
// ones will be available out of the box in your .vue/.scss/.sass files
|
||||
|
||||
// It's highly recommended to change the default colors
|
||||
// to match your app's branding.
|
||||
// Tip: Use the "Theme Builder" on Quasar's documentation website.
|
||||
|
||||
$primary : #d946ef;
|
||||
$secondary : #a855f7;
|
||||
$accent : #ec4899;
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
|
||||
$positive : #21BA45;
|
||||
$negative : #C10015;
|
||||
$info : #31CCEC;
|
||||
$warning : #F2C037;
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import Dexie from 'dexie'
|
||||
|
||||
export const db = new Dexie('thatsMeDB')
|
||||
|
||||
db.version(1).stores({
|
||||
// Events: indexed by id (PK), date for sorted queries, syncStatus for dirty tracking
|
||||
events: 'id, date, updatedAt, syncStatus',
|
||||
|
||||
// Sync queue: outbound mutations waiting to be pushed to server
|
||||
syncQueue: '++queueId, eventId, action, createdAt',
|
||||
|
||||
// Image cache: offline blob storage for thumbnails
|
||||
imageCache: 'url, eventId, type, cachedAt',
|
||||
|
||||
// Metadata: key-value pairs (lastSyncCursor, userId, etc.)
|
||||
meta: 'key'
|
||||
})
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
<template>
|
||||
<div ref="layoutRef" class="lifewave-layout" :class="{ 'lifewave-layout--dark': isDark }">
|
||||
<!-- FloatingLines Fullscreen Background (always visible) -->
|
||||
<FloatingLines
|
||||
ref="floatingLinesRef"
|
||||
class="lifewave-layout__background"
|
||||
:line-count="[fl.lineCount]"
|
||||
:animation-speed="fl.speed"
|
||||
:num-points="shaderNumPoints"
|
||||
:point-x-values="shaderPointXBase"
|
||||
:scroll-container="scrollContainerEl"
|
||||
:scroll-uv-scale="scrollUvScale"
|
||||
:point-y-values="shaderPointY"
|
||||
:point-colors="shaderPointColors"
|
||||
:line-spread="fl.spread"
|
||||
:fan-spread="fl.fanSpread"
|
||||
:line-sharpness="fl.lineSharpness"
|
||||
:wave-frequency="fl.waveFrequency"
|
||||
:bezier-curvature="fl.bezierCurvature"
|
||||
:circle-radius-px="fl.circleRadius"
|
||||
:circle-glow-size="fl.glowSize"
|
||||
:circle-glow-strength="fl.glowStrength"
|
||||
:line-brightness="fl.lineBrightness ?? 1"
|
||||
:lines-gradient="parsedGradient"
|
||||
:bg-color-center="fl.bgCenter"
|
||||
:bg-color-edge="fl.bgEdge"
|
||||
:background-image="fl.backgroundImage"
|
||||
:mix-blend-mode="'screen'"
|
||||
/>
|
||||
|
||||
<!-- Scrollable Timeline -->
|
||||
<TimelineView
|
||||
ref="timelineViewRef"
|
||||
class="lifewave-layout__timeline"
|
||||
@dot-select="onDotSelect"
|
||||
@view-update="onViewUpdate"
|
||||
/>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="lifewave-header">
|
||||
<span class="lifewave-header__logo" @click="toggleDarkMode">ThatsMe</span>
|
||||
<button class="lifewave-header__user glass--button" @click="userMenuOpen = !userMenuOpen">
|
||||
<q-icon name="person" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- FPS Overlay -->
|
||||
<div v-if="settingsStore.showFps" class="lifewave-fps">
|
||||
{{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS
|
||||
<span class="lifewave-fps__dpr">{{ floatingLinesRef?.dprDisplay ?? '0' }}x</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="lifewave-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Zoom Control (Left Side) — hidden when panel is open -->
|
||||
<ZoomControl
|
||||
v-if="!eventsStore.panelOpen"
|
||||
class="lifewave-layout__zoom"
|
||||
:zoom="currentZoom"
|
||||
:min="zoomMin"
|
||||
:max="zoomMax"
|
||||
@zoom-in="timelineViewRef?.zoomIn()"
|
||||
@zoom-out="timelineViewRef?.zoomOut()"
|
||||
@zoom-to="onZoomTo"
|
||||
/>
|
||||
|
||||
<!-- Settings Button (Bottom-Left, below zoom) — hidden when panel is open -->
|
||||
<button
|
||||
v-if="!eventsStore.panelOpen"
|
||||
class="lifewave-settings-btn glass--button"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<q-icon name="tune" size="22px" :color="isDark ? 'white' : 'grey-8'" />
|
||||
</button>
|
||||
|
||||
<!-- Add Event Button (Bottom-Center) — hidden when panel is open -->
|
||||
<AddEventButton v-if="!eventsStore.panelOpen" @click="onAddEvent" />
|
||||
|
||||
<!-- Backdrop blur when panel is open -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="eventsStore.panelOpen"
|
||||
class="lifewave-backdrop"
|
||||
@click="eventsStore.closePanel()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Event Panel (Slide-Up) -->
|
||||
<EventPanel />
|
||||
|
||||
<!-- Wave Settings Backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="settingsOpen && !eventsStore.panelOpen"
|
||||
class="lifewave-backdrop lifewave-backdrop--no-blur"
|
||||
@click="settingsOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Wave Settings Panel (Slide-Up) -->
|
||||
<LifeWaveSettings
|
||||
:open="settingsOpen && !eventsStore.panelOpen"
|
||||
@close="settingsOpen = false"
|
||||
/>
|
||||
|
||||
<!-- User Menu Backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
class="lifewave-backdrop"
|
||||
@click="userMenuOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- User Menu (Slide from right) -->
|
||||
<UserMenu
|
||||
:open="userMenuOpen"
|
||||
@close="userMenuOpen = false"
|
||||
@navigate="onUserMenuNavigate"
|
||||
/>
|
||||
|
||||
<!-- App Settings Modal Backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="appSettingsOpen"
|
||||
class="lifewave-backdrop"
|
||||
@click="appSettingsOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- App Settings Modal -->
|
||||
<AppSettingsModal
|
||||
:open="appSettingsOpen"
|
||||
@close="appSettingsOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import AddEventButton from 'components/AddEventButton.vue'
|
||||
import EventPanel from 'components/EventPanel.vue'
|
||||
import FloatingLines from 'components/FloatingLines.vue'
|
||||
import LifeWaveSettings from 'components/LifeWaveSettings.vue'
|
||||
import TimelineView from 'components/TimelineView.vue'
|
||||
import AppSettingsModal from 'components/AppSettingsModal.vue'
|
||||
import UserMenu from 'components/UserMenu.vue'
|
||||
import ZoomControl from 'components/ZoomControl.vue'
|
||||
import { useEventsStore } from 'stores/events'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
|
||||
const $q = useQuasar()
|
||||
const eventsStore = useEventsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDark = computed(() => $q.dark.isActive)
|
||||
const settingsOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
const appSettingsOpen = ref(false)
|
||||
const floatingLinesRef = ref(null)
|
||||
const fl = computed(() => settingsStore.floatingLines)
|
||||
|
||||
// Timeline view ref (for direct scroll access in render loop)
|
||||
const timelineViewRef = ref(null)
|
||||
const scrollContainerEl = computed(() => timelineViewRef.value?.timelineRef ?? null)
|
||||
|
||||
// Layout dimensions (for screen→UV conversion)
|
||||
const layoutRef = ref(null)
|
||||
const layoutWidth = ref(window.innerWidth)
|
||||
const layoutHeight = ref(window.innerHeight)
|
||||
let layoutResizeObserver = null
|
||||
|
||||
onMounted(() => {
|
||||
if (layoutRef.value) {
|
||||
layoutWidth.value = layoutRef.value.clientWidth
|
||||
layoutHeight.value = layoutRef.value.clientHeight
|
||||
layoutResizeObserver = new ResizeObserver(() => {
|
||||
layoutWidth.value = layoutRef.value.clientWidth
|
||||
layoutHeight.value = layoutRef.value.clientHeight
|
||||
})
|
||||
layoutResizeObserver.observe(layoutRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
layoutResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
// Timeline state (received from TimelineView via emit)
|
||||
const timelineState = ref(null)
|
||||
|
||||
function onViewUpdate(state) {
|
||||
timelineState.value = state
|
||||
}
|
||||
|
||||
// Convert screen pixel coordinates → shader UV space
|
||||
// Shader: baseUv = (2*fragCoord - iResolution) / iResolution.y; baseUv.y *= -1;
|
||||
// For CSS pixel (sx, sy) from top-left:
|
||||
// uvX = (2*sx - cssWidth) / cssHeight
|
||||
// uvY = (2*sy - cssHeight) / cssHeight
|
||||
function screenToUV(sx, sy) {
|
||||
const w = layoutWidth.value
|
||||
const h = layoutHeight.value
|
||||
return {
|
||||
x: (2 * sx - w) / h,
|
||||
y: (2 * sy - h) / h
|
||||
}
|
||||
}
|
||||
|
||||
// Compute shader point positions from event positions
|
||||
const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px }
|
||||
|
||||
// Select up to 8 points from visible window + boundary events for shader lines
|
||||
const shaderSelection = computed(() => {
|
||||
if (!timelineState.value) return []
|
||||
const { events, visibleStart, visibleEnd } = timelineState.value
|
||||
if (events.length === 0) return []
|
||||
|
||||
// Include 3 events before and after visible range for smooth line continuity
|
||||
const rangeStart = Math.max(0, (visibleStart ?? 0) - 3)
|
||||
const rangeEnd = Math.min(events.length - 1, (visibleEnd ?? events.length - 1) + 3)
|
||||
|
||||
let candidates = events.slice(rangeStart, rangeEnd + 1)
|
||||
|
||||
// If more than 16, subsample evenly (keep first + last)
|
||||
if (candidates.length > 16) {
|
||||
const sampled = [candidates[0]]
|
||||
const step = (candidates.length - 1) / 15
|
||||
for (let i = 1; i < 15; i++) {
|
||||
sampled.push(candidates[Math.round(i * step)])
|
||||
}
|
||||
sampled.push(candidates[candidates.length - 1])
|
||||
candidates = sampled
|
||||
}
|
||||
|
||||
return candidates
|
||||
})
|
||||
|
||||
const shaderNumPoints = computed(() => shaderSelection.value.length)
|
||||
|
||||
// Base X positions in UV space WITHOUT scroll offset.
|
||||
// FloatingLines applies the live scrollLeft in its render loop for perfect sync.
|
||||
const shaderPointXBase = computed(() => {
|
||||
const xs = Array(16).fill(0)
|
||||
const sel = shaderSelection.value
|
||||
const w = layoutWidth.value
|
||||
const h = layoutHeight.value
|
||||
for (let i = 0; i < sel.length; i++) {
|
||||
xs[i] = (2 * sel[i].x - w) / h
|
||||
}
|
||||
return xs
|
||||
})
|
||||
|
||||
// Scale factor to convert scrollLeft pixels → UV offset
|
||||
const scrollUvScale = computed(() => 2.0 / layoutHeight.value)
|
||||
|
||||
const shaderPointY = computed(() => {
|
||||
const ys = Array(16).fill(0)
|
||||
if (!timelineState.value) return ys
|
||||
const sel = shaderSelection.value
|
||||
const tlHeight = timelineState.value.containerHeight
|
||||
for (let i = 0; i < sel.length; i++) {
|
||||
const yPercent = 48 - sel[i].emotion * 30
|
||||
const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight
|
||||
ys[i] = screenToUV(0, screenY).y
|
||||
}
|
||||
return ys
|
||||
})
|
||||
|
||||
const shaderPointColors = computed(() => {
|
||||
return shaderSelection.value.map(e => e.color || '#ffffff')
|
||||
})
|
||||
|
||||
// Parse gradient stops from textarea string
|
||||
const parsedGradient = computed(() => {
|
||||
return fl.value.gradientStops
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && s.startsWith('#'))
|
||||
})
|
||||
|
||||
// Zoom state from TimelineView
|
||||
const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1)
|
||||
const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4)
|
||||
const zoomMax = computed(() => timelineViewRef.value?.MAX_ZOOM ?? 3.0)
|
||||
|
||||
function onZoomTo(value) {
|
||||
if (!timelineViewRef.value) return
|
||||
const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value))
|
||||
// Use applyZoom exposed or set directly — we use the internal method indirectly
|
||||
// by computing step from current to target
|
||||
const tv = timelineViewRef.value
|
||||
const el = tv.timelineRef
|
||||
if (!el) return
|
||||
const cx = el.clientWidth / 2
|
||||
const worldX = el.scrollLeft + cx
|
||||
const ratio = clamped / tv.zoomLevel
|
||||
tv.zoomLevel = clamped
|
||||
// Restore scroll position to keep center stable
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = worldX * ratio - cx
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
$q.dark.toggle()
|
||||
}
|
||||
|
||||
const onAddEvent = () => {
|
||||
eventsStore.openPanel()
|
||||
}
|
||||
|
||||
const onDotSelect = (id) => {
|
||||
eventsStore.selectEvent(id)
|
||||
eventsStore.openPanel(id)
|
||||
}
|
||||
|
||||
const onUserMenuNavigate = (target) => {
|
||||
userMenuOpen.value = false
|
||||
if (target === 'settings') {
|
||||
appSettingsOpen.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lifewave-layout {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #F5F5F5;
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.lifewave-layout--dark {
|
||||
background: #000;
|
||||
color: #F5F5F5;
|
||||
}
|
||||
|
||||
/* FloatingLines Background */
|
||||
.lifewave-layout__background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Timeline — positioning comes from TimelineView's own .timeline class */
|
||||
.lifewave-layout__timeline {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.lifewave-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.lifewave-header__logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lifewave-header__user {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Zoom Control */
|
||||
.lifewave-layout__zoom {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Settings Button — bottom-left, below zoom */
|
||||
.lifewave-settings-btn {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #F5F5F5;
|
||||
}
|
||||
.lifewave-settings-btn--dark {
|
||||
color: #000;
|
||||
}
|
||||
/* Content */
|
||||
.lifewave-content {
|
||||
padding-top: 72px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Backdrop blur overlay */
|
||||
.lifewave-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(1px);
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.lifewave-backdrop--no-blur {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* FPS Overlay */
|
||||
.lifewave-fps {
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
right: 16px;
|
||||
z-index: 30;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #0f0;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lifewave-fps__dpr {
|
||||
color: #999;
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<template>
|
||||
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">
|
||||
404
|
||||
</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.4">
|
||||
Oops. Nothing here...
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
color="white"
|
||||
text-color="blue"
|
||||
unelevated
|
||||
to="/"
|
||||
label="Go Home"
|
||||
no-caps
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<template>
|
||||
<!-- LifeWave page is intentionally empty — all visuals are rendered by LifeWaveLayout -->
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Login</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit.prevent="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
filled
|
||||
:rules="[val => !!val || 'Email is required']"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
filled
|
||||
:rules="[val => !!val || 'Password is required']"
|
||||
/>
|
||||
|
||||
<div class="q-mt-md">
|
||||
<q-btn label="Login" type="submit" color="primary" class="full-width"/>
|
||||
</div>
|
||||
|
||||
<div class="text-center q-mt-sm">
|
||||
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'LoginPage',
|
||||
setup() {
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const onSubmit = () => {
|
||||
console.log('Login attempt with:', email.value, password.value)
|
||||
|
||||
// Save login status
|
||||
localStorage.setItem('isLoggedIn', 'true')
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
|
||||
console.log('Redirecting to wave page...')
|
||||
|
||||
// Direct navigation
|
||||
window.location.href = '/#/wave'
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
onSubmit
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Password Reset</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<p class="text-body2 q-mb-md">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
filled
|
||||
:rules="[val => !!val || 'Email is required']"
|
||||
/>
|
||||
|
||||
<div class="q-mt-md">
|
||||
<q-btn
|
||||
label="Send Reset Link"
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="full-width"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-center q-mt-sm">
|
||||
<router-link to="/login" class="text-primary">Back to login</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
|
||||
<q-dialog v-model="successDialog">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Email Sent</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<p>If an account exists with that email, we've sent instructions to reset your password.</p>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" color="primary" v-close-popup @click="goToLogin" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'PasswordResetPage',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const successDialog = ref(false)
|
||||
|
||||
const onSubmit = () => {
|
||||
loading.value = true
|
||||
|
||||
// Here you would call your API to request password reset
|
||||
console.log('Password reset requested for:', email.value)
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
successDialog.value = true
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
loading,
|
||||
onSubmit,
|
||||
successDialog,
|
||||
goToLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Sign Up</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input v-model="firstName" label="First Name" filled :rules="[val => !!val || 'First name is required']" />
|
||||
<q-input v-model="lastName" label="Last Name" filled :rules="[val => !!val || 'Last name is required']" />
|
||||
<!-- Add gender selection -->
|
||||
<q-select v-model="gender" :options="genderOptions" label="Gender" filled
|
||||
:rules="[val => !!val || 'Please select a gender']" />
|
||||
<q-input v-model="email" type="email" label="Email" filled :rules="[
|
||||
val => !!val || 'Email is required',
|
||||
val => /^[^@]+@[^@]+\.[^@]+$/.test(val) || 'Please enter a valid email'
|
||||
]" />
|
||||
<q-input v-model="password" type="password" label="Password" filled :rules="[
|
||||
val => !!val || 'Password is required',
|
||||
val => val.length >= 8 || 'Password must be at least 8 characters'
|
||||
]" />
|
||||
<q-input v-model="confirmPassword" type="password" label="Confirm Password" filled :rules="[
|
||||
val => !!val || 'Please confirm your password',
|
||||
val => val === password || 'Passwords do not match'
|
||||
]" />
|
||||
<q-checkbox v-model="termsAccepted" label="I agree to the Terms of Service">
|
||||
<template v-slot:default>
|
||||
I agree to the <a href="#" @click.prevent="showTerms = true" class="text-primary">Terms of Service</a>
|
||||
</template>
|
||||
</q-checkbox>
|
||||
<div class="q-mt-md">
|
||||
<q-btn label="Sign Up" type="submit" color="primary" class="full-width" :loading="loading"
|
||||
:disable="!termsAccepted" />
|
||||
</div>
|
||||
<div class="text-center q-mt-sm">
|
||||
Already have an account? <router-link to="/login" class="text-primary">Log in</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-dialog v-model="successDialog">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Registration Successful</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<p>Your account has been created successfully. You can now log in.</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Go to Login" color="primary" v-close-popup @click="goToLogin" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="showTerms">
|
||||
<q-card style="width: 700px; max-width: 80vw">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Terms of Service</div>
|
||||
</q-card-section>
|
||||
<q-card-section style="max-height: 70vh" class="scroll">
|
||||
<p>These Terms of Service ("Terms") govern your use of our website and services.</p>
|
||||
<p>By using our services, you agree to these Terms. Please read them carefully.</p>
|
||||
<h6 class="text-subtitle1 q-mt-md">1. Your Account</h6>
|
||||
<p>You are responsible for safeguarding your account and for any activities or actions under your account.
|
||||
</p>
|
||||
<h6 class="text-subtitle1 q-mt-md">2. Privacy</h6>
|
||||
<p>Our Privacy Policy explains how we treat your personal data and protect your privacy.</p>
|
||||
<!-- Add more terms as needed -->
|
||||
<h6 class="text-subtitle1 q-mt-md">3. Termination</h6>
|
||||
<p>We may terminate or suspend your account at any time for any reason.</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
export default {
|
||||
name: 'SignUpPage',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const termsAccepted = ref(false)
|
||||
const loading = ref(false)
|
||||
const successDialog = ref(false)
|
||||
const showTerms = ref(false)
|
||||
// Add gender field
|
||||
const gender = ref('')
|
||||
const genderOptions = [
|
||||
'Male',
|
||||
'Female',
|
||||
'Non-binary',
|
||||
'Prefer not to say'
|
||||
]
|
||||
const onSubmit = () => {
|
||||
loading.value = true
|
||||
// Here you would call your API to register the user
|
||||
console.log('Sign up attempt with:', {
|
||||
firstName: firstName.value,
|
||||
lastName: lastName.value,
|
||||
gender: gender.value,
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
successDialog.value = true
|
||||
}, 1500)
|
||||
}
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
gender,
|
||||
genderOptions,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
termsAccepted,
|
||||
loading,
|
||||
onSubmit,
|
||||
successDialog,
|
||||
showTerms,
|
||||
goToLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { defineRouter } from '#q-app/wrappers'
|
||||
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import routes from './routes'
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
* directly export the Router instantiation;
|
||||
*
|
||||
* The function below can be async too; either use
|
||||
* async/await or return a Promise which resolves
|
||||
* with the Router instance.
|
||||
*/
|
||||
|
||||
export default defineRouter(function (/* { store, ssrContext } */) {
|
||||
const createHistory = process.env.SERVER
|
||||
? createMemoryHistory
|
||||
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
|
||||
|
||||
const Router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
routes,
|
||||
|
||||
// Leave this as is and make changes in quasar.conf.js instead!
|
||||
// quasar.conf.js -> build -> vueRouterMode
|
||||
// quasar.conf.js -> build -> publicPath
|
||||
history: createHistory(process.env.VUE_ROUTER_BASE)
|
||||
})
|
||||
|
||||
return Router
|
||||
})
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('layouts/LifeWaveLayout.vue'),
|
||||
children: [
|
||||
{ path: '', component: () => import('pages/LifeWavePage.vue') }
|
||||
]
|
||||
},
|
||||
|
||||
// Always leave this as last one
|
||||
{
|
||||
path: '/:catchAll(.*)*',
|
||||
component: () => import('pages/ErrorNotFound.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
import { ref } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
|
||||
// API base URL — configured per environment
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||
|
||||
const isSyncing = ref(false)
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const lastSyncAt = ref(null)
|
||||
|
||||
// Track online status
|
||||
window.addEventListener('online', () => {
|
||||
isOnline.value = true
|
||||
processSyncQueue()
|
||||
})
|
||||
window.addEventListener('offline', () => {
|
||||
isOnline.value = false
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the stored OAuth access token.
|
||||
*/
|
||||
async function getToken() {
|
||||
try {
|
||||
const meta = await db.meta.get('accessToken')
|
||||
return meta?.value || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an OAuth access token.
|
||||
*/
|
||||
async function setToken(token) {
|
||||
await db.meta.put({ key: 'accessToken', value: token })
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch wrapper.
|
||||
*/
|
||||
async function apiFetch(path, options = {}) {
|
||||
const token = await getToken()
|
||||
if (!token) throw new Error('Not authenticated')
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired — clear it
|
||||
await db.meta.delete('accessToken')
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the outbound sync queue (FIFO).
|
||||
* Called on app start, every 30s when online, and on reconnect.
|
||||
*/
|
||||
async function processSyncQueue() {
|
||||
if (!isOnline.value || isSyncing.value) return
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
|
||||
isSyncing.value = true
|
||||
|
||||
try {
|
||||
const queue = await db.syncQueue.orderBy('queueId').toArray()
|
||||
if (queue.length === 0) {
|
||||
isSyncing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Batch sync: send up to 100 mutations at once
|
||||
const batch = queue.slice(0, 100)
|
||||
const mutations = batch.map((item) => ({
|
||||
action: item.action,
|
||||
eventId: item.eventId,
|
||||
payload: item.payload,
|
||||
}))
|
||||
|
||||
const response = await apiFetch('/events/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mutations }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Remove successfully processed items from queue
|
||||
const processedIds = []
|
||||
data.results.forEach((result, i) => {
|
||||
if (result.status === 'ok') {
|
||||
processedIds.push(batch[i].queueId)
|
||||
}
|
||||
})
|
||||
|
||||
if (processedIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(processedIds)
|
||||
}
|
||||
|
||||
// Update syncStatus on local events
|
||||
for (const result of data.results) {
|
||||
if (result.status === 'ok') {
|
||||
const event = await db.events.get(result.eventId)
|
||||
if (event && event.syncStatus !== 'local') {
|
||||
await db.events.update(result.eventId, { syncStatus: 'synced' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSyncAt.value = Date.now()
|
||||
|
||||
// If there are more items, process next batch
|
||||
if (queue.length > 100) {
|
||||
await processSyncQueue()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Sync queue processing failed:', e)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull remote changes since last sync cursor.
|
||||
* Merges with local data using "last write wins" on updatedAt.
|
||||
*/
|
||||
async function pullRemoteChanges() {
|
||||
if (!isOnline.value) return
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const lastSync = await db.meta.get('lastSyncCursor')
|
||||
const since = lastSync?.value || null
|
||||
|
||||
let url = '/events?limit=200'
|
||||
if (since) {
|
||||
url += `&since=${since}`
|
||||
}
|
||||
|
||||
const response = await apiFetch(url)
|
||||
if (!response.ok) return
|
||||
|
||||
const data = await response.json()
|
||||
const remoteEvents = data.data || []
|
||||
|
||||
for (const remote of remoteEvents) {
|
||||
const local = await db.events.get(remote.id)
|
||||
|
||||
if (!local) {
|
||||
// New event from server
|
||||
await db.events.put({
|
||||
id: remote.id,
|
||||
title: remote.title,
|
||||
date: remote.date,
|
||||
emotion: remote.emotion,
|
||||
customColor: remote.customColor,
|
||||
gradientPreset: remote.gradientPreset,
|
||||
image: remote.image,
|
||||
note: remote.note,
|
||||
syncStatus: 'synced',
|
||||
createdAt: remote.createdAt,
|
||||
updatedAt: remote.updatedAt,
|
||||
})
|
||||
} else if (remote.updatedAt > local.updatedAt && local.syncStatus === 'synced') {
|
||||
// Remote is newer and local hasn't been modified — update
|
||||
await db.events.update(remote.id, {
|
||||
title: remote.title,
|
||||
date: remote.date,
|
||||
emotion: remote.emotion,
|
||||
customColor: remote.customColor,
|
||||
gradientPreset: remote.gradientPreset,
|
||||
image: remote.image,
|
||||
note: remote.note,
|
||||
syncStatus: 'synced',
|
||||
updatedAt: remote.updatedAt,
|
||||
})
|
||||
}
|
||||
// If local is modified, skip — local changes will be pushed via sync queue
|
||||
}
|
||||
|
||||
// Update sync cursor
|
||||
await db.meta.put({ key: 'lastSyncCursor', value: new Date().toISOString() })
|
||||
|
||||
// Handle pagination (cursor-based)
|
||||
if (data.next_cursor) {
|
||||
// There are more pages — but for now we only pull one batch
|
||||
// Future: iterate through pages
|
||||
}
|
||||
|
||||
lastSyncAt.value = Date.now()
|
||||
} catch (e) {
|
||||
console.warn('Pull remote changes failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync: push local changes, then pull remote.
|
||||
*/
|
||||
async function fullSync() {
|
||||
await processSyncQueue()
|
||||
await pullRemoteChanges()
|
||||
}
|
||||
|
||||
// Auto-sync interval (30s)
|
||||
let syncInterval = null
|
||||
|
||||
function startAutoSync() {
|
||||
if (syncInterval) return
|
||||
syncInterval = setInterval(() => {
|
||||
if (isOnline.value) {
|
||||
fullSync()
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
// Initial sync
|
||||
fullSync()
|
||||
}
|
||||
|
||||
function stopAutoSync() {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
isOnline,
|
||||
isSyncing,
|
||||
lastSyncAt,
|
||||
getToken,
|
||||
setToken,
|
||||
apiFetch,
|
||||
processSyncQueue,
|
||||
pullRemoteChanges,
|
||||
fullSync,
|
||||
startAutoSync,
|
||||
stopAutoSync,
|
||||
}
|
||||
|
|
@ -1,418 +0,0 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch, shallowRef, triggerRef } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
import { startAutoSync, getToken } from 'src/services/syncService'
|
||||
|
||||
// Color interpolation
|
||||
function lerpColor(a, b, t) {
|
||||
const ar = parseInt(a.slice(1, 3), 16)
|
||||
const ag = parseInt(a.slice(3, 5), 16)
|
||||
const ab = parseInt(a.slice(5, 7), 16)
|
||||
const br = parseInt(b.slice(1, 3), 16)
|
||||
const bg = parseInt(b.slice(3, 5), 16)
|
||||
const bb = parseInt(b.slice(5, 7), 16)
|
||||
const r = Math.round(ar + (br - ar) * t)
|
||||
const g = Math.round(ag + (bg - ag) * t)
|
||||
const blue = Math.round(ab + (bb - ab) * t)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Gradient presets: [negative, neutral, positive]
|
||||
const GRADIENT_PRESETS = [
|
||||
{ name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] },
|
||||
{ name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] },
|
||||
{ name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] },
|
||||
{ name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] },
|
||||
{ name: 'Spring', colors: ['#FDBB2D', '#96BE74', '#22C1C3'] },
|
||||
{ name: 'Neon', colors: ['#FC466B', '#9A52B6', '#3F5EFB'] },
|
||||
{ name: 'Pastel', colors: ['#EEAECA', '#C2B4D9', '#94BBE9'] },
|
||||
{ name: 'Aurora', colors: ['#FF6B6B', '#C084FC', '#67E8F9'] },
|
||||
{ name: 'Forest', colors: ['#DC2626', '#A3A830', '#059669'] },
|
||||
{ name: 'Berry', colors: ['#F472B6', '#FB923C', '#A78BFA'] }
|
||||
]
|
||||
|
||||
// Glow color logic: emotion value → color, with optional gradient preset
|
||||
function emotionToColor(emotion, gradientIdx = null) {
|
||||
const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null
|
||||
if (preset) {
|
||||
const [neg, mid, pos] = preset.colors
|
||||
if (emotion >= 0) {
|
||||
return lerpColor(mid, pos, emotion)
|
||||
} else {
|
||||
return lerpColor(mid, neg, Math.abs(emotion))
|
||||
}
|
||||
}
|
||||
if (emotion >= 0) {
|
||||
if (emotion < 0.5) {
|
||||
return lerpColor('#FF6B35', '#FFD700', emotion / 0.5)
|
||||
}
|
||||
return lerpColor('#FFD700', '#4CAF50', (emotion - 0.5) / 0.5)
|
||||
} else {
|
||||
const abs = Math.abs(emotion)
|
||||
if (abs < 0.5) {
|
||||
return lerpColor('#2196F3', '#9C27B0', abs / 0.5)
|
||||
}
|
||||
return lerpColor('#9C27B0', '#E91E63', (abs - 0.5) / 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// Demo seed data
|
||||
const demoEvents = [
|
||||
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||||
{ id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }
|
||||
]
|
||||
|
||||
// Generate realistic demo events for testing at scale
|
||||
function generateManyEvents(count = 500) {
|
||||
// Realistic life event categories with emotion ranges
|
||||
const categories = [
|
||||
// Positive events
|
||||
{ titles: ['Geburtstag', 'Geburtstagsfeier', 'Überraschungsparty'], emotionRange: [0.3, 0.8], noteChance: 0.4, notes: ['Tolles Fest!', 'Viele Geschenke', 'Schöner Tag mit Freunden', 'Alles Gute!'] },
|
||||
{ titles: ['Urlaub', 'Strandurlaub', 'Städtereise', 'Roadtrip', 'Backpacking'], emotionRange: [0.4, 0.95], noteChance: 0.6, notes: ['Unvergesslich', 'Wunderschöne Landschaft', 'Endlich Erholung', 'Muss ich wiederholen'] },
|
||||
{ titles: ['Hochzeit', 'Verlobung', 'Jahrestag'], emotionRange: [0.7, 1.0], noteChance: 0.8, notes: ['Der schönste Tag', 'Für immer', 'Tränen der Freude', 'Unbeschreiblich'] },
|
||||
{ titles: ['Beförderung', 'Neuer Job', 'Gehaltserhöhung', 'Jobangebot'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Endlich!', 'Harte Arbeit zahlt sich aus', 'Neues Kapitel', 'Verdient'] },
|
||||
{ titles: ['Konzert', 'Festival', 'Theaterbesuch', 'Oper'], emotionRange: [0.3, 0.85], noteChance: 0.5, notes: ['Gänsehaut', 'Beste Band ever', 'Geniale Atmosphäre', 'Nächstes Jahr wieder'] },
|
||||
{ titles: ['Geburt', 'Baby da!', 'Nachwuchs'], emotionRange: [0.85, 1.0], noteChance: 0.9, notes: ['Das größte Wunder', 'Willkommen auf der Welt', 'Unbeschreibliches Glück'] },
|
||||
{ titles: ['Abschluss', 'Prüfung bestanden', 'Diplom', 'Master geschafft'], emotionRange: [0.6, 0.95], noteChance: 0.6, notes: ['Geschafft!', 'Jahre harter Arbeit', 'Stolz', 'Endlich vorbei'] },
|
||||
{ titles: ['Bergwanderung', 'Gipfel erreicht', 'Marathon geschafft', 'Triathlon'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Was für ein Ausblick!', 'Körperliche Grenzen überwunden', 'Nie aufgeben'] },
|
||||
{ titles: ['Hauskauf', 'Wohnungseinweihung', 'Renovierung fertig'], emotionRange: [0.4, 0.8], noteChance: 0.5, notes: ['Endlich eigene vier Wände', 'Traum wird wahr', 'Viel Arbeit, aber es lohnt sich'] },
|
||||
{ titles: ['Erstes Date', 'Zusammengekommen', 'Liebeserklärung'], emotionRange: [0.5, 0.95], noteChance: 0.6, notes: ['Schmetterlinge', 'Liebe auf den ersten Blick', 'Endlich getraut'] },
|
||||
// Neutral events
|
||||
{ titles: ['Umzug', 'Neue Stadt', 'Wohnungswechsel'], emotionRange: [-0.2, 0.3], noteChance: 0.4, notes: ['Neuanfang', 'Alles anders', 'Spannend und stressig zugleich'] },
|
||||
{ titles: ['Arztbesuch', 'Vorsorge', 'Check-up'], emotionRange: [-0.1, 0.1], noteChance: 0.2, notes: ['Alles okay', 'Routine'] },
|
||||
{ titles: ['Meeting', 'Präsentation', 'Workshop'], emotionRange: [-0.1, 0.4], noteChance: 0.3, notes: ['Gut gelaufen', 'Viel gelernt', 'Anstrengend'] },
|
||||
{ titles: ['Friseur', 'Shopping', 'Einkauf'], emotionRange: [0.0, 0.3], noteChance: 0.1, notes: ['Neuer Look', 'Guter Fund'] },
|
||||
// Negative events
|
||||
{ titles: ['Trennung', 'Beziehungsende', 'Scheidung'], emotionRange: [-1.0, -0.5], noteChance: 0.5, notes: ['Schmerzhaft', 'Warum?', 'Es ist besser so', 'Brauche Zeit'] },
|
||||
{ titles: ['Jobverlust', 'Kündigung', 'Firma pleite'], emotionRange: [-0.9, -0.4], noteChance: 0.5, notes: ['Schock', 'Wie geht es weiter?', 'Unverdient'] },
|
||||
{ titles: ['Krankheit', 'OP', 'Krankenhaus'], emotionRange: [-0.8, -0.3], noteChance: 0.6, notes: ['Wird schon', 'Hauptsache gesund werden', 'Lange Genesung'] },
|
||||
{ titles: ['Abschied', 'Verlust', 'Trauer'], emotionRange: [-1.0, -0.6], noteChance: 0.7, notes: ['Ruhe in Frieden', 'Fehlt mir', 'Unvergessen', 'Schwerer Tag'] },
|
||||
{ titles: ['Streit', 'Konflikt', 'Auseinandersetzung'], emotionRange: [-0.7, -0.2], noteChance: 0.3, notes: ['Muss nicht sein', 'Hoffe auf Klärung'] },
|
||||
{ titles: ['Unfall', 'Panne', 'Autopanne'], emotionRange: [-0.6, -0.2], noteChance: 0.4, notes: ['Zum Glück nichts Schlimmes', 'Ärgerlich', 'Hätte schlimmer sein können'] },
|
||||
{ titles: ['Prüfung nicht bestanden', 'Absage', 'Ablehnung'], emotionRange: [-0.7, -0.3], noteChance: 0.4, notes: ['Nächstes Mal', 'Nicht aufgeben', 'Enttäuschend'] },
|
||||
]
|
||||
|
||||
const demoImages = [
|
||||
'demo/photo-1530103862676-de8c9debad1d.jpeg',
|
||||
'demo/photo-1534067783941-51c9c23ecefd.jpeg',
|
||||
'demo/photo-1506905925346-21bda4d32df4.jpeg'
|
||||
]
|
||||
|
||||
// Seeded random for reproducibility
|
||||
let seed = 42
|
||||
function rand() {
|
||||
seed = (seed * 16807 + 0) % 2147483647
|
||||
return (seed - 1) / 2147483646
|
||||
}
|
||||
|
||||
function randInt(min, max) {
|
||||
return Math.floor(rand() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(rand() * arr.length)]
|
||||
}
|
||||
|
||||
function randFloat(min, max) {
|
||||
return Math.round((min + rand() * (max - min)) * 100) / 100
|
||||
}
|
||||
|
||||
const evts = []
|
||||
const startYear = 1985
|
||||
const endYear = 2026
|
||||
|
||||
// Generate events with realistic distribution (more events in recent years)
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Weight towards recent years: cube root distribution
|
||||
const t = rand()
|
||||
const yearFloat = startYear + (endYear - startYear) * (t * t * 0.4 + t * 0.6)
|
||||
const year = Math.floor(yearFloat)
|
||||
const month = randInt(1, 12)
|
||||
const day = randInt(1, 28) // safe for all months
|
||||
const date = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
|
||||
const cat = pick(categories)
|
||||
const title = pick(cat.titles)
|
||||
const emotion = randFloat(cat.emotionRange[0], cat.emotionRange[1])
|
||||
const hasNote = rand() < cat.noteChance
|
||||
const note = hasNote ? pick(cat.notes) : ''
|
||||
const hasImage = rand() < 0.15 // 15% chance
|
||||
const image = hasImage ? pick(demoImages) : null
|
||||
const hasPreset = rand() < 0.25 // 25% chance
|
||||
const gradientPreset = hasPreset ? randInt(0, 9) : null
|
||||
|
||||
evts.push({
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
date,
|
||||
emotion,
|
||||
customColor: null,
|
||||
gradientPreset,
|
||||
image,
|
||||
note,
|
||||
syncStatus: 'local',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by date
|
||||
evts.sort((a, b) => a.date.localeCompare(b.date))
|
||||
return evts
|
||||
}
|
||||
|
||||
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents }
|
||||
|
||||
export const useEventsStore = defineStore('events', () => {
|
||||
// shallowRef: event objects are never mutated field-by-field, only replaced
|
||||
// wholesale — deep reactivity on 500+ objects would be pure proxy overhead.
|
||||
// In-place array mutations must call triggerRef(events) afterwards.
|
||||
const events = shallowRef([])
|
||||
const isLoaded = ref(false)
|
||||
const selectedEventId = ref(null)
|
||||
const panelOpen = ref(false)
|
||||
const editingEventId = ref(null)
|
||||
|
||||
// Load events from IndexedDB; seed demo data on first launch
|
||||
async function init() {
|
||||
try {
|
||||
let stored = await db.events.orderBy('date').toArray()
|
||||
if (stored.length === 0) {
|
||||
const seed = generateManyEvents(500)
|
||||
await db.events.bulkPut(seed)
|
||||
stored = seed
|
||||
}
|
||||
events.value = stored
|
||||
} catch (e) {
|
||||
console.warn('Dexie load failed, using demo data:', e)
|
||||
events.value = [...demoEvents]
|
||||
}
|
||||
isLoaded.value = true
|
||||
|
||||
// Start auto-sync if authenticated
|
||||
getToken().then((token) => {
|
||||
if (token) startAutoSync()
|
||||
})
|
||||
}
|
||||
|
||||
// Fire-and-forget DB write (UI already updated via ref)
|
||||
function dbPut(event) {
|
||||
db.events.put(event).catch(e => console.warn('Dexie put failed:', e))
|
||||
}
|
||||
|
||||
function dbDelete(id) {
|
||||
db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e))
|
||||
}
|
||||
|
||||
function dbQueueSync(eventId, action, payload) {
|
||||
db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() })
|
||||
.catch(e => console.warn('Dexie sync queue failed:', e))
|
||||
}
|
||||
|
||||
// Queue an 'update' mutation. Any older pending update for the same event
|
||||
// is replaced, so the queue holds at most one update per event.
|
||||
function dbQueueUpdate(event) {
|
||||
db.syncQueue
|
||||
.where('eventId').equals(event.id)
|
||||
.and((item) => item.action === 'update')
|
||||
.delete()
|
||||
.then(() => db.syncQueue.add({
|
||||
eventId: event.id,
|
||||
action: 'update',
|
||||
payload: { ...event },
|
||||
createdAt: Date.now()
|
||||
}))
|
||||
.catch(e => console.warn('Dexie sync queue failed:', e))
|
||||
}
|
||||
|
||||
// Ghost event for live preview while creating/editing
|
||||
const ghostEmotion = ref(0)
|
||||
const ghostCustomColor = ref(null)
|
||||
const ghostGradientPreset = ref(null)
|
||||
const ghostTitle = ref('')
|
||||
const ghostDate = ref(new Date().toISOString().slice(0, 10))
|
||||
const ghostNote = ref('')
|
||||
const ghostImage = ref(null)
|
||||
|
||||
const ghostEvent = computed(() => ({
|
||||
id: '__ghost__',
|
||||
title: ghostTitle.value || 'New Event',
|
||||
date: ghostDate.value,
|
||||
emotion: ghostEmotion.value,
|
||||
customColor: ghostCustomColor.value,
|
||||
gradientPreset: ghostGradientPreset.value,
|
||||
image: ghostImage.value,
|
||||
note: ghostNote.value
|
||||
}))
|
||||
|
||||
const sortedEvents = computed(() => {
|
||||
// Dates are ISO strings (YYYY-MM-DD) — lexicographic order is chronological
|
||||
return [...events.value].sort((a, b) => a.date.localeCompare(b.date))
|
||||
})
|
||||
|
||||
function selectEvent(id) {
|
||||
selectedEventId.value = id
|
||||
}
|
||||
|
||||
function openPanel(eventId = null) {
|
||||
if (eventId) {
|
||||
editingEventId.value = eventId
|
||||
const event = events.value.find((e) => e.id === eventId)
|
||||
if (event) {
|
||||
ghostTitle.value = event.title
|
||||
ghostDate.value = event.date
|
||||
ghostEmotion.value = event.emotion
|
||||
ghostCustomColor.value = event.customColor
|
||||
ghostGradientPreset.value = event.gradientPreset ?? null
|
||||
ghostImage.value = event.image || null
|
||||
ghostNote.value = event.note
|
||||
}
|
||||
} else {
|
||||
editingEventId.value = null
|
||||
ghostTitle.value = ''
|
||||
ghostDate.value = new Date().toISOString().slice(0, 10)
|
||||
ghostEmotion.value = 0
|
||||
ghostCustomColor.value = null
|
||||
ghostGradientPreset.value = null
|
||||
ghostImage.value = null
|
||||
ghostNote.value = ''
|
||||
}
|
||||
panelOpen.value = true
|
||||
}
|
||||
|
||||
// Debounced DB write: the UI updates on every keystroke via the ref,
|
||||
// but IndexedDB only gets the final state after typing pauses
|
||||
const PERSIST_DEBOUNCE_MS = 400
|
||||
let persistTimer = null
|
||||
let pendingWrite = null
|
||||
|
||||
function schedulePersist(event) {
|
||||
pendingWrite = event
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(flushPersist, PERSIST_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function flushPersist() {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
if (pendingWrite) {
|
||||
dbPut(pendingWrite)
|
||||
dbQueueUpdate(pendingWrite)
|
||||
pendingWrite = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelPersist() {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
pendingWrite = null
|
||||
}
|
||||
|
||||
// Auto-save: persist ghost → event in edit mode
|
||||
function persistToEvent() {
|
||||
if (!editingEventId.value) return
|
||||
const idx = events.value.findIndex((e) => e.id === editingEventId.value)
|
||||
if (idx === -1) return
|
||||
const updated = {
|
||||
...events.value[idx],
|
||||
title: ghostTitle.value,
|
||||
date: ghostDate.value,
|
||||
emotion: ghostEmotion.value,
|
||||
customColor: ghostCustomColor.value,
|
||||
gradientPreset: ghostGradientPreset.value,
|
||||
image: ghostImage.value,
|
||||
note: ghostNote.value,
|
||||
syncStatus: 'modified',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
events.value[idx] = updated
|
||||
triggerRef(events)
|
||||
schedulePersist(updated)
|
||||
}
|
||||
|
||||
watch(
|
||||
[ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote],
|
||||
() => { persistToEvent() }
|
||||
)
|
||||
|
||||
function closePanel() {
|
||||
flushPersist()
|
||||
if (!editingEventId.value && ghostTitle.value.trim()) {
|
||||
const newEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
title: ghostTitle.value,
|
||||
date: ghostDate.value,
|
||||
emotion: ghostEmotion.value,
|
||||
customColor: ghostCustomColor.value,
|
||||
gradientPreset: ghostGradientPreset.value,
|
||||
image: ghostImage.value,
|
||||
note: ghostNote.value,
|
||||
syncStatus: 'local',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
events.value.push(newEvent)
|
||||
triggerRef(events)
|
||||
dbPut(newEvent)
|
||||
dbQueueSync(newEvent.id, 'create', { ...newEvent })
|
||||
}
|
||||
panelOpen.value = false
|
||||
editingEventId.value = null
|
||||
selectedEventId.value = null
|
||||
}
|
||||
|
||||
function deleteEvent(id) {
|
||||
if (pendingWrite?.id === id) cancelPersist()
|
||||
events.value = events.value.filter((e) => e.id !== id)
|
||||
dbDelete(id)
|
||||
dbQueueSync(id, 'delete', null)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
// Memoized: emotionToColor is pure and its input space is small
|
||||
// (emotion in 0.05 steps × 10 presets), so the cache stays bounded
|
||||
const glowColorCache = new Map()
|
||||
|
||||
function getGlowColor(event) {
|
||||
if (event.customColor) return event.customColor
|
||||
const preset = event.gradientPreset ?? null
|
||||
const key = `${event.emotion}|${preset}`
|
||||
let color = glowColorCache.get(key)
|
||||
if (!color) {
|
||||
color = emotionToColor(event.emotion, preset)
|
||||
glowColorCache.set(key, color)
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
// Auto-init on store creation
|
||||
init()
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoaded,
|
||||
selectedEventId,
|
||||
panelOpen,
|
||||
editingEventId,
|
||||
ghostEmotion,
|
||||
ghostCustomColor,
|
||||
ghostGradientPreset,
|
||||
ghostTitle,
|
||||
ghostDate,
|
||||
ghostNote,
|
||||
ghostImage,
|
||||
ghostEvent,
|
||||
sortedEvents,
|
||||
selectEvent,
|
||||
openPanel,
|
||||
closePanel,
|
||||
deleteEvent,
|
||||
getGlowColor
|
||||
}
|
||||
})
|
||||
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
|
@ -1,20 +0,0 @@
|
|||
import { defineStore } from '#q-app/wrappers'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
* directly export the Store instantiation;
|
||||
*
|
||||
* The function below can be async too; either use
|
||||
* async/await or return a Promise which resolves
|
||||
* with the Store instance.
|
||||
*/
|
||||
|
||||
export default defineStore((/* { ssrContext } */) => {
|
||||
const pinia = createPinia()
|
||||
|
||||
// You can add Pinia plugins here
|
||||
// pinia.use(SomePiniaPlugin)
|
||||
|
||||
return pinia
|
||||
})
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'thatsme-settings'
|
||||
|
||||
export const ACCENT_COLORS = [
|
||||
{ label: 'Standard', value: 'default', hex: '#9e9e9e' },
|
||||
{ label: 'Blau', value: 'blue', hex: '#2196F3' },
|
||||
{ label: 'Grün', value: 'green', hex: '#4CAF50' },
|
||||
{ label: 'Gelb', value: 'yellow', hex: '#FFC107' },
|
||||
{ label: 'Rosa', value: 'pink', hex: '#E91E63' },
|
||||
{ label: 'Orange', value: 'orange', hex: '#FF9800' }
|
||||
]
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ label: 'Deutsch', value: 'de' },
|
||||
{ label: 'English', value: 'en' }
|
||||
]
|
||||
|
||||
const FLOATING_LINES_DEFAULTS = {
|
||||
// Linien
|
||||
speed: 1.0,
|
||||
lineCount: 10,
|
||||
spread: 0.05,
|
||||
fanSpread: 0.05,
|
||||
lineSharpness: 8.0,
|
||||
waveFrequency: 7.0,
|
||||
bezierCurvature: 0.2,
|
||||
circleRadius: 75,
|
||||
glowSize: 18,
|
||||
glowStrength: 1.5,
|
||||
lineBrightness: 1.0,
|
||||
// Hintergrund
|
||||
bgCenter: '#0a0514',
|
||||
bgEdge: '#000000',
|
||||
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
|
||||
backgroundImage: '',
|
||||
// Labels
|
||||
labelSize: 'small', // 'small' | 'medium' | 'large'
|
||||
labelColor: '#ffffff'
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { FLOATING_LINES_DEFAULTS }
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const stored = loadFromStorage()
|
||||
|
||||
const theme = ref(stored?.theme ?? 'light')
|
||||
const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS })
|
||||
|
||||
// App preferences
|
||||
const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark'
|
||||
const accentColor = ref(stored?.accentColor ?? 'default')
|
||||
const language = ref(stored?.language ?? 'de')
|
||||
|
||||
// Developer / debug
|
||||
const showFps = ref(stored?.showFps ?? false)
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
theme: theme.value,
|
||||
floatingLines: floatingLines.value,
|
||||
appearance: appearance.value,
|
||||
accentColor: accentColor.value,
|
||||
language: language.value,
|
||||
showFps: showFps.value
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
watch([theme, floatingLines, appearance, accentColor, language, showFps], persist, { deep: true })
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function updateFloatingLines(updates) {
|
||||
floatingLines.value = { ...floatingLines.value, ...updates }
|
||||
}
|
||||
|
||||
function resetFloatingLines() {
|
||||
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
floatingLines,
|
||||
appearance,
|
||||
accentColor,
|
||||
language,
|
||||
showFps,
|
||||
toggleTheme,
|
||||
updateFloatingLines,
|
||||
resetFloatingLines
|
||||
}
|
||||
})
|
||||
8
frontend/dev/05-03-2026/tasks.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
Die Positionen von Linie und Glow Icons sowie auch die Anzeigen des Jahres sind nicht mehr positioniert, wenn die Browser Größe verändert wird.
|
||||
|
||||
Gut wäre es, wenn man eine Einstellung hätte, auch den Verlauf der Linien rauszunehmen, Einfarbige Linien prüfen, wie hoch der Aufwand der Entwicklung ist
|
||||
|
||||
|
||||
Einen Horizont einbauen, der genau die Mittellinie markiert, dünne Linie
|
||||
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
# Verbesserungsvorschläge: floating-lines.js
|
||||
|
||||
Analyse von `floating-lines.js` (Dev-Klasse) im Vergleich zur produktiven `FloatingLines.vue`.
|
||||
**Stand: April 2026 — alle wesentlichen Punkte umgesetzt.**
|
||||
|
||||
---
|
||||
|
||||
## Was der Code macht (Kurzübersicht)
|
||||
|
||||
Ein WebGL-Fullscreen-Shader via Three.js mit drei visuellen Schichten:
|
||||
|
||||
- **Top/Bottom**: Einfache Sinus-Wellen mit Rotation (`wave()`)
|
||||
- **Middle**: Bézier-Kurven zwischen Kontrollpunkten mit animierten Fächer-Linien (`waveFocal()`) + Kreise an den Punkten
|
||||
- **Hintergrund**: Radialer Verlauf von Mitte → Rand (oder Horizont-Split bei Modus „Trennung")
|
||||
|
||||
---
|
||||
|
||||
## Fehler / Bugs
|
||||
|
||||
### 1. Totes Uniform `bgColor` ✅ Umgesetzt
|
||||
**Datei:** `floating-lines.js`, Zeile 508
|
||||
Das tote `bgColor`-Uniform wurde entfernt. Der Shader nutzt ausschließlich `bgColorCenter` + `bgColorEdge`.
|
||||
|
||||
```js
|
||||
// Entfernt:
|
||||
bgColor: { value: new Vector3(0, 0, 0) },
|
||||
```
|
||||
|
||||
### 2. Konstruktor-Parameter `middleWavePosition` wird ignoriert ✅ Umgesetzt
|
||||
Der ungenutzte Parameter wurde aus der Konstruktorsignatur entfernt.
|
||||
|
||||
### 3. Kein Konstruktor-Interface für `bgColorCenter` / `bgColorEdge` ✅ Umgesetzt
|
||||
Beide Uniforms sind jetzt als Konstruktor-Parameter verfügbar und werden korrekt initialisiert:
|
||||
|
||||
```js
|
||||
constructor({ ..., bgColorCenter = '#0a0514', bgColorEdge = '#000000', ... })
|
||||
```
|
||||
|
||||
Die Uniforms werden beim Konstruktoraufruf aus den Hex-Strings in `Vector3`-Werte konvertiert.
|
||||
|
||||
---
|
||||
|
||||
## Performance-Problem
|
||||
|
||||
### 4. `bezierClosestT` wird pro Linie neu berechnet (kritisch) ✅ Umgesetzt
|
||||
`bezierClosestT` wird jetzt **einmal pro Segment** berechnet. Die Ergebnisse (`bt`, `bPos`, `bNorm`) werden als Parameter an `waveFocal()` übergeben:
|
||||
|
||||
```glsl
|
||||
// Neue waveFocal()-Signatur (precomputed values):
|
||||
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm)
|
||||
|
||||
// Im Segment-Loop (einmal pro Segment, nicht pro Linie):
|
||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||
vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc));
|
||||
vec2 bNorm = vec2(-bTang.y, bTang.x);
|
||||
// → alle middleLineCount Aufrufe nutzen dieselben Werte
|
||||
```
|
||||
|
||||
Reduktion von O(Segmente × Linien) auf O(Segmente) `bezierClosestT`-Aufrufe pro Pixel.
|
||||
|
||||
---
|
||||
|
||||
## Fehlende Features (in `.vue` vorhanden, in `.js` nicht)
|
||||
|
||||
### 5. `lineBrightness` Uniform fehlt ✅ Umgesetzt
|
||||
`lineBrightness` ist jetzt als Konstruktor-Parameter und Uniform vorhanden. Im Shader:
|
||||
|
||||
```glsl
|
||||
col *= lineBrightness; // vor Background-Composite
|
||||
```
|
||||
|
||||
### 6. Kein Pause bei verstecktem Tab ✅ Umgesetzt
|
||||
Der `requestAnimationFrame`-Loop pausiert jetzt bei `document.hidden`:
|
||||
|
||||
```js
|
||||
this._handleVisibility = () => {
|
||||
if (document.hidden) {
|
||||
cancelAnimationFrame(this.raf)
|
||||
this.raf = 0
|
||||
} else if (!this.raf) {
|
||||
renderLoop()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', this._handleVisibility)
|
||||
// destroy() ruft removeEventListener auf
|
||||
```
|
||||
|
||||
### 7. Kein adaptives DPR ⏭️ Offen / Optional
|
||||
Die Vue-Version misst FPS live und reduziert `devicePixelRatio` bei schlechter Performance (vor allem Mobile). Die Dev-Klasse ist ein Test-Tool und nicht für Mobile ausgelegt — diese Komplexität lohnt sich hier nicht.
|
||||
|
||||
---
|
||||
|
||||
## Code-Qualität
|
||||
|
||||
### 8. Legacy-Code: `background_color()`, `BLACK`, `PINK`, `BLUE` ✅ Umgesetzt
|
||||
Die Shader-Konstanten `BLACK`, `PINK`, `BLUE` und die Funktion `background_color()` wurden entfernt. Der Hintergrund wird immer über `bgColorCenter`/`bgColorEdge` gesteuert.
|
||||
|
||||
### 9. Redundante `enabledWaves.includes()` Checks ✅ Umgesetzt
|
||||
Die doppelten Prüfungen in den Hilfsfunktionen wurden entfernt. Die äußere Prüfung im Aufrufer ist die einzige Guard.
|
||||
|
||||
### 10. Hardcoded `* 0.5` in `getLineColor()` ✅ Umgesetzt
|
||||
Der feste `* 0.5`-Faktor wurde entfernt. `getLineColor()` gibt jetzt die volle Gradient-Farbe zurück. Die Kompensation mit `* 2.5` an den Kreisen wurde auf `* 1.5` angepasst. Die Helligkeit wird über das `lineBrightness`-Uniform gesteuert (→ Punkt 5).
|
||||
|
||||
---
|
||||
|
||||
## Optionale Verbesserungen / Ideen
|
||||
|
||||
### 11. Glättere Kreise bei höherem DPR ⏭️ Offen / Optional
|
||||
Der AA-Radius passt sich durch `iResolution` (physische Pixel bei gesetztem DPR) bereits implizit an. Keine Änderung nötig.
|
||||
|
||||
### 12. `pointSpacingX` + `pointsOffsetX` vs. explizite X-Koordinaten ⏭️ Offen / Optional
|
||||
Die Dev-Klasse behält das Auto-Spacing-Modell für einfache Testzwecke. Die Vue-Komponente nutzt explizite X-Koordinaten für die Lebenszeitlinie. Beide Ansätze sind intentional verschieden.
|
||||
|
||||
### 13. GLSL `precision highp` → `mediump` ✅ Umgesetzt
|
||||
Fragment-Shader nutzt jetzt `precision mediump float` — ausreichend für diese Visualisierung, effizienter auf Mobile/Low-End.
|
||||
|
||||
---
|
||||
|
||||
## Neue Punkte (nachträglich ergänzt)
|
||||
|
||||
### 14. Resize-Bug: Kreise und Linien desynchronisieren sich ✅ Umgesetzt
|
||||
**Betrifft:** `LifeWaveLayout.vue`
|
||||
|
||||
**Ursache:** `layoutResizeObserver` aktualisierte `layoutWidth/Height` sofort, während `TimelineView`s `@view-update` (mit den neuen CSS-Event-Positionen) erst im nächsten Frame kam — führte zu 1-Frame UV-Desync.
|
||||
|
||||
**Fix:** `requestAnimationFrame`-Wrapper im Callback:
|
||||
|
||||
```js
|
||||
layoutResizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!layoutRef.value) return
|
||||
layoutWidth.value = layoutRef.value.clientWidth
|
||||
layoutHeight.value = layoutRef.value.clientHeight
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 15. Feature: Horizont ✅ Umgesetzt (erweitert)
|
||||
Statt einer einzelnen Linie wurden **drei wählbare Horizont-Modi** implementiert:
|
||||
|
||||
| Modus | Wert | Beschreibung |
|
||||
|-------|------|--------------|
|
||||
| Aus | `'off'` | Kein Horizont-Effekt |
|
||||
| Nebel | `'fog'` | Leuchtender Band-Effekt auf Y=0, Farbe aus Gradient |
|
||||
| Trennung | `'split'` | Hintergrund wird vertikal geteilt: `bgColorCenter` oben, `bgColorEdge` unten — mit einstellbarer Blend-Breite |
|
||||
| Glow | `'glow'` | Weiches + hartes Leuchten auf dem Horizont, Farbe aus Gradient |
|
||||
|
||||
**Shader (alle Modi):**
|
||||
```glsl
|
||||
uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
|
||||
uniform float horizonOpacity;
|
||||
uniform float horizonBlend;
|
||||
|
||||
if (horizonMode == 1) {
|
||||
float band = exp(-baseUv.y * baseUv.y * 5.0);
|
||||
vec3 fogColor = getLineColor(0.5, bg) * 2.0;
|
||||
col += fogColor * band * horizonOpacity;
|
||||
} else if (horizonMode == 2) {
|
||||
float blendW = max(horizonBlend * 0.7, 0.001);
|
||||
float t = smoothstep(-blendW, blendW, baseUv.y);
|
||||
bg = mix(bgColorEdge, bgColorCenter, t);
|
||||
} else if (horizonMode == 3) {
|
||||
float d2 = baseUv.y * baseUv.y;
|
||||
float softGlow = exp(-d2 * 10.0);
|
||||
float coreGlow = exp(-d2 * 70.0) * 0.7;
|
||||
vec3 glowColor = getLineColor(0.5, bg) * 3.0;
|
||||
col += glowColor * (softGlow + coreGlow) * horizonOpacity;
|
||||
}
|
||||
```
|
||||
|
||||
**Umgesetzt in:**
|
||||
- `floating-lines.js` — Shader + Konstruktor-Parameter + Uniforms
|
||||
- `FloatingLines.vue` — Shader (mit `gradientMid()` Hilfsfunktion) + Props + Uniforms + Watches
|
||||
- `settings.js` — `horizonMode: 'off'`, `horizonOpacity: 0.5`, `horizonBlend: 0.2`
|
||||
- `LifeWaveSettings.vue` — Segmented Control + bedingte Slider (Deckkraft / Übergang)
|
||||
- `LifeWaveLayout.vue` — Props weitergeleitet
|
||||
- `init-fl.html` — 4 Modus-Buttons + bedingte Slider-Sichtbarkeit
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung Prioritäten
|
||||
|
||||
| # | Typ | Priorität | Aufwand | Status |
|
||||
|---|-----|-----------|---------|--------|
|
||||
| 4 | Performance: `bezierClosestT` Hoisting | **Hoch** | Mittel | ✅ |
|
||||
| 14 | Bug: Resize-Desync (LifeWaveLayout) | **Hoch** | Minimal | ✅ |
|
||||
| 1 | Bug: totes `bgColor` Uniform | Mittel | Minimal | ✅ |
|
||||
| 3 | Bug: `bgColorCenter/Edge` nicht setzbar | Mittel | Klein | ✅ |
|
||||
| 5 | Feature: `lineBrightness` | Mittel | Klein | ✅ |
|
||||
| 10 | Qualität: hardcoded `* 0.5` | Mittel | Klein | ✅ |
|
||||
| 15 | Feature: Horizont (3 Modi) | Mittel | Mittel | ✅ |
|
||||
| 8 | Qualität: Legacy-Code entfernen | Niedrig | Klein | ✅ |
|
||||
| 6 | Feature: Tab-Pause | Niedrig | Klein | ✅ |
|
||||
| 2 | Bug: ignorierter Parameter | Niedrig | Minimal | ✅ |
|
||||
| 9 | Qualität: redundante Checks | Niedrig | Minimal | ✅ |
|
||||
| 13 | Perf: `mediump` Precision | Optional | Minimal | ✅ |
|
||||
| 7 | Feature: adaptives DPR | Optional | Groß | ⏭️ |
|
||||
| 11 | Qualität: AA-Radius explizit | Optional | Minimal | ⏭️ |
|
||||
| 12 | API: explizite X-Koordinaten | Optional | Mittel | ⏭️ |
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
# Entwicklungsplan DB/API-Anbindung
|
||||
|
||||
Version: `0.0.1`
|
||||
|
||||
Status: Konzept und Umsetzungsplan fuer die Anbindung der Quasar-App an das Laravel-Backend mit MySQL.
|
||||
|
||||
## Zielbild
|
||||
|
||||
Die App soll nicht mehr primaer auf browserlokaler Persistenz basieren. Laravel mit MySQL wird die fuehrende Datenquelle fuer User, Settings, Events, Presets und Medien. IndexedDB und `localStorage` duerfen spaeter weiter als Cache und Offline-Schicht genutzt werden, aber nicht als alleinige Wahrheit.
|
||||
|
||||
Das System geht konsequent vom eingeloggten User aus:
|
||||
|
||||
- Jeder User besitzt eigene Settings.
|
||||
- Jeder User besitzt eigene Timeline-Daten.
|
||||
- Jeder User besitzt eigene Events.
|
||||
- Jeder User besitzt eigene Medien, Images, Presets und spaeter Videos, Audios und Texte.
|
||||
- Kein User darf Medien, Events oder Settings eines anderen Users direkt oder indirekt lesen.
|
||||
- Teilen und Veröffentlichen wird spaeter bewusst als eigener Rechte- und Freigabeprozess umgesetzt.
|
||||
|
||||
## Versionsregel
|
||||
|
||||
Die aktuelle App-Version ist `0.0.1`.
|
||||
|
||||
Bei jeder weiteren Entwicklungsrunde muessen folgende Stellen mitgefuehrt werden:
|
||||
|
||||
- `frontend/package.json`
|
||||
- `frontend/src/config/appVersion.js`
|
||||
- `frontend/README.md`
|
||||
- dieses Planungsdokument, sofern sich Architektur, Datenmodell oder API-Verhalten aendern
|
||||
- spaetere Release Notes oder Migrationsdokumente
|
||||
|
||||
Versionen in der `0.x`-Phase duerfen Breaking Changes enthalten. Jede Aenderung am Datenmodell oder an API-Kontrakten muss trotzdem dokumentiert werden.
|
||||
|
||||
## Grundentscheidung
|
||||
|
||||
Wir verwenden direkt MySQL im Laravel-Backend. SQLite wird nicht als Zielsystem genutzt, weil die Anwendung mehrere Benutzer, geschuetzte Medien, Sync und spaetere Sharing-/Invite-Funktionen benoetigt. MySQL passt besser zu dauerhaftem Betrieb, Indizes, Relationen und wachsendem Medien-Metadatenbestand.
|
||||
|
||||
## Sicherheitsprinzip
|
||||
|
||||
Die Datenhoheit liegt beim User. Alle API-Abfragen muessen serverseitig ueber den authentifizierten User eingeschraenkt werden. Das Frontend darf keine fremden IDs als Vertrauensbasis liefern.
|
||||
|
||||
Regeln:
|
||||
|
||||
- Jede userbezogene Tabelle enthaelt `user_id`.
|
||||
- API-Controller filtern immer ueber `$request->user()`.
|
||||
- Policies pruefen Zugriff auf Events, Medien, Presets und Settings.
|
||||
- Storage-Pfade enthalten keine erratbaren oeffentlichen URLs.
|
||||
- Originalmedien werden nicht aus einem public disk ausgeliefert.
|
||||
- Medien werden ueber autorisierte Backend-Routen oder signierte, kurzlebige URLs bereitgestellt.
|
||||
- Thumbnails duerfen ebenfalls nur nach Berechtigungspruefung ausgeliefert werden.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Bestehende Basis:
|
||||
|
||||
- `users` existiert bereits.
|
||||
- `events` existiert bereits und hat bereits `user_id`.
|
||||
|
||||
Geplante Erweiterungen:
|
||||
|
||||
- `user_settings`
|
||||
- `user_id`
|
||||
- `settings` als JSON
|
||||
- `timeline_zoom`
|
||||
- `timeline_scroll_left`
|
||||
- `active_preset_id`
|
||||
- Timestamps
|
||||
|
||||
- `setting_presets`
|
||||
- `user_id`
|
||||
- `name`
|
||||
- `settings` als JSON
|
||||
- `is_public` fuer spaeteres Veröffentlichen von reinen Timeline-/Visual-Presets
|
||||
- Timestamps
|
||||
|
||||
- `events`
|
||||
- weiterhin strikt usergebunden
|
||||
- Erweiterung um Felder aus dem aktuellen Frontend, z. B. Key-Image-Titel, Farbdaten, Notizen und spaetere Event-Metadaten
|
||||
- `client_id` bleibt wichtig fuer Offline-/Sync-Kompatibilitaet
|
||||
|
||||
- `event_media`
|
||||
- `user_id`
|
||||
- `event_id`
|
||||
- `type`: image, video, audio, text
|
||||
- `title`
|
||||
- `original_path`
|
||||
- `thumbnail_path`
|
||||
- `mime_type`
|
||||
- `size`
|
||||
- `width`
|
||||
- `height`
|
||||
- `duration`
|
||||
- `sort_order`
|
||||
- `metadata` als JSON
|
||||
- Timestamps
|
||||
|
||||
- `event_media_variants`
|
||||
- optional spaeter, wenn mehrere Bildgroessen benoetigt werden
|
||||
- z. B. thumbnail, preview, original
|
||||
|
||||
- `timeline_shares`
|
||||
- spaeter
|
||||
- Einladung oder Share-Link fuer eine komplette Timeline
|
||||
- getrennt von Preset-Sharing
|
||||
- eigene Berechtigungen und Ablaufdaten
|
||||
|
||||
## Medienkonzept
|
||||
|
||||
Medien werden nicht als Data-URL in der Datenbank gespeichert. Die Datenbank speichert nur Metadaten und Storage-Pfade.
|
||||
|
||||
Upload-Ablauf:
|
||||
|
||||
1. Frontend laedt Datei an Laravel hoch.
|
||||
2. Backend validiert User, Dateityp und Groesse.
|
||||
3. Backend speichert Originaldatei in einem geschuetzten Storage-Bereich.
|
||||
4. Backend erzeugt Thumbnail oder Preview.
|
||||
5. Backend speichert Metadaten in `event_media`.
|
||||
6. API gibt Media-Objekt mit Thumbnail-Endpunkt zurueck.
|
||||
|
||||
Performance-Regel:
|
||||
|
||||
- Timeline und Event-Panel laden zuerst nur Thumbnails.
|
||||
- Originaldateien werden erst bei grosser Betrachtung oder Download geladen.
|
||||
- Videos und Audios bekommen spaeter eigene Preview-/Poster-Strategien.
|
||||
- Texte werden spaeter als eigener Media-Typ behandelt, nicht als Bilderspezialfall.
|
||||
|
||||
## API-Konzept
|
||||
|
||||
Geplante API-Gruppen unter `/api`:
|
||||
|
||||
- Auth
|
||||
- Login
|
||||
- Logout
|
||||
- aktueller User
|
||||
|
||||
- Settings
|
||||
- `GET /settings`
|
||||
- `PUT /settings`
|
||||
|
||||
- Presets
|
||||
- `GET /setting-presets`
|
||||
- `POST /setting-presets`
|
||||
- `PUT /setting-presets/{preset}`
|
||||
- `DELETE /setting-presets/{preset}`
|
||||
- spaeter: `POST /setting-presets/{preset}/publish`
|
||||
|
||||
- Events
|
||||
- `GET /events`
|
||||
- `POST /events`
|
||||
- `GET /events/{event}`
|
||||
- `PUT /events/{event}`
|
||||
- `DELETE /events/{event}`
|
||||
- `POST /events/sync`
|
||||
|
||||
- Event-Medien
|
||||
- `GET /events/{event}/media`
|
||||
- `POST /events/{event}/media`
|
||||
- `PUT /events/{event}/media/{media}`
|
||||
- `DELETE /events/{event}/media/{media}`
|
||||
- `GET /media/{media}/thumbnail`
|
||||
- `GET /media/{media}/original`
|
||||
|
||||
Alle Endpunkte muessen authentifiziert sein. Jeder Zugriff auf `{event}`, `{media}` oder `{preset}` muss serverseitig gegen `user_id` abgesichert werden.
|
||||
|
||||
## Frontend-Migration
|
||||
|
||||
Aktueller Zustand:
|
||||
|
||||
- Auth liegt in `localStorage`.
|
||||
- Zusaetzlich angelegte lokale Test-User liegen in `localStorage` unter `thatsme-users`.
|
||||
- Settings liegen in `localStorage`.
|
||||
- Events, SyncQueue und Medien liegen in IndexedDB.
|
||||
|
||||
Zwischenstand in Version `0.0.1`:
|
||||
|
||||
- Die urspruenglichen Demo-User `user1` bis `user5` behalten ihre bestehenden lokalen Daten.
|
||||
- Neue lokale User koennen auf der Login-Seite ueber den Plus-Button angelegt werden.
|
||||
- Neue lokale User starten mit leerer Timeline und bekommen keine automatisch generierten Test-Events.
|
||||
- Diese lokale User-Verwaltung ist nur eine Uebergangsloesung bis zur Laravel/MySQL-Auth.
|
||||
|
||||
Zielzustand:
|
||||
|
||||
- Auth laeuft ueber Laravel API.
|
||||
- Settings werden nach Login vom Backend geladen.
|
||||
- Events werden vom Backend geladen und im Store gehalten.
|
||||
- IndexedDB bleibt als Cache und optionale Offline-Queue.
|
||||
- Medien werden als Backend-URLs/IDs verwaltet, nicht mehr als Data-URLs im Event.
|
||||
|
||||
Schrittweise Migration:
|
||||
|
||||
1. API-Client im Frontend anlegen.
|
||||
2. Auth-Store auf Backend-Login vorbereiten.
|
||||
3. Settings-Store mit Backend-Laden/Speichern erweitern.
|
||||
4. Events-Store auf Backend-CRUD umstellen.
|
||||
5. Media-Upload ueber Backend einbauen.
|
||||
6. IndexedDB als Cache neu definieren und nicht mehr als Primaerspeicher behandeln.
|
||||
7. SyncQueue mit Server-Status und Konfliktregeln ueberarbeiten.
|
||||
|
||||
## Konflikt- und Sync-Regeln
|
||||
|
||||
Da spaeter Offline-Faehigkeit weiter sinnvoll ist, bleibt `client_id` wichtig.
|
||||
|
||||
Regeln:
|
||||
|
||||
- Jeder lokal erzeugte Datensatz bekommt eine stabile `client_id`.
|
||||
- Backend antwortet mit Server-ID und `updated_at`.
|
||||
- Bei Updates wird `updated_at` fuer Konflikterkennung genutzt.
|
||||
- Bei Medien wird Upload nicht stillschweigend ueberschrieben.
|
||||
- Konflikte werden spaeter sichtbar gemacht, nicht automatisch fremd geloest.
|
||||
|
||||
## Sharing spaeter
|
||||
|
||||
Preset-Sharing und Timeline-Sharing werden getrennt.
|
||||
|
||||
Preset-Sharing:
|
||||
|
||||
- Nur visuelle Timeline-/LifeWave-Einstellungen.
|
||||
- Keine Events.
|
||||
- Keine Medien.
|
||||
- Keine personenbezogenen Inhalte.
|
||||
|
||||
Timeline-Sharing:
|
||||
|
||||
- Spaeter ueber Einladung, Token oder freigegebene Empfaenger.
|
||||
- Enthält Settings, Events und Medien nur im explizit freigegebenen Umfang.
|
||||
- Zugriff muss widerrufbar sein.
|
||||
- Keine oeffentlichen, erratbaren Medienlinks.
|
||||
|
||||
## Umsetzungsphasen
|
||||
|
||||
Phase 1: Backend-Datenmodell
|
||||
|
||||
- Bestehende `events`-Tabelle mit aktuellem Frontend-Modell abgleichen.
|
||||
- Migrationen fuer `user_settings`, `setting_presets` und `event_media` erstellen.
|
||||
- Models, Relationships, Factories und Policies anlegen.
|
||||
- Tests fuer User-Isolation erstellen.
|
||||
|
||||
Phase 2: API-Basis
|
||||
|
||||
- API Resources und Form Requests erstellen.
|
||||
- Settings- und Preset-Endpunkte implementieren.
|
||||
- Event-Endpunkte usergesichert vervollstaendigen.
|
||||
- Tests fuer CRUD und Fremdzugriff schreiben.
|
||||
|
||||
Phase 3: Media Storage
|
||||
|
||||
- Geschuetzten Storage-Disk konfigurieren.
|
||||
- Upload-Endpunkte implementieren.
|
||||
- Thumbnail-Erzeugung vorbereiten.
|
||||
- Autorisierte Auslieferung fuer Thumbnail und Original bauen.
|
||||
- Tests fuer Zugriffsschutz und Dateitypen schreiben.
|
||||
|
||||
Phase 4: Frontend-Anbindung
|
||||
|
||||
- API-Service im Frontend erstellen.
|
||||
- Auth-Store an Backend anbinden.
|
||||
- Settings-Store vom Backend laden und speichern.
|
||||
- Events-Store CRUD und Sync an Backend anbinden.
|
||||
- EventPanel-Medienupload auf Server umstellen.
|
||||
|
||||
Phase 5: Migration aus Browserdaten
|
||||
|
||||
- Einmalige Importstrategie fuer lokale Demo-Daten definieren.
|
||||
- Pro User lokale Settings und Events optional zum Backend hochladen.
|
||||
- Danach lokale Daten nur noch als Cache nutzen.
|
||||
|
||||
Phase 6: Dokumentation und Stabilisierung
|
||||
|
||||
- API-Kontrakte dokumentieren.
|
||||
- Datenmodell dokumentieren.
|
||||
- Version aktualisieren.
|
||||
- Manuelle QA-Schritte dokumentieren.
|
||||
- Bekannte Grenzen und Folgeaufgaben festhalten.
|
||||
|
||||
## Dokumentationspflicht pro Schritt
|
||||
|
||||
Jede Umsetzung in diesem Bereich muss dokumentiert werden.
|
||||
|
||||
Pflicht je Schritt:
|
||||
|
||||
- Was wurde geaendert?
|
||||
- Welche Version betrifft es?
|
||||
- Welche Tabellen/API-Endpunkte sind betroffen?
|
||||
- Welche Sicherheitsregel wurde umgesetzt oder geprueft?
|
||||
- Welche Tests wurden ausgefuehrt?
|
||||
- Welche offenen Punkte bleiben?
|
||||
|
||||
Empfohlene Struktur fuer neue Dokumente in diesem Ordner:
|
||||
|
||||
- `README.md`: Gesamtplan und aktueller Stand
|
||||
- `data-model.md`: finale Tabellen und Beziehungen
|
||||
- `api-contract.md`: Request-/Response-Strukturen
|
||||
- `migration-log.md`: chronologische Umsetzungsschritte
|
||||
- `security-checklist.md`: Zugriffsschutz und Storage-Regeln
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
- Welche Laravel-Auth-Variante wird fuer die App final verwendet: vorhandenes Passport-Setup oder eine gezielte API-Token-Strategie?
|
||||
- Welche maximale Upload-Groesse gilt pro Datei und pro User?
|
||||
- Wo werden Originaldateien langfristig gespeichert: lokaler Storage, S3-kompatibel oder Synology C2?
|
||||
- Wann wird echte Offline-Synchronisation wieder aktiviert?
|
||||
- Wie detailliert soll spaeteres Timeline-Sharing steuerbar sein?
|
||||
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
# Umsetzungsplan API- und DB-Anbindung
|
||||
|
||||
Version: `0.5.0`
|
||||
|
||||
Status: Hybrid-MVP weitgehend umgesetzt. Remote-User speichern Events, Settings und Bilder ueber die API; der lokale Demo-User bleibt browserlokal und startet leer.
|
||||
|
||||
## Ziel
|
||||
|
||||
Die Quasar-App soll zwei bewusst getrennte Speicherwege unterstuetzen:
|
||||
|
||||
- **Remote/API-User**: `user1@thats-me.app` bis `user6@thats-me.app`, Passwort `pass`. Diese User speichern Events, Timeline-Einstellungen und Bilder serverseitig ueber die Laravel-API.
|
||||
- **Lokaler Demo-User**: `Demo`. Dieser User startet leer und speichert weiterhin ausschliesslich im Browser Storage. Seine Daten werden nicht an die API uebertragen und sind nicht global oder fuer AI/API-Prozesse sichtbar.
|
||||
|
||||
Der erste Umsetzungsstand soll praesentationstaugliche Backend-User ermoeglichen, ohne den lokalen Testmodus zu entfernen.
|
||||
|
||||
## Grundregeln
|
||||
|
||||
- Remote-User werden immer ueber Laravel authentifiziert.
|
||||
- Lokale Demo-Daten bleiben privat im Browser und verlassen das Geraet nicht.
|
||||
- Backend-Abfragen werden immer ueber den authentifizierten User eingeschraenkt.
|
||||
- Bilder von Remote-Usern werden serverseitig gespeichert und usergebunden ausgeliefert.
|
||||
- `client_id` bleibt die stabile Frontend-ID fuer Events und dient spaeter als Sync-Bruecke.
|
||||
- IndexedDB bleibt fuer lokale Demo-Daten und optionalen Cache erhalten, ist fuer Remote-User aber nicht die fuehrende Datenquelle.
|
||||
|
||||
## Aktueller Umsetzungsstand
|
||||
|
||||
Stand: 2026-06-03
|
||||
|
||||
Abgeschlossen:
|
||||
|
||||
- **Phase 1 Backend-Basis**: Laravel-API, Passport, Demo-API-User und geschuetzte Endpunkte sind vorbereitet.
|
||||
- **Phase 2 API-Login**: Quasar kann `user1@thats-me.app` bis `user6@thats-me.app` ueber die API anmelden; Tokens werden im Frontend zentral verwaltet.
|
||||
- **Phase 3 Hybrid-Auth**: Es gibt einen festen lokalen `Demo`-User ohne API-Login. Dieser startet leer und schreibt keine API-SyncQueue-Eintraege.
|
||||
- **Phase 4 Remote-Events**: Remote-Events werden direkt per API erstellt, geladen, aktualisiert und geloescht.
|
||||
- **Phase 5 Remote-Settings**: Timeline-, App- und LifeWave-Settings werden pro Remote-User serverseitig als JSON gespeichert.
|
||||
- **Phase 6 Serverseitige Bilder**: Event-Bilder und Settings-Hintergrundbilder werden fuer Remote-User serverseitig gespeichert, optimiert und geschuetzt ausgeliefert.
|
||||
|
||||
Noch offen:
|
||||
|
||||
- **Phase 7 Praesentationsdaten**: Struktur und Inhalte fuer kontrollierte Beispiel-/Praesentationsdaten pro API-User.
|
||||
- **Phase 8 Abschlussdokumentation**: API-Vertrag, Datenmodell und Security-Checkliste koennen auf Basis der jetzigen Umsetzung finalisiert werden.
|
||||
|
||||
Wichtige technische Entscheidungen:
|
||||
|
||||
- Remote-User nutzen die Laravel-API als fuehrende Datenquelle.
|
||||
- Lokale Demo-Daten bleiben in IndexedDB/localStorage und werden nicht an die API gesendet.
|
||||
- Geschuetzte Bilder werden im Frontend per Bearer Token geladen und als Blob-URL angezeigt.
|
||||
- Event-Bilder haben drei Varianten:
|
||||
- Timeline/Dot-Thumbnail: 320x320px, quadratisch gecroppt.
|
||||
- Event-Panel/Preview: maximal 900px laengste Kante, JPEG-Qualitaet 84.
|
||||
- Original/Download-Basis: maximal 3508px laengste Kante, JPEG-Qualitaet 90.
|
||||
- Settings-Hintergrundbilder werden separat pro User gespeichert: maximal 1600px laengste Kante, JPEG-Qualitaet 86.
|
||||
|
||||
Wichtige Dokumentationsquelle:
|
||||
|
||||
- `migration-log.md` enthaelt die chronologische Umsetzung mit geaenderten Dateien, Tests und QA-Hinweisen.
|
||||
|
||||
## Phase 1: Backend-Basis Pruefen Und Vorbereiten
|
||||
|
||||
Ziel: Die bestehende Laravel-API lauffaehig, migriert und fuer API-User nutzbar machen.
|
||||
|
||||
Status: Abgeschlossen.
|
||||
|
||||
Umgesetzt:
|
||||
|
||||
- `user1` bis `user6` werden idempotent ueber den Database Seeder angelegt.
|
||||
- Alle sechs API-User nutzen das Passwort `pass`.
|
||||
- Ein Passport Personal Access Client fuer den `users` Provider wird bei Bedarf idempotent angelegt.
|
||||
- API-Endpunkte sind ohne Token geschuetzt.
|
||||
- CORS wurde fuer die Quasar-App-Origin konfiguriert.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- Migrationen fuer `users`, Passport-Tabellen und `events` pruefen und ausfuehren.
|
||||
- Sicherstellen, dass Passport fuer API-Token in der lokalen Umgebung funktioniert.
|
||||
- Demo-Seeder fuer `user1` bis `user6` anlegen oder erweitern.
|
||||
- Passwort fuer alle sechs API-User auf `pass` setzen.
|
||||
- Bestehenden Test-User nicht als Praesentationsuser verwenden.
|
||||
- CORS/API-Erreichbarkeit von `app.thats-me.test` nach `api.thats-me.test` pruefen.
|
||||
|
||||
Akzeptanzkriterien:
|
||||
|
||||
- `user1` bis `user6` existieren in der Datenbank.
|
||||
- Jeder User kann serverseitig authentifiziert werden.
|
||||
- `/api/user` liefert mit Token den passenden User.
|
||||
- Ohne Token sind API-Endpunkte geschuetzt.
|
||||
|
||||
Tests/Dokumentation:
|
||||
|
||||
- Feature-Test fuer API-Login.
|
||||
- Feature-Test fuer geschuetzten Zugriff ohne Token.
|
||||
- Dokumentieren, welche Seeder und Migrations ausgefuehrt wurden.
|
||||
|
||||
## Phase 2: API-Login Fuer Quasar
|
||||
|
||||
Ziel: Die Quasar-App kann Remote-User ueber die Laravel-API anmelden.
|
||||
|
||||
Status: Abgeschlossen.
|
||||
|
||||
Umgesetzt:
|
||||
|
||||
- `POST /api/login` validiert Laravel-User und erstellt Passport Bearer Tokens.
|
||||
- `POST /api/logout` widerruft den aktuellen Token.
|
||||
- Frontend nutzt `src/services/apiClient.js` als zentralen API-Client.
|
||||
- Token-Ablage erfolgt in IndexedDB `meta.accessToken`.
|
||||
- Login-Fehler werden auf der Login-Seite verstaendlich angezeigt.
|
||||
- Bei `app.thats-me.test` wird automatisch `https://api.thats-me.test/api` als API-Basis genutzt, sofern `VITE_API_BASE` nicht gesetzt ist.
|
||||
- Bei Live-Hosts unter `thats-me.app` wird automatisch `https://api.thats-me.app/api` als API-Basis genutzt.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- `POST /api/login` implementieren.
|
||||
- `POST /api/logout` implementieren.
|
||||
- Response-Struktur fuer Login festlegen: Token, User-ID, Name, E-Mail, Storage-Modus `remote`.
|
||||
- Frontend-API-Client fuer authentifizierte Requests anlegen.
|
||||
- Token-Ablage im Frontend festlegen und konsistent nutzen.
|
||||
- Fehlertexte fuer falsche Zugangsdaten sauber zur Login-Seite durchreichen.
|
||||
|
||||
Akzeptanzkriterien:
|
||||
|
||||
- Login mit `user1@thats-me.app` und `pass` funktioniert.
|
||||
- Falsches Passwort liefert einen klaren Fehler.
|
||||
- Logout entfernt den Token im Frontend.
|
||||
- Router erkennt Remote-Login als authentifiziert.
|
||||
|
||||
Tests/Dokumentation:
|
||||
|
||||
- Feature-Test fuer erfolgreichen Login.
|
||||
- Feature-Test fuer falsche Zugangsdaten.
|
||||
- Manuelle QA: Login, Reload, Logout.
|
||||
|
||||
## Phase 3: Hybrid-Auth Im Frontend
|
||||
|
||||
Ziel: Frontend unterscheidet klar zwischen lokalem Demo-Modus und Remote-Usern.
|
||||
|
||||
Status: Abgeschlossen und manuell bestaetigt.
|
||||
|
||||
Umgesetzt:
|
||||
|
||||
- Fester lokaler User `Demo` mit `mode: local`.
|
||||
- `Demo` benoetigt kein Passwort.
|
||||
- `Demo` startet leer und seeded keine 500 Beispiel-Events mehr.
|
||||
- Lokale User-Erstellung wurde entfernt.
|
||||
- Remote-User-Liste enthaelt `user1` bis `user6`.
|
||||
- Lokale Demo-Events bleiben in IndexedDB und werden nicht in die SyncQueue geschrieben.
|
||||
- Wechsel zwischen Demo und Remote-Usern ist ueber `userId`/`mode` getrennt.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- Auth-Store um `mode: 'local' | 'remote'` erweitern.
|
||||
- Lokalen User `Demo` definieren.
|
||||
- `Demo` startet leer und nutzt Browser Storage.
|
||||
- Remote-User-Liste fuer `user1` bis `user6` anzeigen.
|
||||
- Lokale User-Erstellung per Plus-Button entfernen oder durch "Lokalen Demo-Modus starten" ersetzen.
|
||||
- Router-Guard auf lokalen und remote Auth-Modus anpassen.
|
||||
|
||||
Akzeptanzkriterien:
|
||||
|
||||
- `Demo` kann ohne API gestartet werden.
|
||||
- `Demo` startet ohne automatisch generierte 500 Events.
|
||||
- `user1` bis `user6` laufen ueber API-Login.
|
||||
- Wechsel zwischen Demo und Remote-Usern vermischt keine Daten.
|
||||
|
||||
Tests/Dokumentation:
|
||||
|
||||
- Frontend-Lint ausfuehren.
|
||||
- Manuelle QA: Demo starten, Event anlegen, Reload, Daten bleiben lokal.
|
||||
- Manuelle QA: Remote-User einloggen, Reload, Token/Auth bleibt konsistent.
|
||||
|
||||
## Phase 4: Events Remote Persistieren
|
||||
|
||||
Ziel: Events von Remote-Usern werden in der Datenbank gespeichert und aus der API geladen.
|
||||
|
||||
Status: Abgeschlossen.
|
||||
|
||||
Umgesetzt:
|
||||
|
||||
- Remote-Events werden beim Login/Reload ueber `GET /api/events` geladen.
|
||||
- Create/Update/Delete fuer Remote-Events laufen direkt ueber `POST`, `PUT` und `DELETE` gegen die Event-API.
|
||||
- Events sind serverseitig ueber den authentifizierten User isoliert.
|
||||
- Frontend haelt Remote-Events nur als lokalen Cache in IndexedDB.
|
||||
- Lokaler Demo-User bleibt browserlokal.
|
||||
|
||||
Hinweis zur Offline-SyncQueue (Stand 2026-07-07):
|
||||
|
||||
- Der Timeline-Prototyp unter `frontend/_src` schreibt jetzt auch `update`-Mutationen in die lokale SyncQueue: debounced beim Bearbeiten, dedupliziert auf maximal einen offenen Update-Eintrag pro Event. Vorher wurden nur `create` und `delete` in die Queue geschrieben, Edits gingen beim Sync verloren.
|
||||
- `POST /api/events/sync` validiert und verarbeitet bereits alle drei Actions `create`, `update` und `delete`; backendseitig ist dafuer keine Anpassung noetig.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- Bestehende `events`-Tabelle mit Frontend-Modell abgleichen.
|
||||
- Fehlende Felder ergaenzen: mindestens `location`, `key_image_title`; Medien zunaechst ueber eigene Medientabelle.
|
||||
- Event-Requests und `EventResource` erweitern.
|
||||
- Vorhandene Event-API gegen User-Isolation testen.
|
||||
- Frontend-Events-Store ueber Storage-Schicht anbinden:
|
||||
- `local`: Dexie wie bisher.
|
||||
- `remote`: Laravel API.
|
||||
- Create, Update, Delete fuer Remote-Events direkt an API senden.
|
||||
- Lokale SyncQueue fuer Remote-User nur als spaetere Retry-/Offline-Schicht behandeln.
|
||||
|
||||
Akzeptanzkriterien:
|
||||
|
||||
- Remote-User sieht nach Reload seine Events wieder.
|
||||
- Andere Remote-User sehen diese Events nicht.
|
||||
- Lokaler `Demo`-User bleibt vollstaendig browserlokal.
|
||||
- Event-Felder aus dem aktuellen Panel bleiben erhalten.
|
||||
|
||||
Tests/Dokumentation:
|
||||
|
||||
- Feature-Tests fuer Event CRUD.
|
||||
- Feature-Test fuer Fremdzugriff zwischen Usern.
|
||||
- Manuelle QA mit `user1` und `user2`.
|
||||
|
||||
## Phase 5: Timeline-Settings Remote Speichern
|
||||
|
||||
Ziel: Timeline- und App-Einstellungen werden fuer Remote-User serverseitig gespeichert.
|
||||
|
||||
Status: Abgeschlossen.
|
||||
|
||||
Umgesetzt:
|
||||
|
||||
- Tabelle `user_settings` speichert ein JSON-Dokument pro User.
|
||||
- `GET /api/settings` und `PUT /api/settings` sind usergebunden hinter `auth:api`.
|
||||
- Gespeichert werden u.a. Appearance, Accent Color, Sprache, LifeWave/Floating-Lines, Emotion-Gradient, Timeline-Zoom, Timeline-Scroll, Presets, aktives Preset und `showFps`.
|
||||
- Remote-Settings werden beim Userwechsel aus der API geladen und lokal nur gecacht.
|
||||
- Lokaler Demo-User nutzt weiterhin `localStorage`.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- Tabelle `user_settings` anlegen.
|
||||
- Model und Beziehung zu `User` ergaenzen.
|
||||
- Endpunkte anlegen:
|
||||
- `GET /api/settings`
|
||||
- `PUT /api/settings`
|
||||
- Settings als JSON speichern, inklusive Timeline-Zoom, Scroll-Position, Floating-Lines, Farben, Sprache, Presets und aktivem Preset.
|
||||
- Settings-Store ueber Storage-Schicht anbinden:
|
||||
- `local`: `localStorage`.
|
||||
- `remote`: API.
|
||||
|
||||
Akzeptanzkriterien:
|
||||
|
||||
- Remote-User behaelt Timeline-Settings nach Reload und Geraetewechsel.
|
||||
- Lokaler `Demo`-User behaelt Settings nur im Browser.
|
||||
- Settings anderer User sind nicht lesbar.
|
||||
|
||||
Tests/Dokumentation:
|
||||
|
||||
- Feature-Test fuer GET/PUT Settings.
|
||||
- Feature-Test fuer User-Isolation.
|
||||
- Manuelle QA: Settings aendern, Reload, anderer User pruefen.
|
||||
|
||||
## Phase 6: Serverseitige Bilder Fuer Remote-User
|
||||
|
||||
Ziel: Bilder von Remote-Events werden serverseitig gespeichert und sicher usergebunden ausgeliefert.
|
||||
|
||||
Status: Abgeschlossen fuer Event-Bilder und Settings-Hintergrundbilder.
|
||||
|
||||
Umgesetzt:
|
||||
|
||||
- Tabelle `event_media` speichert Event-Medien user- und eventgebunden.
|
||||
- Upload, Liste, Anzeige und Loeschen von Event-Medien sind implementiert.
|
||||
- Event-Bilder werden als Thumbnail, Preview und Original-Variante erzeugt.
|
||||
- Key Images liegen in der Collection `key_image`, Galerie-Bilder in `gallery`.
|
||||
- Geschuetzte Event-Bildrouten pruefen `event_media.user_id` gegen den authentifizierten User.
|
||||
- Frontend laedt geschuetzte Bild-URLs mit Bearer Token als Blob.
|
||||
- Settings-Hintergrundbilder werden ueber eigene Endpunkte gespeichert:
|
||||
- `POST /api/settings/media/background`
|
||||
- `GET /api/settings/media/background`
|
||||
- `DELETE /api/settings/media/background`
|
||||
- Lokaler Demo-User speichert Bilder weiterhin lokal bzw. als Browser-Daten.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- Tabelle `event_media` anlegen.
|
||||
- Model, Factory und Beziehung zu `User` und `Event` anlegen.
|
||||
- Geschuetzten Storage-Bereich definieren.
|
||||
- Upload-Endpunkt anlegen: `POST /api/events/{event}/media`.
|
||||
- Medienliste laden: `GET /api/events/{event}/media`.
|
||||
- Medien loeschen: `DELETE /api/events/{event}/media/{media}`.
|
||||
- Auslieferung pruefen:
|
||||
- Thumbnail/Preview, falls vorhanden.
|
||||
- Original nur nach Auth-Pruefung.
|
||||
- Frontend-EventPanel fuer Remote-User auf Upload umstellen.
|
||||
- Lokaler `Demo`-User speichert Bilder weiter lokal.
|
||||
|
||||
Akzeptanzkriterien:
|
||||
|
||||
- Remote-User kann Bild zu Event hochladen.
|
||||
- Bild ist nach Reload wieder sichtbar.
|
||||
- Andere User koennen das Bild nicht abrufen.
|
||||
- Lokaler Demo-Modus funktioniert unveraendert lokal.
|
||||
|
||||
Tests/Dokumentation:
|
||||
|
||||
- Feature-Test fuer Upload.
|
||||
- Feature-Test fuer Fremdzugriff auf Medien.
|
||||
- Feature-Test fuer Loeschen.
|
||||
- Dokumentieren, wo Dateien gespeichert werden und welche Limits gelten.
|
||||
|
||||
## Phase 7: Praesentationsdaten Fuer API-User
|
||||
|
||||
Ziel: Die sechs API-User koennen gezielt mit Beispielinhalten fuer Praesentationen befuellt werden.
|
||||
|
||||
Status: Offen, naechster sinnvoller Umsetzungsschritt.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- Entscheiden, welche Beispiel-Events pro User angelegt werden.
|
||||
- Seeder fuer Praesentationsdaten vorbereiten.
|
||||
- Bilder entweder als Fixture-Dateien oder ueber dokumentierten Upload-Prozess einspielen.
|
||||
- Sicherstellen, dass `Demo` leer bleibt.
|
||||
- AI/API-Zugriff konzeptionell dokumentieren: welche Endpunkte laden Userdaten, welche Auth ist noetig.
|
||||
|
||||
Akzeptanzkriterien:
|
||||
|
||||
- `user1` bis `user6` haben kontrollierte Praesentationsdaten.
|
||||
- Beispielbilder sind serverseitig vorhanden.
|
||||
- Lokaler Demo-Modus bleibt leer und privat.
|
||||
|
||||
Tests/Dokumentation:
|
||||
|
||||
- Seeder-Ausfuehrung dokumentieren.
|
||||
- Manuelle QA pro User.
|
||||
- Liste der Praesentationsdaten pflegen.
|
||||
|
||||
Empfohlene Umsetzung:
|
||||
|
||||
1. Kleine Datendefinition fuer Praesentationsdaten erstellen, z.B. `backend/database/seeders/PresentationDataSeeder.php` plus strukturierte Arrays.
|
||||
2. Pro User entscheiden, ob er leer bleibt oder eine eigene Story bekommt:
|
||||
- `user1`: Standard-Praesentation mit wenigen Events.
|
||||
- `user2`: Bildlastige Galerie-Demo.
|
||||
- `user3`: Settings-/LifeWave-Demo.
|
||||
- `user4` bis `user6`: Reserve oder alternative Stories.
|
||||
3. Seeder idempotent bauen:
|
||||
- Events ueber stabile `client_id` upserten.
|
||||
- Medien nicht doppelt anlegen.
|
||||
- Settings pro User per `updateOrCreate` setzen.
|
||||
4. Beispielbilder entweder als Fixture-Dateien in einem dokumentierten Ordner ablegen oder bewusst ueber die App hochladen.
|
||||
5. Nach dem Seed pro User manuell pruefen:
|
||||
- Login.
|
||||
- Timeline sichtbar.
|
||||
- Event-Panel oeffnet.
|
||||
- Bilder laden als Thumbnail/Preview.
|
||||
- Settings werden korrekt angewendet.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- `Demo` darf durch den Seeder nicht veraendert werden.
|
||||
- Praesentationsdaten sollten wiederholbar eingespielt werden koennen, ohne Dubletten zu erzeugen.
|
||||
- Fuer echte Praesentationen sollten keine zufaelligen `crypto.randomUUID()` IDs verwendet werden, sondern stabile `client_id` Werte aus der Datendefinition.
|
||||
|
||||
## Phase 8: Dokumentation Und Abschluss Pro Umsetzungsschritt
|
||||
|
||||
Ziel: Jeder Schritt bleibt nachvollziehbar und kann spaeter fortgesetzt werden.
|
||||
|
||||
Pflicht pro Umsetzungsschritt:
|
||||
|
||||
- Datum und Version.
|
||||
- Geaenderte Dateien.
|
||||
- Betroffene Tabellen.
|
||||
- Betroffene API-Endpunkte.
|
||||
- Sicherheitsregel: Wie wurde User-Isolation geprueft?
|
||||
- Ausgefuehrte Tests.
|
||||
- Manuelle QA.
|
||||
- Offene Punkte.
|
||||
|
||||
Empfohlene Dokumente in diesem Ordner:
|
||||
|
||||
- `README.md`: Zielbild und Gesamtuebersicht.
|
||||
- `implementation-plan.md`: dieser abarbeitbare Plan.
|
||||
- `api-contract.md`: konkrete Request-/Response-Strukturen.
|
||||
- `data-model.md`: Tabellen, Relationen und Felder.
|
||||
- `migration-log.md`: chronologische Umsetzungsschritte.
|
||||
- `security-checklist.md`: Auth, User-Isolation und Medienzugriff.
|
||||
|
||||
## Aktuelle Offene Entscheidungen
|
||||
|
||||
- Ob die aktuellen Upload-Limits dauerhaft reichen: aktuell 10 MB pro Bild.
|
||||
- Ob die erlaubten Bildtypen `jpg`, `jpeg`, `png`, `webp` fuer den MVP ausreichen.
|
||||
- Ob Praesentationsdaten per Seeder oder ueber ein Admin-/Import-Skript gepflegt werden.
|
||||
- Wie der spaetere AI-Zugriff authentifiziert wird.
|
||||
- Ob fuer groessere Medienverarbeitung spaeter Queue-Jobs statt synchroner Verarbeitung eingesetzt werden.
|
||||
- Ob alte, lokal gespeicherte Remote-Daten aktiv migriert oder bewusst ignoriert werden.
|
||||
|
||||
## Naechster Konkreter Schritt
|
||||
|
||||
Mit Phase 7 starten:
|
||||
|
||||
1. Inhaltliche Rollen fuer `user1` bis `user6` festlegen.
|
||||
2. Datensatzstruktur fuer Praesentationsdaten definieren.
|
||||
3. Idempotenten `PresentationDataSeeder` vorbereiten.
|
||||
4. Optional Fixture-Bilder festlegen.
|
||||
5. Seeder lokal ausfuehren und pro User manuell pruefen.
|
||||
|
||||
## Arbeitsnotizen Fuer Fortsetzung
|
||||
|
||||
Relevante Frontend-Dateien:
|
||||
|
||||
- `frontend/src/stores/auth.js`: Remote-User, Demo-User, Login/Logout, Token-Handling.
|
||||
- `frontend/src/stores/events.js`: lokale Demo-Events und Remote-Event-API-Anbindung.
|
||||
- `frontend/src/stores/settings.js`: lokale und remote Settings-Persistenz.
|
||||
- `frontend/src/components/EventPanel.vue`: Event-Bild-Upload und Galerie.
|
||||
- `frontend/src/components/LifeWaveSettings.vue`: Settings-Hintergrundbild-Upload.
|
||||
- `frontend/src/composables/useImageCache.js`: geschuetzte Bildpfade als Blob laden.
|
||||
|
||||
Relevante Backend-Dateien:
|
||||
|
||||
- `backend/routes/api.php`: API-Routen.
|
||||
- `backend/app/Http/Controllers/Api/AuthController.php`: Login/Logout.
|
||||
- `backend/app/Http/Controllers/Api/EventController.php`: Event-CRUD und Sync.
|
||||
- `backend/app/Http/Controllers/Api/EventMediaController.php`: Event-Medien.
|
||||
- `backend/app/Http/Controllers/Api/SettingsController.php`: Settings JSON.
|
||||
- `backend/app/Http/Controllers/Api/SettingsMediaController.php`: Settings-Hintergrundbilder.
|
||||
- `backend/app/Services/EventMediaImageProcessor.php`: Bildvarianten fuer Event-Medien.
|
||||
- `backend/database/seeders/DatabaseSeeder.php`: API-User und Passport Client.
|
||||
|
||||
Relevante Tests:
|
||||
|
||||
- `backend/tests/Feature/Api/AuthTest.php`
|
||||
- `backend/tests/Feature/Api/EventTest.php`
|
||||
- `backend/tests/Feature/Api/EventMediaTest.php`
|
||||
- `backend/tests/Feature/Api/SettingsTest.php`
|
||||
|
||||
Empfohlene Checks nach Aenderungen:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
vendor/bin/pint --dirty --format agent
|
||||
php artisan test --compact tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php tests/Feature/Api/EventMediaTest.php tests/Feature/Api/SettingsTest.php
|
||||
|
||||
cd ../frontend
|
||||
npm run lint
|
||||
```
|
||||
|
|
@ -1,469 +0,0 @@
|
|||
# Migration Log
|
||||
|
||||
## 2026-06-05 - Live-Deployment API-Login
|
||||
|
||||
Status: Live-Login gegen `https://api.thats-me.app` erfolgreich hergestellt.
|
||||
|
||||
### Problem
|
||||
|
||||
- Das Live-Frontend lief zuerst gegen eine falsche API-Basisadresse.
|
||||
- Korrekte API-Basis ist `https://api.thats-me.app/api`.
|
||||
- Danach blockierte CORS Requests von `https://thats-me.app`.
|
||||
- Nach dem CORS-Fix kam der Request bis Laravel durch, scheiterte aber mit `Invalid key supplied` beim Passport-Token.
|
||||
|
||||
### Ursache
|
||||
|
||||
- `frontend/src/services/apiClient.js` normalisierte bisher nur `https://api.thats-me.test` automatisch auf `/api`.
|
||||
- `backend/config/cors.php` erlaubte nur lokale `.test`- und localhost-Origins.
|
||||
- Auf dem Live-Server fehlten bzw. griffen keine gueltigen Laravel-Passport-Keys fuer `createToken()`.
|
||||
|
||||
### Umsetzung
|
||||
|
||||
- `frontend/src/services/apiClient.js` nutzt fuer Live-Hosts unter `thats-me.app` automatisch `https://api.thats-me.app/api`.
|
||||
- `VITE_API_BASE=https://api.thats-me.app` wird automatisch zu `https://api.thats-me.app/api` normalisiert.
|
||||
- `backend/config/cors.php` erlaubt zusaetzlich:
|
||||
- `https://thats-me.app`
|
||||
- `https://www.thats-me.app`
|
||||
- `https://app.thats-me.app`
|
||||
- Auf dem Live-Server wurden Passport-Keys/Config korrigiert, danach war der Login erfolgreich.
|
||||
|
||||
### Live-Kommandos
|
||||
|
||||
```bash
|
||||
php artisan passport:keys --force
|
||||
php artisan db:seed --class=DatabaseSeeder --no-interaction
|
||||
php artisan optimize:clear
|
||||
php artisan config:cache
|
||||
```
|
||||
|
||||
### Pruefung
|
||||
|
||||
- Browser-Login im Live-Frontend gegen `https://api.thats-me.app/api/login` funktioniert.
|
||||
- CORS-Fehler fuer `https://thats-me.app` ist behoben.
|
||||
- Passport-Fehler `Invalid key supplied` ist behoben.
|
||||
|
||||
## 2026-06-03 - Phase 3 Hybrid-Auth Demo-Modus
|
||||
|
||||
Version: `0.2.1`
|
||||
|
||||
Status: Abgeschlossen fuer festen lokalen Demo-User ohne API-Sync.
|
||||
|
||||
### Geaenderte Dateien
|
||||
|
||||
- `frontend/src/pages/LoginPage.vue`
|
||||
- `frontend/src/stores/auth.js`
|
||||
- `frontend/src/stores/events.js`
|
||||
- `frontend/dev/db-api-connect/migration-log.md`
|
||||
|
||||
### Umsetzung
|
||||
|
||||
- Es gibt jetzt einen festen lokalen User `Demo` mit `mode: local`.
|
||||
- Die Login-Auswahl zeigt `Demo` zusaetzlich zu den sechs API-Usern.
|
||||
- Fuer `Demo` ist kein Passwort noetig; der Login-Button zeigt `Demo lokal starten`.
|
||||
- Lokale User-Erstellung und lokale `userN@thats-me.app`-Custom-User wurden aus der Auth-Liste entfernt.
|
||||
- `Demo` startet leer und seeded keine Beispiel-Events mehr.
|
||||
- Lokale Demo-Events bleiben in IndexedDB, werden aber nicht mehr in die lokale SyncQueue geschrieben.
|
||||
- Remote-User `user1` bis `user6` bleiben unveraendert API-basiert.
|
||||
|
||||
### Sicherheitsregel
|
||||
|
||||
Der Demo-Modus verwendet keinen API-Login und keinen Bearer Token. Events im Demo-Modus bleiben browserlokal in IndexedDB und werden nicht als API-Sync-Auftrag vorgemerkt.
|
||||
|
||||
### Ausgefuehrte Kommandos
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Testergebnis
|
||||
|
||||
- `npm run lint`: erfolgreich.
|
||||
|
||||
### Manuelle QA
|
||||
|
||||
- Noch nicht im Browser durchgeklickt.
|
||||
- Erwartete QA: `Demo` auswaehlen, ohne Passwort starten, leere Timeline sehen, Event anlegen, Reload pruefen. Danach mit `user1@thats-me.app` einloggen und sicherstellen, dass keine Demo-Events sichtbar sind.
|
||||
|
||||
## 2026-06-03 - Nachtrag Settings-Hintergrundbilder
|
||||
|
||||
Status: Abgeschlossen fuer serverseitige Settings-Hintergrundbilder bei Remote-Usern.
|
||||
|
||||
### Geaenderte Dateien
|
||||
|
||||
- `backend/app/Http/Controllers/Api/SettingsMediaController.php`
|
||||
- `backend/app/Http/Requests/StoreSettingsMediaRequest.php`
|
||||
- `backend/routes/api.php`
|
||||
- `backend/tests/Feature/Api/SettingsTest.php`
|
||||
- `frontend/src/components/LifeWaveSettings.vue`
|
||||
- `frontend/src/composables/useImageCache.js`
|
||||
- `frontend/src/layouts/LifeWaveLayout.vue`
|
||||
- `frontend/src/stores/settings.js`
|
||||
- `frontend/dev/db-api-connect/migration-log.md`
|
||||
|
||||
### Betroffene API-Endpunkte
|
||||
|
||||
- `POST /api/settings/media/background`
|
||||
- `GET /api/settings/media/background`
|
||||
- `DELETE /api/settings/media/background`
|
||||
|
||||
### Umsetzung
|
||||
|
||||
- Remote-User laden eigene LifeWave-Hintergrundbilder nicht mehr als Data-URL in das Settings-JSON.
|
||||
- Der Backend-Endpunkt speichert pro User ein optimiertes JPEG unter dem privaten `local` Disk.
|
||||
- Hintergrundbilder werden auf maximal 1600px laengste Kante und JPEG-Qualitaet 86 reduziert.
|
||||
- Die Settings speichern nur den geschuetzten Pfad `/settings/media/background`.
|
||||
- Das Frontend laedt geschuetzte Settings-Bilder wie Event-Medien per Bearer Token als Blob und verwendet die Blob-URL als CSS-Hintergrund.
|
||||
- Der lokale Demo-/Browser-Modus nutzt weiterhin die bestehende Data-URL-Komprimierung im Browser.
|
||||
|
||||
### Sicherheitsregel
|
||||
|
||||
Alle Settings-Medienrouten liegen hinter `auth:api`. Dateien werden usergebunden unter `settings-media/{userId}/background.jpg` gespeichert und nur fuer den authentifizierten User ausgeliefert.
|
||||
|
||||
### Ausgefuehrte Kommandos
|
||||
|
||||
```bash
|
||||
vendor/bin/pint --dirty --format agent
|
||||
php artisan test --compact tests/Feature/Api/SettingsTest.php
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Testergebnis
|
||||
|
||||
- `tests/Feature/Api/SettingsTest.php`: 8 Tests, 32 Assertions, erfolgreich.
|
||||
- `npm run lint`: erfolgreich.
|
||||
|
||||
## 2026-06-03 - Phase 6 Serverseitige Bilder Fuer Remote-User
|
||||
|
||||
Version: `0.4.0`
|
||||
|
||||
Status: Abgeschlossen fuer serverseitige Remote-Eventbilder, Thumbnail-Erzeugung und geschuetzte Auslieferung.
|
||||
|
||||
### Geaenderte Dateien
|
||||
|
||||
- `backend/app/Http/Controllers/Api/EventController.php`
|
||||
- `backend/app/Http/Controllers/Api/EventMediaController.php`
|
||||
- `backend/app/Http/Requests/StoreEventMediaRequest.php`
|
||||
- `backend/app/Http/Resources/EventMediaResource.php`
|
||||
- `backend/app/Http/Resources/EventResource.php`
|
||||
- `backend/app/Models/Event.php`
|
||||
- `backend/app/Models/EventMedia.php`
|
||||
- `backend/app/Services/EventMediaImageProcessor.php`
|
||||
- `backend/config/cors.php`
|
||||
- `backend/database/factories/EventMediaFactory.php`
|
||||
- `backend/database/migrations/2026_06_03_131801_create_event_media_table.php`
|
||||
- `backend/database/migrations/2026_06_03_133403_add_preview_paths_to_event_media_table.php`
|
||||
- `backend/routes/api.php`
|
||||
- `backend/tests/Feature/Api/EventMediaTest.php`
|
||||
- `frontend/src/components/EventPanel.vue`
|
||||
- `frontend/src/composables/useImageCache.js`
|
||||
- `frontend/src/stores/events.js`
|
||||
- `frontend/dev/db-api-connect/migration-log.md`
|
||||
|
||||
### Betroffene Tabellen
|
||||
|
||||
- `event_media`
|
||||
- `events`
|
||||
- `users`
|
||||
|
||||
### Betroffene API-Endpunkte
|
||||
|
||||
- `GET /api/events/{event}/media`
|
||||
- `POST /api/events/{event}/media`
|
||||
- `DELETE /api/events/{event}/media/{media}`
|
||||
- `GET /api/event-media/{media}/thumb`
|
||||
- `GET /api/event-media/{media}/preview`
|
||||
- `GET /api/event-media/{media}/original`
|
||||
- `GET /api/events`
|
||||
|
||||
### Umsetzung
|
||||
|
||||
- Remote-Bilder werden auf dem Laravel-Server unter dem privaten `local` Disk gespeichert.
|
||||
- Uploads werden user- und eventgebunden in `event_media` abgelegt.
|
||||
- Server erzeugt drei JPEG-Varianten: 320px Thumbnail fuer Timeline/Dots, 900px Preview fuer Event-Panel/Galerie und ein Original mit maximal 3508px laengster Kante fuer A4 bei 300 DPI.
|
||||
- Key Images verwenden die Collection `key_image`; weitere Bilder verwenden `gallery`.
|
||||
- Beim Ersetzen eines Key Images wird das alte Key Image inklusive Dateien entfernt.
|
||||
- Geschuetzte Bildauslieferung prueft den authentifizierten User gegen `event_media.user_id`.
|
||||
- Event-API liefert Medien-Metadaten mit `thumbnailUrl` und `originalUrl`.
|
||||
- Frontend-Remote-Uploads gehen direkt an `/api/events/{id}/media`.
|
||||
- Frontend laedt geschuetzte Bildpfade mit Bearer Token als Blob und verwendet lokale Blob-URLs fuer `<img>`.
|
||||
- Timeline/Dots nutzen `thumbnailUrl`, Event-Panel und Galerie nutzen `previewUrl`, ein spaeterer Download kann `originalUrl` verwenden.
|
||||
- Fuer Medien, die vor der Preview-Variante hochgeladen wurden, faellt `/preview` auf das vorhandene Thumbnail zurueck.
|
||||
- Leere Medienpfade werden beim Loeschen ignoriert, damit Altbestaende ohne Preview-Datei keine 500er ausloesen.
|
||||
- CORS ist fuer `https://app.thats-me.test` und lokale App-Dev-Origins explizit konfiguriert.
|
||||
- Lokale Demo-/Browser-Bilder bleiben unveraendert als lokale Data-URLs.
|
||||
|
||||
### Sicherheitsregel
|
||||
|
||||
Alle Medienrouten liegen hinter `auth:api`. Event-Medien werden nur ueber Events des authentifizierten Users angelegt, gelesen oder geloescht. Direkter Zugriff auf fremde `event_media` IDs liefert `404`.
|
||||
|
||||
### Ausgefuehrte Kommandos
|
||||
|
||||
```bash
|
||||
vendor/bin/pint --dirty --format agent
|
||||
php artisan optimize:clear
|
||||
php artisan test --compact tests/Feature/Api/EventMediaTest.php
|
||||
php artisan migrate --no-interaction
|
||||
npm run lint
|
||||
php artisan test --compact tests/Feature/Api/EventMediaTest.php tests/Feature/Api/EventTest.php tests/Feature/Api/AuthTest.php tests/Feature/Api/SettingsTest.php
|
||||
php artisan migrate:status
|
||||
```
|
||||
|
||||
### Testergebnis
|
||||
|
||||
- `tests/Feature/Api/EventMediaTest.php`: 9 Tests, erfolgreich.
|
||||
- API-Feature-Tests: 33 Tests, 133 Assertions, erfolgreich.
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `php artisan migrate:status`: `2026_06_03_131801_create_event_media_table` ist in Batch 2 ausgefuehrt.
|
||||
|
||||
### Manuelle QA
|
||||
|
||||
- Noch nicht im Browser vollstaendig durchgeklickt.
|
||||
- Erwartete QA: Remote-User einloggen, Key Image hochladen, Galerie-Bild hochladen, Seite neu laden, zweiten Browser mit gleichem User oeffnen, Thumbnail und Original in Galerie pruefen, Bild loeschen und DB/Storage pruefen.
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- Thumbnail-Groesse und JPEG-Qualitaet sind aktuell feste MVP-Werte.
|
||||
- Originalbilder werden aktuell mit JPEG-Qualitaet 90 gespeichert.
|
||||
- Previewbilder werden aktuell mit maximal 900px laengster Kante und JPEG-Qualitaet 84 gespeichert.
|
||||
- Spaeter koennen Queue-Jobs fuer groessere Medienverarbeitung und weitere Varianten ergaenzt werden.
|
||||
- Upload-Limit ist aktuell 10 MB pro Bild.
|
||||
|
||||
## 2026-06-03 - Phase 5 Timeline-Settings Remote Speichern
|
||||
|
||||
Version: `0.3.0`
|
||||
|
||||
Status: Abgeschlossen fuer Remote-Settings von API-Usern.
|
||||
|
||||
### Geaenderte Dateien
|
||||
|
||||
- `backend/app/Http/Controllers/Api/SettingsController.php`
|
||||
- `backend/app/Http/Requests/UpdateSettingsRequest.php`
|
||||
- `backend/app/Models/User.php`
|
||||
- `backend/app/Models/UserSetting.php`
|
||||
- `backend/database/factories/UserSettingFactory.php`
|
||||
- `backend/database/migrations/2026_06_03_130123_create_user_settings_table.php`
|
||||
- `backend/routes/api.php`
|
||||
- `backend/tests/Feature/Api/SettingsTest.php`
|
||||
- `frontend/src/stores/settings.js`
|
||||
- `frontend/dev/db-api-connect/migration-log.md`
|
||||
|
||||
### Betroffene Tabellen
|
||||
|
||||
- `user_settings`
|
||||
- `users`
|
||||
|
||||
### Betroffene API-Endpunkte
|
||||
|
||||
- `GET /api/settings`
|
||||
- `PUT /api/settings`
|
||||
|
||||
### Umsetzung
|
||||
|
||||
- Pro User wird ein JSON-Dokument in `user_settings.settings` gespeichert.
|
||||
- `GET /api/settings` liefert die Settings des authentifizierten Users oder `null`.
|
||||
- `PUT /api/settings` erstellt oder aktualisiert die Settings des authentifizierten Users.
|
||||
- Frontend-Remote-User laden Settings beim Userwechsel/Login aus der API.
|
||||
- Frontend-Remote-User speichern Settings per API und halten `localStorage` nur als lokalen Cache.
|
||||
- Lokale Demo-/Browser-User bleiben auf `localStorage`.
|
||||
- Gespeichert werden unter anderem Appearance, Accent Color, Sprache, Floating-Lines, Emotion-Gradient, Timeline-Zoom, Timeline-Scroll, Presets, aktives Preset und `showFps`.
|
||||
|
||||
### Sicherheitsregel
|
||||
|
||||
Die Settings-Routen liegen hinter `auth:api`. Der Controller greift ausschliesslich ueber `$request->user()->settings()` auf Daten zu, dadurch sind Settings anderer User weder lesbar noch ueberschreibbar.
|
||||
|
||||
### Ausgefuehrte Kommandos
|
||||
|
||||
```bash
|
||||
vendor/bin/pint --dirty --format agent
|
||||
php artisan optimize:clear
|
||||
php artisan test --compact tests/Feature/Api/SettingsTest.php
|
||||
php artisan migrate --no-interaction
|
||||
php artisan migrate:status
|
||||
npm run lint
|
||||
php artisan test --compact tests/Feature/Api/SettingsTest.php tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php
|
||||
```
|
||||
|
||||
### Testergebnis
|
||||
|
||||
- `tests/Feature/Api/SettingsTest.php`: 5 Tests, 19 Assertions, erfolgreich.
|
||||
- `tests/Feature/Api/SettingsTest.php tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php`: 24 Tests, 97 Assertions, erfolgreich.
|
||||
- `npm run lint`: erfolgreich.
|
||||
- `php artisan migrate:status`: `2026_06_03_130123_create_user_settings_table` ist ausgefuehrt.
|
||||
|
||||
### Manuelle QA
|
||||
|
||||
- Noch nicht im Browser gegen zwei Browserprofile durchgeklickt.
|
||||
- Erwartete QA: mit API-User einloggen, Appearance/Accent/Floating-Lines/Timeline-Zoom aendern, in zweitem Browser mit gleichem User einloggen und Settings pruefen.
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- Serverseitige Bilder fuer Remote-User folgen in Phase 6.
|
||||
- Der lokale `Demo`-User als sauber getrennter Browser-only Modus bleibt fuer Phase 3 noch offen.
|
||||
|
||||
## 2026-06-03 - Phase 2 API-Login Fuer Quasar
|
||||
|
||||
Version: `0.2.0`
|
||||
|
||||
Status: Abgeschlossen fuer Backend-Login, Logout, Token-Ablage im Frontend und Remote-User-Auswahl im Login.
|
||||
|
||||
### Geaenderte Dateien
|
||||
|
||||
- `backend/app/Http/Controllers/Api/AuthController.php`
|
||||
- `backend/app/Http/Requests/LoginRequest.php`
|
||||
- `backend/database/seeders/DatabaseSeeder.php`
|
||||
- `backend/routes/api.php`
|
||||
- `backend/tests/Feature/Api/AuthTest.php`
|
||||
- `frontend/src/pages/LoginPage.vue`
|
||||
- `frontend/package.json`
|
||||
- `frontend/src/router/index.js`
|
||||
- `frontend/src/services/apiClient.js`
|
||||
- `frontend/src/services/syncService.js`
|
||||
- `frontend/src/stores/auth.js`
|
||||
- `frontend/dev/db-api-connect/migration-log.md`
|
||||
|
||||
### Betroffene Tabellen
|
||||
|
||||
- `users`
|
||||
- `oauth_clients`
|
||||
- `oauth_access_tokens`
|
||||
|
||||
### Betroffene API-Endpunkte
|
||||
|
||||
- `POST /api/login`
|
||||
- `POST /api/logout`
|
||||
- `GET /api/user`
|
||||
|
||||
### Umsetzung
|
||||
|
||||
- `POST /api/login` validiert E-Mail und Passwort gegen Laravel-User.
|
||||
- Erfolgreicher Login erzeugt einen Passport Personal Access Token fuer die Quasar-App.
|
||||
- Login-Response liefert Token, Token-Typ und Userdaten mit `mode: remote`.
|
||||
- `POST /api/logout` widerruft den aktuellen Token.
|
||||
- `DatabaseSeeder` legt zusaetzlich einen Passport Personal-Access-Client fuer den `users` Provider an, falls noch keiner existiert.
|
||||
- Frontend hat mit `src/services/apiClient.js` einen zentralen API-Client inklusive Token-Ablage in IndexedDB `meta.accessToken`.
|
||||
- `syncService` nutzt denselben API-Client und vermeidet dadurch doppelte Token-Logik.
|
||||
- `auth` Store fuehrt `user1@thats-me.app` bis `user6@thats-me.app` als Remote-User.
|
||||
- Login-Seite nutzt async API-Login, zeigt die sechs API-Accounts und entfernt den sichtbaren User-Anlegen-Button.
|
||||
- Router erkennt gespeicherte Remote-Auth nach Reload ueber die persistierte Auth-Struktur.
|
||||
- Frontend-Lint-Script nutzt den funktionierenden `src/**/*.{js,cjs,mjs,vue}` Glob.
|
||||
|
||||
### Sicherheitsregel
|
||||
|
||||
Falsche Zugangsdaten liefern Validierungsfehler ohne Token. Geschuetzte Endpunkte bleiben hinter `auth:api`; Logout widerruft den Bearer Token serverseitig.
|
||||
|
||||
### Ausgefuehrte Kommandos
|
||||
|
||||
```bash
|
||||
vendor/bin/pint --dirty --format agent
|
||||
php artisan test --compact tests/Feature/Api/AuthTest.php
|
||||
php artisan test --compact tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php
|
||||
php artisan db:seed --class=DatabaseSeeder --no-interaction
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Testergebnis
|
||||
|
||||
- `tests/Feature/Api/AuthTest.php`: 7 Tests, 43 Assertions, erfolgreich.
|
||||
- `tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php`: 19 Tests, 78 Assertions, erfolgreich.
|
||||
- `npm run lint`: erfolgreich.
|
||||
|
||||
### Manuelle QA
|
||||
|
||||
- Browser-Login wurde noch nicht manuell durchgeklickt.
|
||||
- Der Backend-Seed wurde gegen die lokale Datenbank erneut ausgefuehrt.
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- Phase 3 fuehrt den lokalen `Demo`-User sauber als getrennten Browser-only Modus ein.
|
||||
- Phase 4 stellt die Event-Persistenz fuer Remote-User von lokaler Dexie-Fuehrung auf API-Fuehrung um.
|
||||
|
||||
### Nachtrag 2026-06-03
|
||||
|
||||
- `frontend/src/services/apiClient.js` nutzt bei `app.thats-me.test` ohne explizites `VITE_API_BASE` automatisch `https://api.thats-me.test/api`.
|
||||
- `frontend/src/services/apiClient.js` nutzt bei Live-Hosts unter `thats-me.app` automatisch `https://api.thats-me.app/api`.
|
||||
- Damit laufen Login-Requests nicht mehr versehentlich gegen den Quasar-Devserver-Pfad `/api`.
|
||||
- `VITE_API_BASE=https://api.thats-me.test` und `VITE_API_BASE=https://api.thats-me.app` werden automatisch um `/api` ergaenzt.
|
||||
- `backend/config/cors.php` erlaubt fuer Live zusaetzlich `https://thats-me.app`, `https://www.thats-me.app` und `https://app.thats-me.app`.
|
||||
- Im Dev-Modus wird die vollstaendige API-Request-URL mit `API request: ...` in die Browser-Konsole geschrieben.
|
||||
- `frontend/src/stores/auth.js` loggt Remote-Login-Fehler zusaetzlich in der Browser-Konsole.
|
||||
- Geprueft mit `npm run lint` und `php artisan test --compact tests/Feature/Api/AuthTest.php`.
|
||||
|
||||
### Nachtrag Remote-Events 2026-06-03
|
||||
|
||||
- `frontend/src/stores/events.js` laedt Events fuer Remote-User beim Login direkt ueber `GET /api/events`.
|
||||
- Remote-Create, Remote-Update und Remote-Delete laufen fuer API-User jetzt direkt ueber `POST /api/events`, `PUT /api/events/{id}` und `DELETE /api/events/{id}`.
|
||||
- IndexedDB bleibt fuer Remote-User nur Cache; fuehrend ist die API.
|
||||
- Neue Browser laden vorhandene Remote-Events direkt aus der Datenbank.
|
||||
- Geprueft mit `npm run lint` und `php artisan test --compact tests/Feature/Api/EventTest.php tests/Feature/Api/AuthTest.php`.
|
||||
|
||||
## 2026-06-03 - Phase 1 Backend-Basis
|
||||
|
||||
Version: `0.1.0`
|
||||
|
||||
Status: Abgeschlossen fuer Backend-Basis, Demo-API-User und geschuetzte API-Pruefungen.
|
||||
|
||||
### Geaenderte Dateien
|
||||
|
||||
- `backend/database/seeders/DatabaseSeeder.php`
|
||||
- `backend/tests/Feature/Api/AuthTest.php`
|
||||
- `frontend/dev/db-api-connect/migration-log.md`
|
||||
|
||||
### Betroffene Tabellen
|
||||
|
||||
- `users`
|
||||
- `events`
|
||||
- `oauth_auth_codes`
|
||||
- `oauth_access_tokens`
|
||||
- `oauth_refresh_tokens`
|
||||
- `oauth_clients`
|
||||
- `oauth_device_codes`
|
||||
- `cache`
|
||||
- `jobs`
|
||||
- `sessions`
|
||||
- `password_reset_tokens`
|
||||
|
||||
### Betroffene API-Endpunkte
|
||||
|
||||
- `GET /api/user`
|
||||
- `GET /api/events`
|
||||
|
||||
### Umsetzung
|
||||
|
||||
- Lokale MySQL-Datenbank `thats-me` wurde angelegt, falls sie noch nicht vorhanden war.
|
||||
- Alle vorhandenen Backend-Migrationen wurden ausgefuehrt.
|
||||
- `DatabaseSeeder` wurde idempotent gemacht.
|
||||
- `user1@thats-me.app` bis `user6@thats-me.app` werden mit Passwort `pass` angelegt oder aktualisiert.
|
||||
- Der bestehende `test@example.com` bleibt als separater Test-User erhalten und wird nicht als Praesentationsuser verwendet.
|
||||
- Feature-Tests pruefen geschuetzten API-Zugriff ohne Token, Rueckgabe des authentifizierten Users und die Seeder-User inklusive Passwort.
|
||||
|
||||
### Sicherheitsregel
|
||||
|
||||
Die geschuetzten API-Routen liegen weiterhin hinter `auth:api`. Ohne Token liefern `GET /api/user` und `GET /api/events` einen unauthorisierten Status. Mit Passport-Testauthentifizierung liefert `GET /api/user` nur den authentifizierten User.
|
||||
|
||||
### Ausgefuehrte Kommandos
|
||||
|
||||
```bash
|
||||
vendor/bin/pint --dirty --format agent
|
||||
php artisan test --compact tests/Feature/Api/AuthTest.php
|
||||
php artisan test --compact tests/Feature/Api
|
||||
php artisan migrate --no-interaction
|
||||
php artisan db:seed --class=DatabaseSeeder --no-interaction
|
||||
php artisan migrate:status
|
||||
```
|
||||
|
||||
### Testergebnis
|
||||
|
||||
- `tests/Feature/Api/AuthTest.php`: 4 Tests, 24 Assertions, erfolgreich.
|
||||
- `tests/Feature/Api`: 16 Tests, 59 Assertions, erfolgreich.
|
||||
- `php artisan migrate:status`: alle vorhandenen Migrationen in Batch 1 ausgefuehrt.
|
||||
|
||||
### Manuelle QA
|
||||
|
||||
- Nicht im Browser geprueft, da Phase 1 noch keine Frontend-Anbindung enthaelt.
|
||||
- CORS/API-Erreichbarkeit von `app.thats-me.test` nach `api.thats-me.test` bleibt fuer Phase 2/3 offen, sobald die Login-Route und der Frontend-Client angebunden werden.
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- Echte Login-/Logout-Endpunkte fuer Quasar folgen in Phase 2.
|
||||
- Token-Erzeugung fuer das Frontend wird in Phase 2 finalisiert.
|
||||
- Frontend-Demo-Modus und Remote-User-Auswahl folgen in Phase 3.
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ShaderMaterial,
|
||||
Vector3,
|
||||
Vector2,
|
||||
Clock,
|
||||
} from 'three'
|
||||
|
||||
const vertexShader = `
|
||||
|
|
@ -18,7 +19,7 @@ void main() {
|
|||
`
|
||||
|
||||
const fragmentShader = `
|
||||
precision mediump float;
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
|
|
@ -57,24 +58,34 @@ uniform float bendRadius;
|
|||
uniform float bendStrength;
|
||||
uniform float bendInfluence;
|
||||
|
||||
uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
|
||||
uniform float horizonOpacity; // Nebel + Glow: Helligkeit/Dichte
|
||||
uniform float horizonBlend; // Trennung: 0=scharf, 1=weicher Übergang
|
||||
|
||||
uniform bool parallax;
|
||||
uniform float parallaxStrength;
|
||||
uniform vec2 parallaxOffset;
|
||||
|
||||
uniform float lineBrightness;
|
||||
uniform vec3 lineGradient[8];
|
||||
uniform int lineGradientCount;
|
||||
uniform vec3 bgColorCenter;
|
||||
uniform vec3 bgColorEdge;
|
||||
|
||||
const vec3 BLACK = vec3(0.0);
|
||||
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
||||
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
||||
|
||||
mat2 rotate(float r) {
|
||||
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
||||
}
|
||||
|
||||
vec3 background_color(vec2 uv) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
||||
float m = uv.y - y;
|
||||
|
||||
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
||||
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
||||
return col * 0.5;
|
||||
}
|
||||
|
||||
vec3 getLineColor(float t, vec3 baseColor) {
|
||||
if (lineGradientCount <= 0) {
|
||||
return baseColor;
|
||||
|
|
@ -97,7 +108,7 @@ vec3 getLineColor(float t, vec3 baseColor) {
|
|||
gradientColor = mix(c1, c2, f);
|
||||
}
|
||||
|
||||
return gradientColor;
|
||||
return gradientColor * 0.5;
|
||||
}
|
||||
|
||||
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) {
|
||||
|
|
@ -152,9 +163,25 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
|||
return t;
|
||||
}
|
||||
|
||||
// Accepts precomputed bezier values (bt, bPos, bNorm) — computed once per segment
|
||||
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) {
|
||||
float s = dot(uv - bPos, bNorm);
|
||||
float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) {
|
||||
// Bézier-Kontrollpunkt: Mittelpunkt + senkrechter Versatz
|
||||
vec2 seg = ep - sp;
|
||||
float segLen = length(seg);
|
||||
if (segLen < 0.001) return 0.0;
|
||||
vec2 segDir = seg / segLen;
|
||||
vec2 segPerp = vec2(-segDir.y, segDir.x);
|
||||
vec2 pc = (sp + ep) * 0.5 + segPerp * segLen * bezierCurvature;
|
||||
|
||||
float t = bezierClosestT(uv, sp, pc, ep);
|
||||
float mt = 1.0 - t;
|
||||
|
||||
// Position und Tangente auf der Kurve
|
||||
vec2 curvePos = mt*mt*sp + 2.0*mt*t*pc + t*t*ep;
|
||||
vec2 tang = normalize(2.0*mt*(pc - sp) + 2.0*t*(ep - pc));
|
||||
vec2 norm = vec2(-tang.y, tang.x);
|
||||
|
||||
// Senkrechter Abstand von der Kurve
|
||||
float s = dot(uv - curvePos, norm);
|
||||
|
||||
float time = iTime * animationSpeed;
|
||||
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
|
||||
|
|
@ -200,7 +227,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec3 b = bgColorCenter;
|
||||
vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv);
|
||||
|
||||
vec2 mouseUv = vec2(0.0);
|
||||
if (interactive) {
|
||||
|
|
@ -242,34 +269,33 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|||
vec2 sp = vec2(x0, pointY[s]);
|
||||
vec2 ep = vec2(x1, pointY[s + 1]);
|
||||
|
||||
// Segment-Geometrie (einmalig berechnet, von Gradient + Bézier genutzt)
|
||||
vec2 seg = ep - sp;
|
||||
float segL = length(seg);
|
||||
vec2 segDir = segL > 0.001 ? seg / segL : vec2(1.0, 0.0);
|
||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||
|
||||
// Gradient
|
||||
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
|
||||
// Gradient: globaler t-Bereich [s, s+1] / (numPoints-1)
|
||||
vec2 pd = ep - sp;
|
||||
float pl = length(pd);
|
||||
vec2 pa = pl > 0.001 ? pd / pl : vec2(1.0, 0.0);
|
||||
float t_seg = clamp(dot(baseUv - sp, pa) / pl, 0.0, 1.0);
|
||||
float t_global = (float(s) + t_seg) * tScale;
|
||||
vec3 lineCol = getLineColor(t_global, b);
|
||||
vec3 lineCol = getLineColor(t_global, b);
|
||||
|
||||
// Bézier einmal pro Segment — geteilt von Nebel + allen Linien
|
||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||
float bmt = 1.0 - bt;
|
||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||
vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc));
|
||||
vec2 bNorm = vec2(-bTang.y, bTang.x);
|
||||
// Bézier-Kontrollpunkt für Nebel (gleiche Logik wie in waveFocal)
|
||||
vec2 segD = ep - sp;
|
||||
float segL = length(segD);
|
||||
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||
|
||||
// Weicher Nebel entlang der Kurve
|
||||
float bDist = length(baseUv - bPos);
|
||||
// Weicher Nebel entlang der Bézier-Kurve → füllt dunkle Winkel organisch
|
||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||
float bmt = 1.0 - bt;
|
||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||
float bDist = length(baseUv - bPos);
|
||||
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
||||
float fogEnv = sin(bt * 3.14159265359);
|
||||
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
||||
col += lineCol * segFog;
|
||||
|
||||
for (int i = 0; i < middleLineCount; ++i) {
|
||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), sp, ep);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,7 +304,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|||
if (p >= numPoints) break;
|
||||
float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX;
|
||||
float t_pt = numPoints > 1 ? float(p) * tScale : 0.0;
|
||||
vec3 circCol = getLineColor(t_pt, b) * 1.5;
|
||||
vec3 circCol = getLineColor(t_pt, b) * 2.5;
|
||||
col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol);
|
||||
}
|
||||
}
|
||||
|
|
@ -302,33 +328,9 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|||
}
|
||||
}
|
||||
|
||||
col *= lineBrightness;
|
||||
|
||||
// Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand)
|
||||
float dist = length(baseUv) / 1.8;
|
||||
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
||||
|
||||
if (horizonMode == 1) {
|
||||
// Nebel: breites weiches Gaussband in Gradient-Mittelfarbe
|
||||
float band = exp(-baseUv.y * baseUv.y * 5.0);
|
||||
vec3 fogColor = getLineColor(0.5, bg) * 2.0;
|
||||
col += fogColor * band * horizonOpacity;
|
||||
} else if (horizonMode == 2) {
|
||||
// Farbtrennung: vertikaler Split an Y=0
|
||||
// bgColorCenter → oben (positives UV-Y), bgColorEdge → unten
|
||||
// horizonBlend: 0=harter Schnitt, 1=sehr weicher Übergang
|
||||
float blendW = max(horizonBlend * 0.7, 0.001);
|
||||
float t = smoothstep(-blendW, blendW, baseUv.y);
|
||||
bg = mix(bgColorEdge, bgColorCenter, t);
|
||||
} else if (horizonMode == 3) {
|
||||
// Glow: konzentriertes Leuchten in Gradient-Mittelfarbe
|
||||
float d2 = baseUv.y * baseUv.y;
|
||||
float softGlow = exp(-d2 * 10.0);
|
||||
float coreGlow = exp(-d2 * 70.0) * 0.7;
|
||||
vec3 glowColor = getLineColor(0.5, bg) * 3.0;
|
||||
col += glowColor * (softGlow + coreGlow) * horizonOpacity;
|
||||
}
|
||||
|
||||
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +376,7 @@ export default class FloatingLines {
|
|||
lineCount = [6],
|
||||
lineDistance = [5],
|
||||
topWavePosition,
|
||||
middleWavePosition,
|
||||
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
|
||||
numPoints = 4,
|
||||
pointSpacingX = 0.8,
|
||||
|
|
@ -386,7 +389,6 @@ export default class FloatingLines {
|
|||
bezierCurvature = 0.3,
|
||||
circleRadiusPx = 50,
|
||||
animationSpeed = 1,
|
||||
lineBrightness = 1.0,
|
||||
interactive = true,
|
||||
bendRadius = 5.0,
|
||||
bendStrength = -0.5,
|
||||
|
|
@ -395,11 +397,6 @@ export default class FloatingLines {
|
|||
parallaxStrength = 0.2,
|
||||
circleGlowSize = 18.0,
|
||||
circleGlowStrength = 1.5,
|
||||
horizonMode = 'off',
|
||||
horizonOpacity = 0.5,
|
||||
horizonBlend = 0.2,
|
||||
bgColorCenter = '#0a0514',
|
||||
bgColorEdge = '#000000',
|
||||
mixBlendMode = 'screen',
|
||||
} = {},
|
||||
) {
|
||||
|
|
@ -430,12 +427,14 @@ export default class FloatingLines {
|
|||
return lineDistance[index] ?? 0.1
|
||||
}
|
||||
|
||||
const topLineCount = getLineCount('top')
|
||||
const middleLineCount = getLineCount('middle')
|
||||
const bottomLineCount = getLineCount('bottom')
|
||||
const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0
|
||||
const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0
|
||||
const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0
|
||||
|
||||
const topLineDistance = getLineDistance('top') * 0.01
|
||||
const bottomLineDistance = getLineDistance('bottom') * 0.01
|
||||
const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01
|
||||
const bottomLineDistance = enabledWaves.includes('bottom')
|
||||
? getLineDistance('bottom') * 0.01
|
||||
: 0.01
|
||||
|
||||
this.scene = new Scene()
|
||||
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||
|
|
@ -452,7 +451,6 @@ export default class FloatingLines {
|
|||
iTime: { value: 0 },
|
||||
iResolution: { value: new Vector3(1, 1, 1) },
|
||||
animationSpeed: { value: animationSpeed },
|
||||
lineBrightness: { value: lineBrightness },
|
||||
|
||||
enableTop: { value: enabledWaves.includes('top') },
|
||||
enableMiddle: { value: enabledWaves.includes('middle') },
|
||||
|
|
@ -499,10 +497,6 @@ export default class FloatingLines {
|
|||
bendStrength: { value: bendStrength },
|
||||
bendInfluence: { value: 0 },
|
||||
|
||||
horizonMode: { value: { off: 0, fog: 1, split: 2, glow: 3 }[horizonMode] ?? 0 },
|
||||
horizonOpacity: { value: horizonOpacity },
|
||||
horizonBlend: { value: horizonBlend },
|
||||
|
||||
parallax: { value: parallax },
|
||||
parallaxStrength: { value: parallaxStrength },
|
||||
parallaxOffset: { value: new Vector2(0, 0) },
|
||||
|
|
@ -511,8 +505,7 @@ export default class FloatingLines {
|
|||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
|
||||
},
|
||||
lineGradientCount: { value: 0 },
|
||||
bgColorCenter: { value: new Vector3(0, 0, 0) },
|
||||
bgColorEdge: { value: new Vector3(0, 0, 0) },
|
||||
bgColor: { value: new Vector3(0, 0, 0) },
|
||||
}
|
||||
|
||||
if (linesGradient && linesGradient.length > 0) {
|
||||
|
|
@ -524,11 +517,6 @@ export default class FloatingLines {
|
|||
})
|
||||
}
|
||||
|
||||
const center = hexToVec3(bgColorCenter)
|
||||
this.uniforms.bgColorCenter.value.set(center.x, center.y, center.z)
|
||||
const edge = hexToVec3(bgColorEdge)
|
||||
this.uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
|
||||
|
||||
const material = new ShaderMaterial({
|
||||
uniforms: this.uniforms,
|
||||
vertexShader,
|
||||
|
|
@ -541,7 +529,7 @@ export default class FloatingLines {
|
|||
|
||||
this.geometry = geometry
|
||||
this.material = material
|
||||
this._startTime = performance.now()
|
||||
this.clock = new Clock()
|
||||
|
||||
this._setSize = () => {
|
||||
const width = container.clientWidth || 1
|
||||
|
|
@ -583,7 +571,7 @@ export default class FloatingLines {
|
|||
|
||||
this.raf = 0
|
||||
const renderLoop = () => {
|
||||
this.uniforms.iTime.value = (performance.now() - this._startTime) * 0.001
|
||||
this.uniforms.iTime.value = this.clock.getElapsedTime()
|
||||
|
||||
if (this.interactive) {
|
||||
this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
|
||||
|
|
@ -601,24 +589,12 @@ export default class FloatingLines {
|
|||
this.renderer.render(this.scene, this.camera)
|
||||
this.raf = requestAnimationFrame(renderLoop)
|
||||
}
|
||||
|
||||
this._handleVisibility = () => {
|
||||
if (document.hidden) {
|
||||
cancelAnimationFrame(this.raf)
|
||||
this.raf = 0
|
||||
} else if (!this.raf) {
|
||||
renderLoop()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', this._handleVisibility)
|
||||
|
||||
renderLoop()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this.raf)
|
||||
if (this.ro) this.ro.disconnect()
|
||||
document.removeEventListener('visibilitychange', this._handleVisibility)
|
||||
|
||||
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
|
||||
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
border-top: 1px solid #222;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 2fr 0.8fr 1.2fr;
|
||||
grid-template-columns: 1fr 1fr 2fr 1.2fr;
|
||||
gap: 10px 16px;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
|
|
@ -287,27 +287,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 3.5: Horizont -->
|
||||
<div class="ctrl-group" style="grid-column: span 1">
|
||||
<h3>Horizont</h3>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 4px;">
|
||||
<button class="img-btn active" data-mode="0">Aus</button>
|
||||
<button class="img-btn" data-mode="1">Nebel</button>
|
||||
<button class="img-btn" data-mode="2">Trennung</button>
|
||||
<button class="img-btn" data-mode="3">Glow</button>
|
||||
</div>
|
||||
<div class="row" id="row-horizonOpacity">
|
||||
<label for="horizonOpacity">Deckkraft</label>
|
||||
<input type="range" id="horizonOpacity" min="0.05" max="1" step="0.05" value="0.5" />
|
||||
<span class="val" id="horizonOpacity-val">0.50</span>
|
||||
</div>
|
||||
<div class="row" id="row-horizonBlend" style="display:none">
|
||||
<label for="horizonBlend">Übergang</label>
|
||||
<input type="range" id="horizonBlend" min="0" max="1" step="0.02" value="0.2" />
|
||||
<span class="val" id="horizonBlend-val">0.20</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 4: Hintergrundbild + Farben -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Hintergrundbild</h3>
|
||||
|
|
@ -522,27 +501,6 @@
|
|||
hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge)
|
||||
})
|
||||
|
||||
// ── Horizont ─────────────────────────────────────────────────────
|
||||
const rowOpacity = document.getElementById('row-horizonOpacity')
|
||||
const rowBlend = document.getElementById('row-horizonBlend')
|
||||
|
||||
function updateHorizonRows(mode) {
|
||||
rowOpacity.style.display = mode === 2 ? 'none' : 'flex'
|
||||
rowBlend.style.display = mode === 2 ? 'flex' : 'none'
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-mode]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-mode]').forEach((b) => b.classList.remove('active'))
|
||||
btn.classList.add('active')
|
||||
const mode = parseInt(btn.dataset.mode)
|
||||
fl.uniforms.horizonMode.value = mode
|
||||
updateHorizonRows(mode)
|
||||
})
|
||||
})
|
||||
slider('horizonOpacity', 2, (v) => (fl.uniforms.horizonOpacity.value = v))
|
||||
slider('horizonBlend', 2, (v) => (fl.uniforms.horizonBlend.value = v))
|
||||
|
||||
// ── Gradient ──────────────────────────────────────────────────────
|
||||
const MAX_STOPS = 8
|
||||
function applyGradient() {
|
||||
|
|
|
|||
12
frontend/package-lock.json
generated
|
|
@ -15,8 +15,7 @@
|
|||
"quasar": "^2.16.0",
|
||||
"three": "^0.183.0",
|
||||
"vue": "^3.4.18",
|
||||
"vue-router": "^4.0.0",
|
||||
"vue-select": "^4.0.0-beta.6"
|
||||
"vue-router": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.14.0",
|
||||
|
|
@ -8173,15 +8172,6 @@
|
|||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-select": {
|
||||
"version": "4.0.0-beta.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-select/-/vue-select-4.0.0-beta.6.tgz",
|
||||
"integrity": "sha512-K+zrNBSpwMPhAxYLTCl56gaMrWZGgayoWCLqe5rWwkB8aUbAUh7u6sXjIR7v4ckp2WKC7zEEUY27g6h1MRsIHw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "3.x"
|
||||
}
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint -c ./eslint.config.js \"src/**/*.{js,cjs,mjs,vue}\"",
|
||||
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{js,cjs,mjs,vue}\"",
|
||||
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||
"test": "echo \"No test specified\" && exit 0",
|
||||
"dev": "npm install && quasar dev",
|
||||
|
|
@ -21,8 +21,7 @@
|
|||
"quasar": "^2.16.0",
|
||||
"three": "^0.183.0",
|
||||
"vue": "^3.4.18",
|
||||
"vue-router": "^4.0.0",
|
||||
"vue-select": "^4.0.0-beta.6"
|
||||
"vue-router": "^4.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-darwin-arm64": "^4.0.0",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 10 MiB |