Sprint 1: Auth, metro map canvas, services, and retro UI

Authentication:
- Laravel Fortify + Sanctum with Inertia views
- RBAC middleware (admin, project_owner, team_member, viewer)
- Retro terminal-styled login/register/forgot-password pages

Metro Map (core UI):
- D3.js zoomable SVG canvas with metro line rendering
- Station nodes with glow-on-hover, status coloring, tooltips
- Breadcrumb navigation for multi-level drill-down
- Node preview panel with zoom-in action
- C64-style CLI bar with blinking cursor at bottom

Backend services:
- ProjectService (CRUD, phase transitions, park/stop, audit logging)
- ThemaService (CRUD with audit)
- MapDataService (strategy map L1, project map L2)
- Thin controllers: MapController, ProjectController, ThemaController
- 32 routes total (auth + app + API)

Style foundation:
- Retro-futurism theme: VT323, Press Start 2P, IBM Plex Mono fonts
- Dark palette with cyan/orange/green/purple neon accents
- Comprehensive seed data (4 themes, 12 projects, commitments, deps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-01 13:52:35 +02:00
parent 7d14ca7b3b
commit d03fe15542
40 changed files with 5368 additions and 21 deletions

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Actions\Fortify;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => ['required', 'string', 'min:8', 'confirmed'],
])->validate();
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
// Assign default 'viewer' role
$viewerRole = Role::where('naam', 'viewer')->first();
if ($viewerRole) {
$user->roles()->attach($viewerRole);
}
return $user;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
'functie' => ['nullable', 'string', 'max:255'],
'afdeling' => ['nullable', 'string', 'max:255'],
])->validate();
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'functie' => $input['functie'] ?? $user->functie,
'afdeling' => $input['afdeling'] ?? $user->afdeling,
])->save();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Services\MapDataService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class MapController extends Controller
{
public function __construct(
private MapDataService $mapDataService
) {}
public function index()
{
$mapData = $this->mapDataService->getStrategyMap();
return Inertia::render('Map/MetroMap', [
'mapData' => $mapData,
]);
}
public function project(int $projectId)
{
$mapData = $this->mapDataService->getProjectMap($projectId);
return Inertia::render('Map/MetroMap', [
'mapData' => $mapData,
]);
}
public function apiStrategy(Request $request)
{
return response()->json($this->mapDataService->getStrategyMap());
}
public function apiProject(int $projectId)
{
return response()->json($this->mapDataService->getProjectMap($projectId));
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ProjectStatus;
use App\Models\Project;
use App\Services\ProjectService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ProjectController extends Controller
{
public function __construct(
private ProjectService $projectService
) {}
public function store(Request $request)
{
$validated = $request->validate([
'naam' => 'required|string|max:255',
'beschrijving' => 'nullable|string',
'speerpunt_id' => 'nullable|exists:speerpunten,id',
'prioriteit' => 'nullable|string',
'startdatum' => 'nullable|date',
'streef_einddatum' => 'nullable|date|after:startdatum',
]);
$project = $this->projectService->create($validated);
return back()->with('success', "Project '{$project->naam}' aangemaakt.");
}
public function show(Project $project)
{
$project = $this->projectService->getWithDetails($project->id);
return Inertia::render('Project/Show', [
'project' => $project,
]);
}
public function update(Request $request, Project $project)
{
$validated = $request->validate([
'naam' => 'sometimes|string|max:255',
'beschrijving' => 'nullable|string',
'prioriteit' => 'nullable|string',
'speerpunt_id' => 'nullable|exists:speerpunten,id',
'streef_einddatum' => 'nullable|date',
]);
$this->projectService->update($project, $validated);
return back()->with('success', 'Project bijgewerkt.');
}
public function transition(Request $request, Project $project)
{
$validated = $request->validate([
'status' => 'required|string',
]);
$newStatus = ProjectStatus::from($validated['status']);
$this->projectService->transitionPhase($project, $newStatus);
return back()->with('success', 'Projectfase bijgewerkt.');
}
public function park(Request $request, Project $project)
{
$reason = $request->input('reason', '');
$this->projectService->park($project, $reason);
return back()->with('success', 'Project geparkeerd.');
}
public function stop(Request $request, Project $project)
{
$reason = $request->input('reason', '');
$this->projectService->stop($project, $reason);
return back()->with('success', 'Project gestopt.');
}
public function destroy(Project $project)
{
$project->delete();
return redirect('/map')->with('success', 'Project verwijderd.');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers;
use App\Models\Thema;
use App\Services\ThemaService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ThemaController extends Controller
{
public function __construct(
private ThemaService $themaService
) {}
public function index()
{
return Inertia::render('Thema/Index', [
'themas' => $this->themaService->getAll(),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'naam' => 'required|string|max:255',
'beschrijving' => 'nullable|string',
'prioriteit' => 'nullable|string',
'periode_start' => 'nullable|date',
'periode_eind' => 'nullable|date|after:periode_start',
]);
$this->themaService->create($validated);
return back()->with('success', 'Thema aangemaakt.');
}
public function update(Request $request, Thema $thema)
{
$validated = $request->validate([
'naam' => 'sometimes|string|max:255',
'beschrijving' => 'nullable|string',
'prioriteit' => 'nullable|string',
'periode_start' => 'nullable|date',
'periode_eind' => 'nullable|date',
]);
$this->themaService->update($thema, $validated);
return back()->with('success', 'Thema bijgewerkt.');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (! $request->user() || ! $request->user()->roles()->whereIn('naam', $roles)->exists()) {
abort(403, 'Je hebt geen toegang tot deze functie.');
}
return $next($request);
}
}

View File

@@ -37,7 +37,21 @@ class HandleInertiaRequests extends Middleware
{
return [
...parent::share($request),
//
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'functie' => $request->user()->functie,
'afdeling' => $request->user()->afdeling,
'roles' => $request->user()->roles->pluck('naam'),
] : null,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
'locale' => app()->getLocale(),
];
}
}

View File

@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name',

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
app()->singleton(\Laravel\Fortify\Contracts\CreatesNewUsers::class, CreateNewUser::class);
app()->singleton(\Laravel\Fortify\Contracts\UpdatesUserProfileInformation::class, UpdateUserProfileInformation::class);
app()->singleton(\Laravel\Fortify\Contracts\UpdatesUserPasswords::class, UpdateUserPassword::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::loginView(fn () => Inertia::render('Auth/Login'));
Fortify::registerView(fn () => Inertia::render('Auth/Register'));
Fortify::requestPasswordResetLinkView(fn () => Inertia::render('Auth/ForgotPassword'));
Fortify::resetPasswordView(fn ($request) => Inertia::render('Auth/ResetPassword', [
'token' => $request->route('token'),
'email' => $request->query('email'),
]));
Fortify::verifyEmailView(fn () => Inertia::render('Auth/VerifyEmail'));
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Services;
use App\Models\Thema;
use App\Models\Project;
use App\Models\Afhankelijkheid;
use Illuminate\Support\Str;
class MapDataService
{
/**
* Build the Level 1 (Strategy) metro map data.
* Each theme = a metro line, each project = a station.
*/
public function getStrategyMap(): array
{
$themas = Thema::with([
'speerpunten.projects' => function ($q) {
$q->with('eigenaar')
->withCount(['documents', 'commitments', 'risicos', 'fases']);
}
])->get();
$lines = [];
$nodes = [];
$connections = [];
$lineColors = ['#00d2ff', '#e94560', '#00ff88', '#7b68ee', '#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff'];
$yOffset = 0;
foreach ($themas as $index => $thema) {
$color = $lineColors[$index % count($lineColors)];
$lines[] = [
'id' => "thema-{$thema->id}",
'name' => $thema->naam,
'color' => $color,
];
$projects = $thema->speerpunten->flatMap->projects;
$xOffset = -200;
foreach ($projects as $order => $project) {
$nodes[] = [
'id' => "project-{$project->id}",
'entityId' => $project->id,
'entityType' => 'project',
'name' => $project->naam,
'lineId' => "thema-{$thema->id}",
'x' => $xOffset + ($order * 200),
'y' => $yOffset,
'order' => $order + 1,
'status' => $project->status->value,
'description' => Str::limit($project->beschrijving, 100),
'owner' => $project->eigenaar?->name,
'badge' => ucfirst(str_replace('_', ' ', $project->status->value)),
'children' => $project->documents_count + $project->commitments_count,
];
}
$yOffset += 130;
}
// Get dependencies as connections
$dependencies = Afhankelijkheid::all();
foreach ($dependencies as $dep) {
$connections[] = [
'from' => "project-{$dep->project_id}",
'to' => "project-{$dep->afhankelijk_van_project_id}",
];
}
return [
'lines' => $lines,
'nodes' => $nodes,
'connections' => $connections,
'level' => 1,
];
}
/**
* Build Level 2 (Project) metro map data.
* The project's lifecycle phases = a metro line, milestones = stations.
*/
public function getProjectMap(int $projectId): array
{
$project = Project::with([
'fases',
'commitments' => fn ($q) => $q->with('eigenaar'),
'documents',
'risicos',
'besluiten',
])->findOrFail($projectId);
$lines = [
['id' => 'lifecycle', 'name' => $project->naam, 'color' => '#00d2ff'],
['id' => 'commitments', 'name' => 'Commitments', 'color' => '#e94560'],
['id' => 'documents', 'name' => 'Documenten', 'color' => '#7b68ee'],
];
$nodes = [];
$xOffset = -300;
// Phase nodes on lifecycle line
foreach ($project->fases->sortBy('type') as $order => $fase) {
$nodes[] = [
'id' => "fase-{$fase->id}",
'entityId' => $fase->id,
'entityType' => 'fase',
'name' => ucfirst(str_replace('_', ' ', $fase->type->value)),
'lineId' => 'lifecycle',
'x' => $xOffset + ($order * 180),
'y' => -50,
'order' => $order + 1,
'status' => $fase->status->value,
'badge' => ucfirst($fase->status->value),
];
}
// Commitment nodes
foreach ($project->commitments as $order => $commitment) {
$nodes[] = [
'id' => "commitment-{$commitment->id}",
'entityId' => $commitment->id,
'entityType' => 'commitment',
'name' => Str::limit($commitment->beschrijving, 40),
'lineId' => 'commitments',
'x' => $xOffset + ($order * 180),
'y' => 80,
'order' => $order + 1,
'status' => $commitment->status->value,
'owner' => $commitment->eigenaar?->name,
'badge' => $commitment->deadline?->format('d M'),
];
}
// Document nodes
foreach ($project->documents as $order => $doc) {
$nodes[] = [
'id' => "document-{$doc->id}",
'entityId' => $doc->id,
'entityType' => 'document',
'name' => $doc->titel,
'lineId' => 'documents',
'x' => $xOffset + ($order * 180),
'y' => 210,
'order' => $order + 1,
'status' => 'active',
'badge' => "v{$doc->versie}",
];
}
return [
'lines' => $lines,
'nodes' => $nodes,
'connections' => [],
'level' => 2,
'project' => [
'id' => $project->id,
'naam' => $project->naam,
'status' => $project->status->value,
],
];
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Services;
use App\Models\Project;
use App\Models\AuditLog;
use App\Enums\ProjectStatus;
use App\Enums\FaseType;
use App\Enums\FaseStatus;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class ProjectService
{
/**
* Get all projects with their relationships for the metro map.
*/
public function getAllForMap(): Collection
{
return Project::with(['eigenaar', 'speerpunt.thema', 'fases', 'risicos', 'commitments'])
->withCount(['documents', 'commitments', 'risicos'])
->get();
}
/**
* Get a single project with full details.
*/
public function getWithDetails(int $id): Project
{
return Project::with([
'eigenaar',
'speerpunt.thema',
'fases',
'risicos',
'commitments.acties',
'commitments.eigenaar',
'documents',
'besluiten',
'teamleden',
'afhankelijkheden.afhankelijkVan',
'overdrachtsplannen.criteria',
])->findOrFail($id);
}
/**
* Create a new project with initial phase.
*/
public function create(array $data): Project
{
return DB::transaction(function () use ($data) {
$project = Project::create([
'naam' => $data['naam'],
'beschrijving' => $data['beschrijving'] ?? '',
'eigenaar_id' => $data['eigenaar_id'] ?? Auth::id(),
'speerpunt_id' => $data['speerpunt_id'] ?? null,
'status' => ProjectStatus::Signaal,
'prioriteit' => $data['prioriteit'] ?? \App\Enums\Prioriteit::Midden,
'startdatum' => $data['startdatum'] ?? now(),
'streef_einddatum' => $data['streef_einddatum'] ?? null,
]);
// Create initial "signaal" phase
$project->fases()->create([
'type' => FaseType::Signaal,
'status' => FaseStatus::Actief,
'startdatum' => now(),
]);
// Assign creator as project owner
$project->teamleden()->attach(Auth::id(), ['rol' => \App\Enums\ProjectRol::Eigenaar]);
$this->audit('created', $project);
return $project;
});
}
/**
* Update project details.
*/
public function update(Project $project, array $data): Project
{
$project->update(array_filter([
'naam' => $data['naam'] ?? null,
'beschrijving' => $data['beschrijving'] ?? null,
'prioriteit' => $data['prioriteit'] ?? null,
'speerpunt_id' => $data['speerpunt_id'] ?? null,
'streef_einddatum' => $data['streef_einddatum'] ?? null,
], fn ($v) => $v !== null));
$this->audit('updated', $project);
return $project->fresh();
}
/**
* Transition a project to the next phase.
*/
public function transitionPhase(Project $project, ProjectStatus $newStatus): Project
{
return DB::transaction(function () use ($project, $newStatus) {
$oldStatus = $project->status;
// Close current active phase
$project->fases()
->where('status', FaseStatus::Actief)
->update([
'status' => FaseStatus::Afgerond->value,
'einddatum' => now(),
]);
// Create new phase (if it maps to a FaseType)
$faseType = FaseType::tryFrom($newStatus->value);
if ($faseType) {
$project->fases()->create([
'type' => $faseType,
'status' => FaseStatus::Actief,
'startdatum' => now(),
]);
}
// Update project status
$project->update(['status' => $newStatus]);
$this->audit('phase_transition', $project, [
'from' => $oldStatus->value,
'to' => $newStatus->value,
]);
return $project->fresh(['fases']);
});
}
/**
* Park a project (temporarily halt).
*/
public function park(Project $project, string $reason = ''): Project
{
return $this->transitionToSpecialStatus($project, ProjectStatus::Geparkeerd, $reason);
}
/**
* Stop a project permanently.
*/
public function stop(Project $project, string $reason = ''): Project
{
return $this->transitionToSpecialStatus($project, ProjectStatus::Gestopt, $reason);
}
private function transitionToSpecialStatus(Project $project, ProjectStatus $status, string $reason): Project
{
return DB::transaction(function () use ($project, $status, $reason) {
$oldStatus = $project->status;
$project->fases()
->where('status', FaseStatus::Actief)
->update([
'status' => FaseStatus::Afgerond->value,
'einddatum' => now(),
'opmerkingen' => $reason,
]);
$project->update(['status' => $status]);
$this->audit('status_change', $project, [
'from' => $oldStatus->value,
'to' => $status->value,
'reason' => $reason,
]);
return $project->fresh();
});
}
private function audit(string $action, Project $project, ?array $extra = null): void
{
AuditLog::create([
'user_id' => Auth::id(),
'action' => "project.{$action}",
'entity_type' => 'project',
'entity_id' => $project->id,
'payload' => $extra,
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services;
use App\Models\Thema;
use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
class ThemaService
{
public function getAll(): Collection
{
return Thema::with(['speerpunten.projects'])->get();
}
public function getForMap(): Collection
{
return Thema::with([
'speerpunten.projects' => function ($q) {
$q->with('eigenaar')
->withCount(['documents', 'commitments', 'risicos']);
}
])->get();
}
public function create(array $data): Thema
{
$thema = Thema::create([
'naam' => $data['naam'],
'beschrijving' => $data['beschrijving'] ?? '',
'prioriteit' => $data['prioriteit'] ?? \App\Enums\Prioriteit::Midden,
'periode_start' => $data['periode_start'] ?? null,
'periode_eind' => $data['periode_eind'] ?? null,
]);
AuditLog::create([
'user_id' => Auth::id(),
'action' => 'thema.created',
'entity_type' => 'thema',
'entity_id' => $thema->id,
]);
return $thema;
}
public function update(Thema $thema, array $data): Thema
{
$thema->update(array_filter($data, fn ($v) => $v !== null));
AuditLog::create([
'user_id' => Auth::id(),
'action' => 'thema.updated',
'entity_type' => 'thema',
'entity_id' => $thema->id,
]);
return $thema->fresh();
}
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
@@ -14,6 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);
$middleware->alias([
'role' => \App\Http\Middleware\CheckRole::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@@ -4,4 +4,5 @@ use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
];

View File

@@ -8,7 +8,9 @@
"require": {
"php": "^8.3",
"inertiajs/inertia-laravel": "^3.0",
"laravel/fortify": "^1.36",
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0"
},
"require-dev": {

354
composer.lock generated
View File

@@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1c6dc1e0948ef3b8fd1905a824a40c58",
"content-hash": "49877ead17f176f4f560ededac27ed08",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "v3.0.4",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "3feed0e212b8412cc5d2612706744789b0615824"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824",
"reference": "3feed0e212b8412cc5d2612706744789b0615824",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4"
},
"time": "2026-03-16T01:01:30+00:00"
},
{
"name": "brick/math",
"version": "0.14.8",
@@ -135,6 +190,56 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -1126,6 +1231,69 @@
},
"time": "2026-03-25T21:07:46+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.36.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
"reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/fortify/zipball/b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
"reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^3.0",
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
"pragmarx/google2fa": "^9.0"
},
"require-dev": {
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Fortify\\FortifyServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Fortify\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Backend controllers and scaffolding for Laravel authentication.",
"keywords": [
"auth",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify"
},
"time": "2026-03-20T20:13:51+00:00"
},
{
"name": "laravel/framework",
"version": "v13.2.0",
@@ -1406,6 +1574,69 @@
},
"time": "2026-03-23T14:35:33+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.3.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2026-02-07T17:19:31+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.10",
@@ -2606,6 +2837,75 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -2681,6 +2981,58 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
{
"name": "pragmarx/google2fa",
"version": "v9.0.0",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
},
"time": "2025-09-19T22:51:08+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",

154
config/fortify.php Normal file
View File

@@ -0,0 +1,154 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/dashboard',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => false,
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
],
];

84
config/sanctum.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')
->after('password')
->nullable();
$table->text('two_factor_recovery_codes')
->after('two_factor_secret')
->nullable();
$table->timestamp('two_factor_confirmed_at')
->after('two_factor_recovery_codes')
->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@@ -2,24 +2,551 @@
namespace Database\Seeders;
use App\Enums\CommitmentStatus;
use App\Enums\FaseStatus;
use App\Enums\FaseType;
use App\Enums\Prioriteit;
use App\Enums\ProjectRol;
use App\Enums\ProjectStatus;
use App\Enums\SpeerpuntStatus;
use App\Models\Afhankelijkheid;
use App\Models\Commitment;
use App\Models\Document;
use App\Models\Fase;
use App\Models\Project;
use App\Models\Role;
use App\Models\Speerpunt;
use App\Models\Thema;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
// ──────────────────────────────────────────────────────────────
// 1. System roles
// ──────────────────────────────────────────────────────────────
$roleAdmin = Role::create([
'naam' => 'admin',
'beschrijving' => 'Volledige toegang tot het platform',
'permissies' => ['*'],
]);
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$roleProjectOwner = Role::create([
'naam' => 'project_owner',
'beschrijving' => 'Kan projecten beheren en bewerken',
'permissies' => ['projects.manage', 'commitments.manage', 'documents.manage'],
]);
$roleTeamMember = Role::create([
'naam' => 'team_member',
'beschrijving' => 'Kan bijdragen aan toegewezen projecten',
'permissies' => ['projects.view', 'commitments.edit', 'documents.upload'],
]);
Role::create([
'naam' => 'viewer',
'beschrijving' => 'Alleen-lezen toegang',
'permissies' => ['projects.view', 'documents.view'],
]);
// ──────────────────────────────────────────────────────────────
// 2. Users
// ──────────────────────────────────────────────────────────────
$adminUser = User::create([
'name' => 'Admin Gebruiker',
'email' => 'admin@innovatieplatform.nl',
'password' => Hash::make('password'),
'functie' => 'Platform Beheerder',
'afdeling' => 'R&D Lab',
'email_verified_at' => now(),
]);
$adminUser->roles()->attach($roleAdmin);
$testUser = User::create([
'name' => 'Rene de Ren',
'email' => 'rene@wbd-rd.nl',
'password' => Hash::make('password'),
'functie' => 'R&D Engineer',
'afdeling' => 'R&D Lab',
'email_verified_at' => now(),
]);
$testUser->roles()->attach($roleProjectOwner);
$analyst = User::create([
'name' => 'Lisanne Bakker',
'email' => 'l.bakker@wbd-rd.nl',
'password' => Hash::make('password'),
'functie' => 'Data Analist',
'afdeling' => 'Watermanagement',
'email_verified_at' => now(),
]);
$analyst->roles()->attach($roleTeamMember);
$engineer = User::create([
'name' => 'Joris van Dam',
'email' => 'j.vandam@wbd-rd.nl',
'password' => Hash::make('password'),
'functie' => 'Senior Technisch Adviseur',
'afdeling' => 'Infrastructuur',
'email_verified_at' => now(),
]);
$engineer->roles()->attach($roleTeamMember);
// ──────────────────────────────────────────────────────────────
// 3. Themas (4 strategic themes)
// ──────────────────────────────────────────────────────────────
$themaWater = Thema::create([
'naam' => 'Waterkwaliteit',
'beschrijving' => 'Verbetering van de kwaliteit van oppervlaktewater en grondwater door innovatieve monitoring- en zuiveringstechnieken.',
'prioriteit' => Prioriteit::Hoog,
'periode_start' => '2025-01-01',
'periode_eind' => '2028-12-31',
]);
$themaInfra = Thema::create([
'naam' => 'Slimme Infrastructuur',
'beschrijving' => 'Digitalisering en automatisering van waterkeringen, gemalen en sluizen voor efficiënter beheer en snellere responstijden.',
'prioriteit' => Prioriteit::Hoog,
'periode_start' => '2025-01-01',
'periode_eind' => '2028-12-31',
]);
$themaData = Thema::create([
'naam' => 'Data-gedreven Beheer',
'beschrijving' => 'Inzet van data-analyse, AI en digitale tweelingen voor betere besluitvorming in waterbeheer.',
'prioriteit' => Prioriteit::Midden,
'periode_start' => '2025-06-01',
'periode_eind' => '2029-06-30',
]);
$themaDuurzaam = Thema::create([
'naam' => 'Duurzaamheid & Klimaatadaptatie',
'beschrijving' => 'Innovaties gericht op energieneutraliteit, circulaire waterketens en klimaatrobuuste inrichting van het beheergebied.',
'prioriteit' => Prioriteit::Midden,
'periode_start' => '2025-01-01',
'periode_eind' => '2030-12-31',
]);
// ──────────────────────────────────────────────────────────────
// 4. Speerpunten (2 per thema)
// ──────────────────────────────────────────────────────────────
// Waterkwaliteit
$spWaterMonitoring = Speerpunt::create([
'thema_id' => $themaWater->id,
'naam' => 'Real-time Watermonitoring',
'beschrijving' => 'Continue meting van waterkwaliteitsparameters via IoT-sensoren in het beheergebied.',
'eigenaar_id' => $testUser->id,
'status' => SpeerpuntStatus::Actief,
]);
$spMicropollution = Speerpunt::create([
'thema_id' => $themaWater->id,
'naam' => 'Microverontreinigingen',
'beschrijving' => 'Detectie en verwijdering van opkomende stoffen zoals medicijnresten en PFAS.',
'eigenaar_id' => $engineer->id,
'status' => SpeerpuntStatus::Concept,
]);
// Slimme Infrastructuur
$spDigitaalBeheer = Speerpunt::create([
'thema_id' => $themaInfra->id,
'naam' => 'Digitaal Kunstwerkenregister',
'beschrijving' => 'Volledig digitaal beheer van kunstwerken met BIM-koppeling en conditiebewaking.',
'eigenaar_id' => $engineer->id,
'status' => SpeerpuntStatus::Actief,
]);
$spSmartGemaal = Speerpunt::create([
'thema_id' => $themaInfra->id,
'naam' => 'Slimme Gemaalbesturing',
'beschrijving' => 'Predictieve sturing van gemalen op basis van weersvoorspelling en waterstanden.',
'eigenaar_id' => $testUser->id,
'status' => SpeerpuntStatus::Actief,
]);
// Data-gedreven Beheer
$spDigitaalTwin = Speerpunt::create([
'thema_id' => $themaData->id,
'naam' => 'Digitale Tweeling Watersysteem',
'beschrijving' => 'Virtueel model van het watersysteem voor scenario-analyse en operationele ondersteuning.',
'eigenaar_id' => $analyst->id,
'status' => SpeerpuntStatus::Concept,
]);
$spAIVoorspelling = Speerpunt::create([
'thema_id' => $themaData->id,
'naam' => 'AI-gestuurde Waterstandsvoorspelling',
'beschrijving' => 'Machine learning modellen voor nauwkeurige korte- en middellangetermijnwaterstanden.',
'eigenaar_id' => $analyst->id,
'status' => SpeerpuntStatus::Actief,
]);
// Duurzaamheid
$spEnergieneutraal = Speerpunt::create([
'thema_id' => $themaDuurzaam->id,
'naam' => 'Energieneutrale Zuivering',
'beschrijving' => 'Zelfvoorzienende rwzi\'s door terugwinning van energie uit afvalwater.',
'eigenaar_id' => $engineer->id,
'status' => SpeerpuntStatus::Actief,
]);
$spCirculaireWater = Speerpunt::create([
'thema_id' => $themaDuurzaam->id,
'naam' => 'Circulaire Waterketen',
'beschrijving' => 'Terugwinning van grondstoffen (fosfaat, cellulose, warmte) uit afvalwater.',
'eigenaar_id' => $testUser->id,
'status' => SpeerpuntStatus::Concept,
]);
// ──────────────────────────────────────────────────────────────
// 5. Projects (1012 spread across themes and lifecycle phases)
// ──────────────────────────────────────────────────────────────
$projects = [];
// --- Waterkwaliteit projects ---
$p1 = $this->createProject([
'speerpunt_id' => $spWaterMonitoring->id,
'naam' => 'LoRaWAN Sensornetwerk Biesbosch',
'beschrijving' => 'Uitrol van een draadloos sensornetwerk in het Biesbosch-gebied voor real-time meting van waterkwaliteitsparameters (pH, DO, troebelheid, geleidbaarheid).',
'eigenaar_id' => $testUser->id,
'status' => ProjectStatus::Pilot,
'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-03-01',
'streef_einddatum' => '2025-12-31',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot]);
$p2 = $this->createProject([
'speerpunt_id' => $spWaterMonitoring->id,
'naam' => 'Drone-inspectie Waterbodem',
'beschrijving' => 'Onderzoek naar de inzet van autonome onderwaterdrones voor sedimentkartering en vervuilingdetectie in kanalen en sloten.',
'eigenaar_id' => $engineer->id,
'status' => ProjectStatus::Experiment,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2025-06-01',
'streef_einddatum' => '2026-06-30',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment]);
$p3 = $this->createProject([
'speerpunt_id' => $spMicropollution->id,
'naam' => 'PFAS Detectiemethode Ontwikkeling',
'beschrijving' => 'Ontwikkeling van een snelle en goedkope veldmethode voor PFAS-detectie in oppervlaktewater, als alternatief voor kostbare laboratoriumanalyse.',
'eigenaar_id' => $analyst->id,
'status' => ProjectStatus::Verkenning,
'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-09-01',
'streef_einddatum' => '2026-09-30',
], [FaseType::Signaal, FaseType::Verkenning]);
// --- Slimme Infrastructuur projects ---
$p4 = $this->createProject([
'speerpunt_id' => $spDigitaalBeheer->id,
'naam' => 'BIM-model Gemaal De Donge',
'beschrijving' => 'Digitale driedimensionale representatie van gemaal De Donge inclusief alle technische installaties, leidingen en elektrotechnische componenten.',
'eigenaar_id' => $engineer->id,
'status' => ProjectStatus::OverdrachtBouwen,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2024-09-01',
'streef_einddatum' => '2025-06-30',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming, FaseType::OverdrachtBouwen]);
$p5 = $this->createProject([
'speerpunt_id' => $spSmartGemaal->id,
'naam' => 'Predictieve Gemaalbesturing Mark-Vliet',
'beschrijving' => 'Implementatie van een ML-algoritme dat op basis van KNMI-weerdata en historische afvoerpatronen de optimale pompsturing berekent voor het Mark-Vliet systeem.',
'eigenaar_id' => $testUser->id,
'status' => ProjectStatus::Besluitvorming,
'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-01-01',
'streef_einddatum' => '2025-12-31',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming]);
$p6 = $this->createProject([
'speerpunt_id' => $spSmartGemaal->id,
'naam' => 'Remote Monitoring Waterkeringen',
'beschrijving' => 'Continuemonitoring van primaire waterkeringen met IoT-sensoren voor zakking, piping-detectie en grondwaterstand.',
'eigenaar_id' => $engineer->id,
'status' => ProjectStatus::Signaal,
'prioriteit' => Prioriteit::Laag,
'startdatum' => '2026-01-01',
'streef_einddatum' => null,
], [FaseType::Signaal]);
// --- Data-gedreven Beheer projects ---
$p7 = $this->createProject([
'speerpunt_id' => $spDigitaalTwin->id,
'naam' => 'Digitale Tweeling Pilot Roosendaalse Vliet',
'beschrijving' => 'Eerste proof-of-concept van een digitale tweeling voor het deelgebied Roosendaalse Vliet, gekoppeld aan het SOBEK-hydraulisch model.',
'eigenaar_id' => $analyst->id,
'status' => ProjectStatus::Concept,
'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-07-01',
'streef_einddatum' => '2026-12-31',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept]);
$p8 = $this->createProject([
'speerpunt_id' => $spAIVoorspelling->id,
'naam' => 'AI Waterstandsmodel Hollandsch Diep',
'beschrijving' => 'Training en validatie van een LSTM-neuraal netwerk voor 48-uurs waterstandsvoorspellingen op het Hollandsch Diep, ter vervanging van het huidige regressiemodel.',
'eigenaar_id' => $analyst->id,
'status' => ProjectStatus::Experiment,
'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-04-01',
'streef_einddatum' => '2026-03-31',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment]);
$p9 = $this->createProject([
'speerpunt_id' => $spAIVoorspelling->id,
'naam' => 'Open Data Platform Waterschap',
'beschrijving' => 'Ontwikkeling van een publiekstoegankelijk data-portaal voor het ontsluiten van historische en actuele meetdata van het waterschap.',
'eigenaar_id' => $testUser->id,
'status' => ProjectStatus::Geparkeerd,
'prioriteit' => Prioriteit::Laag,
'startdatum' => '2024-06-01',
'streef_einddatum' => '2025-12-31',
], [FaseType::Signaal, FaseType::Verkenning]);
// --- Duurzaamheid projects ---
$p10 = $this->createProject([
'speerpunt_id' => $spEnergieneutraal->id,
'naam' => 'Biogasopwaardering RWZI Bath',
'beschrijving' => 'Opwaardering van slibvergistingsgas naar groengas-kwaliteit voor invoeding op het gasnet en verkoop aan een energiemaatschappij.',
'eigenaar_id' => $engineer->id,
'status' => ProjectStatus::Evaluatie,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2024-01-01',
'streef_einddatum' => '2025-06-30',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming, FaseType::OverdrachtBouwen, FaseType::OverdrachtBeheer, FaseType::Evaluatie]);
$p11 = $this->createProject([
'speerpunt_id' => $spCirculaireWater->id,
'naam' => 'Fosfaatterugwinning Struviet',
'beschrijving' => 'Implementatie van struvietkristallisatie-technologie op RWZI Nieuw-Vossemeer voor de terugwinning van fosfaat als meststof.',
'eigenaar_id' => $testUser->id,
'status' => ProjectStatus::Pilot,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2025-02-01',
'streef_einddatum' => '2026-02-28',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot]);
$p12 = $this->createProject([
'speerpunt_id' => $spEnergieneutraal->id,
'naam' => 'Warmteterugwinning Afvalwater Centrum',
'beschrijving' => 'Pilotinstallatie voor warmtewisselaars op het rioolstelsel in Breda-centrum om warmte terug te winnen voor stadsverwarming.',
'eigenaar_id' => $engineer->id,
'status' => ProjectStatus::Verkenning,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2025-10-01',
'streef_einddatum' => '2027-03-31',
], [FaseType::Signaal, FaseType::Verkenning]);
$projects = [$p1, $p2, $p3, $p4, $p5, $p6, $p7, $p8, $p9, $p10, $p11, $p12];
// ──────────────────────────────────────────────────────────────
// 6. Assign team members
// ──────────────────────────────────────────────────────────────
$p1->teamleden()->attach($analyst->id, ['rol' => ProjectRol::Lid->value]);
$p2->teamleden()->attach($testUser->id, ['rol' => ProjectRol::Reviewer->value]);
$p5->teamleden()->attach($analyst->id, ['rol' => ProjectRol::Lid->value]);
$p5->teamleden()->attach($engineer->id, ['rol' => ProjectRol::Reviewer->value]);
$p8->teamleden()->attach($engineer->id, ['rol' => ProjectRol::Lid->value]);
// ──────────────────────────────────────────────────────────────
// 7. Commitments
// ──────────────────────────────────────────────────────────────
Commitment::create([
'project_id' => $p1->id,
'beschrijving' => 'Installatie van 20 sensorknooppunten in het veld vóór einde Q3 2025',
'eigenaar_id' => $testUser->id,
'deadline' => '2025-09-30',
'status' => CommitmentStatus::InUitvoering,
'bron' => 'Stuurgroep Waterkwaliteit — 12 maart 2025',
]);
Commitment::create([
'project_id' => $p1->id,
'beschrijving' => 'Validatierapport sensornauwkeurigheid opleveren aan dataplatformteam',
'eigenaar_id' => $analyst->id,
'deadline' => '2025-11-30',
'status' => CommitmentStatus::Open,
'bron' => 'Stuurgroep Waterkwaliteit — 12 maart 2025',
]);
Commitment::create([
'project_id' => $p5->id,
'beschrijving' => 'Afstemming met Rijkswaterstaat over databeschikbaarheid afvoermetingen',
'eigenaar_id' => $engineer->id,
'deadline' => '2025-07-31',
'status' => CommitmentStatus::Afgerond,
'bron' => 'Projectstartup 15 januari 2025',
]);
Commitment::create([
'project_id' => $p5->id,
'beschrijving' => 'Businesscase energiebesparing opstellen voor directie',
'eigenaar_id' => $testUser->id,
'deadline' => '2025-10-15',
'status' => CommitmentStatus::Open,
'bron' => 'Besluitvormingsrapport fase 5',
]);
Commitment::create([
'project_id' => $p8->id,
'beschrijving' => 'Trainingsdata leveren: minimaal 5 jaar uurlijkse waterstandsmetingen',
'eigenaar_id' => $analyst->id,
'deadline' => '2025-08-01',
'status' => CommitmentStatus::Afgerond,
'bron' => 'Projectplan AI Waterstandsmodel v1.0',
]);
Commitment::create([
'project_id' => $p10->id,
'beschrijving' => 'Eindrapportage energieopbrengst en milieuprestatie biogasinstallatie',
'eigenaar_id' => $engineer->id,
'deadline' => '2025-05-31',
'status' => CommitmentStatus::Afgerond,
'bron' => 'Evaluatieprogramma RWZI Bath',
]);
Commitment::create([
'project_id' => $p11->id,
'beschrijving' => 'Technische specificaties struvietreactor aanleveren aan leverancier',
'eigenaar_id' => $testUser->id,
'deadline' => '2025-05-01',
'status' => CommitmentStatus::Afgerond,
'bron' => 'Pilotopzet Fosfaatterugwinning',
]);
// ──────────────────────────────────────────────────────────────
// 8. Documents
// ──────────────────────────────────────────────────────────────
Document::create([
'project_id' => $p1->id,
'titel' => 'Technisch Ontwerp LoRaWAN Netwerk',
'type' => 'technisch_ontwerp',
'inhoud' => 'Systeemarchitectuur, gatewaylocaties, frequentieplan en databeheerprotocol voor het LoRaWAN sensornetwerk in het Biesboschgebied.',
'versie' => 2,
'auteur_id' => $engineer->id,
]);
Document::create([
'project_id' => $p1->id,
'titel' => 'Projectplan Fase Pilot',
'type' => 'projectplan',
'inhoud' => 'Doelstellingen, activiteiten, planning en risico\'s voor de pilotfase van het LoRaWAN sensornetwerk.',
'versie' => 1,
'auteur_id' => $testUser->id,
]);
Document::create([
'project_id' => $p5->id,
'titel' => 'Businesscase Predictieve Gemaalbesturing',
'type' => 'businesscase',
'inhoud' => 'Kosten-batenanalyse voor de implementatie van ML-gestuurde pompsturing, inclusief energiebesparingspotentieel en investeringskosten.',
'versie' => 3,
'auteur_id' => $testUser->id,
]);
Document::create([
'project_id' => $p8->id,
'titel' => 'Modelarchitectuur LSTM Waterstandsvoorspelling',
'type' => 'technisch_rapport',
'inhoud' => 'Gedetailleerde beschrijving van de LSTM-netwerkarchitectuur, feature engineering, trainingsopzet en validatiemethodiek.',
'versie' => 1,
'auteur_id' => $analyst->id,
]);
Document::create([
'project_id' => $p10->id,
'titel' => 'Evaluatierapport Biogasopwaardering RWZI Bath',
'type' => 'evaluatierapport',
'inhoud' => 'Eindresultaten van de biogasopwaarderingsinstallatie: energieproductie, CH4-gehalte, opbrengst en geleerde lessen.',
'versie' => 1,
'auteur_id' => $engineer->id,
]);
Document::create([
'project_id' => $p4->id,
'titel' => 'BIM-protocol Kunstwerken v2.0',
'type' => 'protocol',
'inhoud' => 'Afspraken voor objectcodering, LOD-niveaus, attribuutvelden en uitwisselformaten (IFC) voor het BIM-model van kunstwerken.',
'versie' => 2,
'auteur_id' => $engineer->id,
]);
// ──────────────────────────────────────────────────────────────
// 9. Dependencies between projects
// ──────────────────────────────────────────────────────────────
// AI Waterstandsmodel is afhankelijk van LoRaWAN Sensornetwerk (databron)
Afhankelijkheid::create([
'project_id' => $p8->id,
'afhankelijk_van_project_id' => $p1->id,
'type' => 'data',
'beschrijving' => 'Het AI-model heeft real-time sensordata nodig uit het LoRaWAN netwerk als input feature.',
'status' => 'actief',
]);
// Digitale Tweeling is afhankelijk van AI Waterstandsmodel
Afhankelijkheid::create([
'project_id' => $p7->id,
'afhankelijk_van_project_id' => $p8->id,
'type' => 'technisch',
'beschrijving' => 'De digitale tweeling integreert de voorspellingsmodule van het AI waterstandsmodel.',
'status' => 'actief',
]);
// Predictieve Gemaalbesturing is afhankelijk van AI Waterstandsmodel
Afhankelijkheid::create([
'project_id' => $p5->id,
'afhankelijk_van_project_id' => $p8->id,
'type' => 'technisch',
'beschrijving' => 'De predictieve besturing gebruikt de 48-uurs waterstandsvoorspellingen als stuurinput.',
'status' => 'actief',
]);
// Remote Monitoring Waterkeringen is afhankelijk van LoRaWAN Sensornetwerk
Afhankelijkheid::create([
'project_id' => $p6->id,
'afhankelijk_van_project_id' => $p1->id,
'type' => 'infrastructuur',
'beschrijving' => 'Het monitoring-project maakt gebruik van de LoRaWAN-infrastructuur voor datatransport.',
'status' => 'gepland',
]);
}
/**
* Helper: create a project with its completed and active phases.
*
* @param array<string, mixed> $attributes
* @param FaseType[] $faseTypes All phase types in chronological order; the last one is the active phase.
*/
private function createProject(array $attributes, array $faseTypes): Project
{
$project = Project::create($attributes);
// Attach the project owner as eigenaar in the pivot table
$project->teamleden()->attach($attributes['eigenaar_id'], ['rol' => ProjectRol::Eigenaar->value]);
foreach ($faseTypes as $index => $faseType) {
$isLast = $index === count($faseTypes) - 1;
Fase::create([
'project_id' => $project->id,
'type' => $faseType,
'status' => $isLast ? FaseStatus::Actief : FaseStatus::Afgerond,
'startdatum' => now()->subMonths(count($faseTypes) - 1 - $index),
'einddatum' => $isLast ? null : now()->subMonths(count($faseTypes) - 2 - $index),
]);
}
return $project;
}
}

489
package-lock.json generated
View File

@@ -5,9 +5,13 @@
"packages": {
"": {
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/press-start-2p": "^5.2.7",
"@fontsource/vt323": "^5.2.7",
"@inertiajs/vue3": "^3.0.1",
"@vitejs/plugin-vue": "^6.0.5",
"@vueuse/core": "^14.2.1",
"d3": "^7.9.0",
"pinia": "^3.0.4",
"vue": "^3.5.31"
},
@@ -97,6 +101,33 @@
"tslib": "^2.4.0"
}
},
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz",
"integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/press-start-2p": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/press-start-2p/-/press-start-2p-5.2.7.tgz",
"integrity": "sha512-ezSxYRchANoteja170ZHh2Tt4oejmVEvX42VFPOVVvERSZ68pcuZFjbhHfrK/sNjYmaGg656y7B3431ojO9PCQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/vt323": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/vt323/-/vt323-5.2.7.tgz",
"integrity": "sha512-8JTMM23vMhQxin9Cn/ijty8cNwXW4INrln0VAJ2227Rz0CVfkzM3qr3l/CqudZJ6BXCnbCGUTdf2ym3cTNex8A==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@inertiajs/core": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.1.tgz",
@@ -1071,6 +1102,15 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@@ -1117,6 +1157,416 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1465,6 +1915,27 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1949,6 +2420,12 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
@@ -1988,6 +2465,12 @@
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"license": "MIT"
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -1998,6 +2481,12 @@
"tslib": "^2.1.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",

View File

@@ -15,9 +15,13 @@
"vite": "^8.0.0"
},
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/press-start-2p": "^5.2.7",
"@fontsource/vt323": "^5.2.7",
"@inertiajs/vue3": "^3.0.1",
"@vitejs/plugin-vue": "^6.0.5",
"@vueuse/core": "^14.2.1",
"d3": "^7.9.0",
"pinia": "^3.0.4",
"vue": "^3.5.31"
}

View File

@@ -1 +1,29 @@
@import "tailwindcss";
@import "@fontsource/vt323";
@import "@fontsource/press-start-2p";
@import "@fontsource/ibm-plex-mono";
@theme {
--color-bg-deep: #1a1a2e;
--color-bg-surface: #16213e;
--color-primary: #0f3460;
--color-accent-cyan: #00d2ff;
--color-accent-orange: #e94560;
--color-accent-green: #00ff88;
--color-accent-purple: #7b68ee;
--color-text-primary: #e8e8e8;
--color-text-secondary: #8892b0;
--font-mono: 'IBM Plex Mono', monospace;
--font-retro: 'VT323', monospace;
--font-pixel: 'Press Start 2P', monospace;
}
body {
background-color: var(--color-bg-deep);
color: var(--color-text-primary);
font-family: var(--font-mono);
margin: 0;
overflow: hidden;
height: 100vh;
}

View File

@@ -0,0 +1,156 @@
<script setup>
import { ref, nextTick } from 'vue'
const emit = defineEmits(['command'])
const input = ref('')
const inputRef = ref(null)
const history = ref([])
const showHistory = ref(false)
const handleSubmit = () => {
if (!input.value.trim()) return
const command = input.value.trim()
history.value.push({ type: 'input', text: command })
history.value.push({ type: 'response', text: 'Processing...' })
showHistory.value = true
emit('command', command)
input.value = ''
}
const focusInput = () => {
inputRef.value?.focus()
}
</script>
<template>
<div class="cli-container" @click="focusInput">
<!-- History panel -->
<Transition name="slide-up">
<div v-if="showHistory && history.length > 0" class="cli-history">
<div
v-for="(entry, i) in history"
:key="i"
class="history-entry"
:class="entry.type"
>
<span v-if="entry.type === 'input'" class="prompt-char">&gt; </span>
<span v-if="entry.type === 'response'" class="ai-label">[AI] </span>
{{ entry.text }}
</div>
</div>
</Transition>
<!-- Input bar -->
<div class="cli-bar">
<span class="prompt">&gt;</span>
<input
ref="inputRef"
v-model="input"
class="cli-input"
placeholder="ask me anything..."
spellcheck="false"
autocomplete="off"
@keydown.enter="handleSubmit"
/>
<span class="cursor-blink"></span>
</div>
</div>
</template>
<style scoped>
.cli-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
}
.cli-history {
background: rgba(22, 33, 62, 0.95);
border-top: 1px solid rgba(0, 210, 255, 0.2);
max-height: 200px;
overflow-y: auto;
padding: 12px 20px;
backdrop-filter: blur(10px);
}
.history-entry {
font-family: 'VT323', monospace;
font-size: 16px;
line-height: 1.6;
color: #8892b0;
}
.history-entry.input {
color: #e8e8e8;
}
.history-entry.response {
color: #00ff88;
}
.prompt-char {
color: #00d2ff;
}
.ai-label {
color: #7b68ee;
}
.cli-bar {
display: flex;
align-items: center;
background: #0a0a1a;
border-top: 2px solid #00d2ff;
padding: 12px 20px;
box-shadow: 0 -4px 30px rgba(0, 210, 255, 0.15);
}
.prompt {
font-family: 'Press Start 2P', monospace;
font-size: 12px;
color: #00d2ff;
margin-right: 12px;
text-shadow: 0 0 10px rgba(0, 210, 255, 0.5);
}
.cli-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-family: 'VT323', monospace;
font-size: 18px;
color: #e8e8e8;
caret-color: transparent;
}
.cli-input::placeholder {
color: #8892b0;
opacity: 0.5;
}
.cursor-blink {
font-family: 'VT323', monospace;
font-size: 18px;
color: #00d2ff;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.slide-up-enter-active, .slide-up-leave-active {
transition: all 0.25s ease;
}
.slide-up-enter-from, .slide-up-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup>
defineProps({
path: { type: Array, default: () => [] },
})
const emit = defineEmits(['navigate'])
</script>
<template>
<div class="breadcrumb">
<span
v-for="(item, index) in path"
:key="index"
class="breadcrumb-item"
:class="{ active: index === path.length - 1 }"
@click="index < path.length - 1 && emit('navigate', item, index)"
>
<span v-if="index > 0" class="separator"> &gt; </span>
<span :class="{ 'cursor-pointer hover:text-[#00d2ff]': index < path.length - 1 }">
{{ item.label }}
</span>
</span>
</div>
</template>
<style scoped>
.breadcrumb {
position: fixed;
top: 12px;
left: 16px;
z-index: 50;
font-family: 'VT323', monospace;
font-size: 16px;
color: #8892b0;
background: rgba(26, 26, 46, 0.85);
padding: 6px 14px;
border-radius: 4px;
border: 1px solid rgba(0, 210, 255, 0.15);
backdrop-filter: blur(8px);
}
.breadcrumb-item.active {
color: #00d2ff;
}
.separator {
color: #8892b0;
opacity: 0.5;
margin: 0 2px;
}
</style>

View File

@@ -0,0 +1,354 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import * as d3 from 'd3'
const props = defineProps({
nodes: { type: Array, default: () => [] },
lines: { type: Array, default: () => [] },
connections: { type: Array, default: () => [] },
currentLevel: { type: Number, default: 1 },
})
const emit = defineEmits(['node-click', 'node-hover', 'node-leave', 'zoom-change'])
const svgRef = ref(null)
const containerRef = ref(null)
const transform = ref(d3.zoomIdentity)
const hoveredNode = ref(null)
// Metro line colors
const lineColors = [
'#00d2ff', '#e94560', '#00ff88', '#7b68ee',
'#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff',
'#ff8fab', '#a8dadc', '#e07a5f', '#81b29a',
]
const getLineColor = (index) => lineColors[index % lineColors.length]
let svg, g, zoom
const initCanvas = () => {
if (!svgRef.value) return
const width = containerRef.value.clientWidth
const height = containerRef.value.clientHeight
svg = d3.select(svgRef.value)
.attr('width', width)
.attr('height', height)
// Clear previous content
svg.selectAll('*').remove()
// Add defs for glow filter
const defs = svg.append('defs')
const glowFilter = defs.append('filter')
.attr('id', 'glow')
.attr('x', '-50%')
.attr('y', '-50%')
.attr('width', '200%')
.attr('height', '200%')
glowFilter.append('feGaussianBlur')
.attr('stdDeviation', '4')
.attr('result', 'coloredBlur')
const feMerge = glowFilter.append('feMerge')
feMerge.append('feMergeNode').attr('in', 'coloredBlur')
feMerge.append('feMergeNode').attr('in', 'SourceGraphic')
// Scanline pattern
const scanlinePattern = defs.append('pattern')
.attr('id', 'scanlines')
.attr('patternUnits', 'userSpaceOnUse')
.attr('width', 4)
.attr('height', 4)
scanlinePattern.append('line')
.attr('x1', 0).attr('y1', 0)
.attr('x2', 4).attr('y2', 0)
.attr('stroke', 'rgba(0,0,0,0.08)')
.attr('stroke-width', 1)
// Main group for zoom/pan
g = svg.append('g')
// Setup zoom
zoom = d3.zoom()
.scaleExtent([0.3, 5])
.on('zoom', (event) => {
g.attr('transform', event.transform)
transform.value = event.transform
emit('zoom-change', {
scale: event.transform.k,
x: event.transform.x,
y: event.transform.y,
})
})
svg.call(zoom)
// Center the view
const initialTransform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(1)
svg.call(zoom.transform, initialTransform)
renderMap()
}
const renderMap = () => {
if (!g) return
g.selectAll('*').remove()
// Draw metro lines
props.lines.forEach((line, lineIndex) => {
const color = line.color || getLineColor(lineIndex)
const lineNodes = props.nodes.filter(n => n.lineId === line.id)
if (lineNodes.length < 2) return
// Sort nodes by order
lineNodes.sort((a, b) => a.order - b.order)
// Draw the line path
const lineGenerator = d3.line()
.x(d => d.x)
.y(d => d.y)
.curve(d3.curveMonotoneX)
g.append('path')
.datum(lineNodes)
.attr('d', lineGenerator)
.attr('fill', 'none')
.attr('stroke', color)
.attr('stroke-width', 4)
.attr('stroke-linecap', 'round')
.attr('opacity', 0.7)
// Line label
if (lineNodes.length > 0) {
g.append('text')
.attr('x', lineNodes[0].x - 10)
.attr('y', lineNodes[0].y - 25)
.attr('fill', color)
.attr('font-family', "'VT323', monospace")
.attr('font-size', '16px')
.attr('opacity', 0.8)
.text(line.name)
}
})
// Draw dependency connections
props.connections.forEach(conn => {
const source = props.nodes.find(n => n.id === conn.from)
const target = props.nodes.find(n => n.id === conn.to)
if (!source || !target) return
g.append('line')
.attr('x1', source.x)
.attr('y1', source.y)
.attr('x2', target.x)
.attr('y2', target.y)
.attr('stroke', '#8892b0')
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', '6,4')
.attr('opacity', 0.4)
})
// Draw station nodes
const nodeGroups = g.selectAll('.station')
.data(props.nodes)
.enter()
.append('g')
.attr('class', 'station')
.attr('transform', d => `translate(${d.x}, ${d.y})`)
.attr('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation()
emit('node-click', d)
})
.on('mouseenter', (event, d) => {
hoveredNode.value = d
d3.select(event.currentTarget).select('.station-dot')
.transition()
.duration(200)
.attr('r', 12)
.attr('filter', 'url(#glow)')
emit('node-hover', d)
})
.on('mouseleave', (event, d) => {
hoveredNode.value = null
d3.select(event.currentTarget).select('.station-dot')
.transition()
.duration(200)
.attr('r', 8)
.attr('filter', null)
emit('node-leave', d)
})
// Station outer ring
nodeGroups.append('circle')
.attr('r', 11)
.attr('fill', 'none')
.attr('stroke', d => {
const line = props.lines.find(l => l.id === d.lineId)
return line?.color || getLineColor(props.lines.indexOf(line))
})
.attr('stroke-width', 2)
// Station inner dot
nodeGroups.append('circle')
.attr('class', 'station-dot')
.attr('r', 8)
.attr('fill', d => {
if (d.status === 'afgerond' || d.status === 'completed') return '#00ff88'
if (d.status === 'actief' || d.status === 'active') return '#00d2ff'
if (d.status === 'geparkeerd') return '#ffd93d'
if (d.status === 'gestopt') return '#e94560'
return '#16213e'
})
.attr('stroke', d => {
const line = props.lines.find(l => l.id === d.lineId)
return line?.color || getLineColor(props.lines.indexOf(line))
})
.attr('stroke-width', 2)
// Station labels
nodeGroups.append('text')
.attr('x', 18)
.attr('y', 5)
.attr('fill', '#e8e8e8')
.attr('font-family', "'VT323', monospace")
.attr('font-size', '14px')
.text(d => d.name)
// Status badge
nodeGroups.filter(d => d.badge)
.append('text')
.attr('x', 18)
.attr('y', 20)
.attr('fill', '#8892b0')
.attr('font-family', "'IBM Plex Mono', monospace")
.attr('font-size', '10px')
.text(d => d.badge)
}
const handleResize = () => {
if (svgRef.value && containerRef.value) {
svg.attr('width', containerRef.value.clientWidth)
.attr('height', containerRef.value.clientHeight)
}
}
watch(() => [props.nodes, props.lines, props.connections], () => {
renderMap()
}, { deep: true })
onMounted(() => {
initCanvas()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// Expose zoom methods
const zoomTo = (x, y, scale) => {
if (!svg || !zoom) return
const transform = d3.zoomIdentity.translate(x, y).scale(scale)
svg.transition().duration(500).call(zoom.transform, transform)
}
defineExpose({ zoomTo })
</script>
<template>
<div ref="containerRef" class="metro-canvas-container">
<svg ref="svgRef" class="metro-canvas"></svg>
<!-- Hover tooltip -->
<Transition name="fade">
<div
v-if="hoveredNode"
class="node-tooltip"
:style="{
left: `${hoveredNode.x + transform.x + 30}px`,
top: `${hoveredNode.y + transform.y - 20}px`,
}"
>
<div class="tooltip-title">{{ hoveredNode.name }}</div>
<div v-if="hoveredNode.description" class="tooltip-desc">{{ hoveredNode.description }}</div>
<div v-if="hoveredNode.status" class="tooltip-status">{{ hoveredNode.status }}</div>
<div class="tooltip-hint">Click to zoom in</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.metro-canvas-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #1a1a2e;
}
.metro-canvas {
display: block;
width: 100%;
height: 100%;
}
.node-tooltip {
position: absolute;
pointer-events: none;
background: #16213e;
border: 1px solid #00d2ff;
border-radius: 4px;
padding: 8px 12px;
z-index: 100;
box-shadow: 0 0 15px rgba(0, 210, 255, 0.2);
max-width: 250px;
}
.tooltip-title {
font-family: 'VT323', monospace;
font-size: 16px;
color: #00d2ff;
}
.tooltip-desc {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #8892b0;
margin-top: 4px;
}
.tooltip-status {
font-family: 'VT323', monospace;
font-size: 13px;
color: #00ff88;
margin-top: 4px;
}
.tooltip-hint {
font-family: 'VT323', monospace;
font-size: 11px;
color: #8892b0;
margin-top: 6px;
opacity: 0.6;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.15s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,147 @@
<script setup>
defineProps({
node: { type: Object, default: null },
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'zoom-in'])
</script>
<template>
<Transition name="slide">
<div v-if="visible && node" class="node-preview">
<div class="preview-header">
<h2 class="preview-title">{{ node.name }}</h2>
<button @click="emit('close')" class="close-btn">[X]</button>
</div>
<div class="preview-meta">
<span v-if="node.status" class="status-badge" :class="node.status">
{{ node.status }}
</span>
<span v-if="node.owner" class="owner">{{ node.owner }}</span>
</div>
<p v-if="node.description" class="preview-desc">{{ node.description }}</p>
<div v-if="node.children" class="preview-children">
<div class="children-label">Contains {{ node.children }} items</div>
</div>
<button @click="emit('zoom-in', node)" class="zoom-btn">
ZOOM IN &gt;&gt;
</button>
</div>
</Transition>
</template>
<style scoped>
.node-preview {
position: fixed;
right: 16px;
top: 60px;
width: 320px;
background: #16213e;
border: 1px solid rgba(0, 210, 255, 0.3);
border-radius: 6px;
padding: 20px;
z-index: 60;
box-shadow: 0 0 30px rgba(0, 210, 255, 0.1);
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.preview-title {
font-family: 'VT323', monospace;
font-size: 22px;
color: #00d2ff;
margin: 0;
}
.close-btn {
font-family: 'VT323', monospace;
color: #8892b0;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
}
.close-btn:hover {
color: #e94560;
}
.preview-meta {
display: flex;
gap: 8px;
margin-top: 8px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
.status-badge {
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.actief, .status-badge.active { background: rgba(0, 210, 255, 0.15); color: #00d2ff; }
.status-badge.afgerond, .status-badge.completed { background: rgba(0, 255, 136, 0.15); color: #00ff88; }
.status-badge.geparkeerd { background: rgba(255, 217, 61, 0.15); color: #ffd93d; }
.status-badge.gestopt { background: rgba(233, 69, 96, 0.15); color: #e94560; }
.owner {
color: #8892b0;
}
.preview-desc {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
color: #8892b0;
margin-top: 12px;
line-height: 1.5;
}
.preview-children {
margin-top: 12px;
}
.children-label {
font-family: 'VT323', monospace;
font-size: 14px;
color: #7b68ee;
}
.zoom-btn {
margin-top: 16px;
width: 100%;
padding: 8px;
background: rgba(0, 210, 255, 0.1);
border: 1px solid #00d2ff;
color: #00d2ff;
font-family: 'VT323', monospace;
font-size: 16px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.zoom-btn:hover {
background: rgba(0, 210, 255, 0.2);
box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
}
.slide-enter-active, .slide-leave-active {
transition: all 0.25s ease;
}
.slide-enter-from, .slide-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>

View File

@@ -0,0 +1,346 @@
<script setup>
import { useForm, Link } from '@inertiajs/vue3'
const props = defineProps({
status: String,
})
const form = useForm({
email: '',
})
const submit = () => {
form.post('/forgot-password')
}
</script>
<template>
<div class="auth-screen">
<div class="scanlines" />
<div class="terminal-frame">
<div class="terminal-header">
<span class="header-dot red" />
<span class="header-dot yellow" />
<span class="header-dot green" />
<span class="header-title">AUTH.SYS v2.0</span>
</div>
<div class="terminal-body">
<div class="prompt-line">
<span class="prompt-symbol">&gt;</span>
<span class="prompt-text">PASSWORD RECOVERY</span>
<span class="cursor" />
</div>
<h1 class="page-title">FORGOT<br>PASSWORD</h1>
<div class="divider">
<span></span>
</div>
<p class="info-text">
ENTER YOUR EMAIL ADDRESS AND A RECOVERY LINK WILL BE TRANSMITTED.
</p>
<div v-if="status" class="status-msg">
<span class="status-prefix">OK:</span> {{ status }}
</div>
<form @submit.prevent="submit" class="auth-form">
<div class="field-group">
<label class="field-label" for="email">
<span class="label-prefix">[01]</span> EMAIL_ADDRESS:
</label>
<input
id="email"
v-model="form.email"
type="email"
class="field-input"
:class="{ 'field-error': form.errors.email }"
autocomplete="username"
placeholder="user@domain.nl"
autofocus
/>
<p v-if="form.errors.email" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
</p>
</div>
<button
type="submit"
class="submit-btn"
:disabled="form.processing"
>
<span v-if="form.processing">TRANSMITTING...</span>
<span v-else>&gt;&gt; SEND RECOVERY LINK</span>
</button>
</form>
<div class="divider">
<span></span>
</div>
<div class="links">
<Link href="/login" class="auth-link">
&lt;&lt; BACK TO LOGIN
</Link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.auth-screen {
width: 100vw;
height: 100vh;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.08) 2px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 1;
}
.terminal-frame {
position: relative;
z-index: 2;
width: 480px;
background: #0d0d1a;
border: 2px solid #00d2ff;
box-shadow:
0 0 40px rgba(0, 210, 255, 0.25),
inset 0 0 40px rgba(0, 210, 255, 0.03);
}
.terminal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(0, 210, 255, 0.08);
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
.header-title {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #8892b0;
margin-left: auto;
letter-spacing: 2px;
}
.terminal-body {
padding: 32px 40px;
}
.prompt-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.prompt-symbol {
font-family: 'VT323', monospace;
font-size: 20px;
color: #00ff88;
}
.prompt-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8892b0;
letter-spacing: 2px;
}
.cursor {
display: inline-block;
width: 10px;
height: 16px;
background: #00d2ff;
animation: blink 1s step-end infinite;
margin-left: 4px;
}
@keyframes blink {
50% { opacity: 0; }
}
.page-title {
font-family: 'Press Start 2P', monospace;
font-size: 18px;
color: #00d2ff;
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
letter-spacing: 3px;
line-height: 1.6;
margin: 0 0 16px 0;
}
.divider {
font-family: 'VT323', monospace;
font-size: 16px;
color: rgba(0, 210, 255, 0.3);
margin: 16px 0;
overflow: hidden;
}
.info-text {
font-family: 'VT323', monospace;
font-size: 17px;
color: #8892b0;
line-height: 1.5;
margin: 0 0 20px 0;
letter-spacing: 0.5px;
}
.status-msg {
font-family: 'VT323', monospace;
font-size: 17px;
color: #00ff88;
background: rgba(0, 255, 136, 0.08);
border: 1px solid rgba(0, 255, 136, 0.3);
padding: 10px 14px;
margin-bottom: 20px;
}
.status-prefix {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: bold;
margin-right: 6px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-family: 'VT323', monospace;
font-size: 18px;
color: #8892b0;
letter-spacing: 1px;
}
.label-prefix {
color: #00d2ff;
margin-right: 4px;
}
.field-input {
background: rgba(0, 210, 255, 0.04);
border: 1px solid rgba(0, 210, 255, 0.4);
color: #e8e8e8;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
padding: 10px 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
box-sizing: border-box;
}
.field-input::placeholder {
color: rgba(136, 146, 176, 0.4);
}
.field-input:focus {
border-color: #00d2ff;
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
}
.field-input.field-error {
border-color: #e94560;
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
}
.error-msg {
font-family: 'VT323', monospace;
font-size: 16px;
color: #e94560;
margin: 0;
}
.error-prefix {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: bold;
margin-right: 4px;
}
.submit-btn {
padding: 14px 24px;
background: rgba(0, 210, 255, 0.08);
border: 2px solid #00d2ff;
color: #00d2ff;
font-family: 'Press Start 2P', monospace;
font-size: 11px;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
width: 100%;
}
.submit-btn:hover:not(:disabled) {
background: rgba(0, 210, 255, 0.18);
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links {
text-align: center;
font-family: 'VT323', monospace;
font-size: 18px;
}
.auth-link {
color: #8892b0;
text-decoration: none;
letter-spacing: 1px;
transition: color 0.2s;
}
.auth-link:hover {
color: #00d2ff;
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
}
</style>

View File

@@ -0,0 +1,377 @@
<script setup>
import { useForm, Link } from '@inertiajs/vue3'
const form = useForm({
email: '',
password: '',
remember: false,
})
const submit = () => {
form.post('/login', {
onFinish: () => form.reset('password'),
})
}
</script>
<template>
<div class="auth-screen">
<div class="scanlines" />
<div class="terminal-frame">
<div class="terminal-header">
<span class="header-dot red" />
<span class="header-dot yellow" />
<span class="header-dot green" />
<span class="header-title">AUTH.SYS v2.0</span>
</div>
<div class="terminal-body">
<div class="prompt-line">
<span class="prompt-symbol">&gt;</span>
<span class="prompt-text">SYSTEM LOGIN</span>
<span class="cursor" />
</div>
<h1 class="page-title">LOGIN</h1>
<div class="divider">
<span></span>
</div>
<form @submit.prevent="submit" class="auth-form">
<div class="field-group">
<label class="field-label" for="email">
<span class="label-prefix">[01]</span> EMAIL_ADDRESS:
</label>
<input
id="email"
v-model="form.email"
type="email"
class="field-input"
:class="{ 'field-error': form.errors.email }"
autocomplete="username"
placeholder="user@domain.nl"
autofocus
/>
<p v-if="form.errors.email" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
</p>
</div>
<div class="field-group">
<label class="field-label" for="password">
<span class="label-prefix">[02]</span> PASSWORD:
</label>
<input
id="password"
v-model="form.password"
type="password"
class="field-input"
:class="{ 'field-error': form.errors.password }"
autocomplete="current-password"
placeholder="••••••••••••"
/>
<p v-if="form.errors.password" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.password }}
</p>
</div>
<div class="field-group remember-group">
<label class="checkbox-label">
<input
v-model="form.remember"
type="checkbox"
class="checkbox-input"
/>
<span class="checkbox-text">REMEMBER SESSION</span>
</label>
</div>
<button
type="submit"
class="submit-btn"
:disabled="form.processing"
>
<span v-if="form.processing">AUTHENTICATING...</span>
<span v-else>&gt;&gt; AUTHENTICATE</span>
</button>
</form>
<div class="divider">
<span></span>
</div>
<div class="links">
<Link href="/forgot-password" class="auth-link">
FORGOT PASSWORD?
</Link>
<span class="link-separator"> // </span>
<Link href="/register" class="auth-link">
NEW USER? REGISTER
</Link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.auth-screen {
width: 100vw;
height: 100vh;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.08) 2px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 1;
}
.terminal-frame {
position: relative;
z-index: 2;
width: 480px;
background: #0d0d1a;
border: 2px solid #00d2ff;
box-shadow:
0 0 40px rgba(0, 210, 255, 0.25),
inset 0 0 40px rgba(0, 210, 255, 0.03);
}
.terminal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(0, 210, 255, 0.08);
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
.header-title {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #8892b0;
margin-left: auto;
letter-spacing: 2px;
}
.terminal-body {
padding: 32px 40px;
}
.prompt-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.prompt-symbol {
font-family: 'VT323', monospace;
font-size: 20px;
color: #00ff88;
}
.prompt-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8892b0;
letter-spacing: 2px;
}
.cursor {
display: inline-block;
width: 10px;
height: 16px;
background: #00d2ff;
animation: blink 1s step-end infinite;
margin-left: 4px;
}
@keyframes blink {
50% { opacity: 0; }
}
.page-title {
font-family: 'Press Start 2P', monospace;
font-size: 22px;
color: #00d2ff;
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
letter-spacing: 4px;
margin: 0 0 16px 0;
}
.divider {
font-family: 'VT323', monospace;
font-size: 16px;
color: rgba(0, 210, 255, 0.3);
margin: 16px 0;
overflow: hidden;
}
.auth-form {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-family: 'VT323', monospace;
font-size: 18px;
color: #8892b0;
letter-spacing: 1px;
}
.label-prefix {
color: #00d2ff;
margin-right: 4px;
}
.field-input {
background: rgba(0, 210, 255, 0.04);
border: 1px solid rgba(0, 210, 255, 0.4);
color: #e8e8e8;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
padding: 10px 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
box-sizing: border-box;
}
.field-input::placeholder {
color: rgba(136, 146, 176, 0.4);
}
.field-input:focus {
border-color: #00d2ff;
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
}
.field-input.field-error {
border-color: #e94560;
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
}
.error-msg {
font-family: 'VT323', monospace;
font-size: 16px;
color: #e94560;
margin: 0;
}
.error-prefix {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: bold;
margin-right: 4px;
}
.remember-group {
flex-direction: row;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.checkbox-input {
width: 14px;
height: 14px;
accent-color: #00d2ff;
cursor: pointer;
}
.checkbox-text {
font-family: 'VT323', monospace;
font-size: 18px;
color: #8892b0;
letter-spacing: 1px;
}
.submit-btn {
margin-top: 8px;
padding: 14px 24px;
background: rgba(0, 210, 255, 0.08);
border: 2px solid #00d2ff;
color: #00d2ff;
font-family: 'Press Start 2P', monospace;
font-size: 11px;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
width: 100%;
}
.submit-btn:hover:not(:disabled) {
background: rgba(0, 210, 255, 0.18);
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links {
text-align: center;
font-family: 'VT323', monospace;
font-size: 18px;
}
.auth-link {
color: #8892b0;
text-decoration: none;
letter-spacing: 1px;
transition: color 0.2s;
}
.auth-link:hover {
color: #00d2ff;
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
}
.link-separator {
color: rgba(0, 210, 255, 0.3);
margin: 0 4px;
}
</style>

View File

@@ -0,0 +1,380 @@
<script setup>
import { useForm, Link } from '@inertiajs/vue3'
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
})
const submit = () => {
form.post('/register', {
onFinish: () => form.reset('password', 'password_confirmation'),
})
}
</script>
<template>
<div class="auth-screen">
<div class="scanlines" />
<div class="terminal-frame">
<div class="terminal-header">
<span class="header-dot red" />
<span class="header-dot yellow" />
<span class="header-dot green" />
<span class="header-title">AUTH.SYS v2.0</span>
</div>
<div class="terminal-body">
<div class="prompt-line">
<span class="prompt-symbol">&gt;</span>
<span class="prompt-text">NEW USER REGISTRATION</span>
<span class="cursor" />
</div>
<h1 class="page-title">REGISTER</h1>
<div class="divider">
<span></span>
</div>
<form @submit.prevent="submit" class="auth-form">
<div class="field-group">
<label class="field-label" for="name">
<span class="label-prefix">[01]</span> DISPLAY_NAME:
</label>
<input
id="name"
v-model="form.name"
type="text"
class="field-input"
:class="{ 'field-error': form.errors.name }"
autocomplete="name"
placeholder="Voornaam Achternaam"
autofocus
/>
<p v-if="form.errors.name" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.name }}
</p>
</div>
<div class="field-group">
<label class="field-label" for="email">
<span class="label-prefix">[02]</span> EMAIL_ADDRESS:
</label>
<input
id="email"
v-model="form.email"
type="email"
class="field-input"
:class="{ 'field-error': form.errors.email }"
autocomplete="username"
placeholder="user@domain.nl"
/>
<p v-if="form.errors.email" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
</p>
</div>
<div class="field-group">
<label class="field-label" for="password">
<span class="label-prefix">[03]</span> PASSWORD:
</label>
<input
id="password"
v-model="form.password"
type="password"
class="field-input"
:class="{ 'field-error': form.errors.password }"
autocomplete="new-password"
placeholder="••••••••••••"
/>
<p v-if="form.errors.password" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.password }}
</p>
</div>
<div class="field-group">
<label class="field-label" for="password_confirmation">
<span class="label-prefix">[04]</span> CONFIRM_PASSWORD:
</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
class="field-input"
:class="{ 'field-error': form.errors.password_confirmation }"
autocomplete="new-password"
placeholder="••••••••••••"
/>
<p v-if="form.errors.password_confirmation" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.password_confirmation }}
</p>
</div>
<button
type="submit"
class="submit-btn"
:disabled="form.processing"
>
<span v-if="form.processing">CREATING ACCOUNT...</span>
<span v-else>&gt;&gt; CREATE ACCOUNT</span>
</button>
</form>
<div class="divider">
<span></span>
</div>
<div class="links">
<span class="links-text">ALREADY REGISTERED?</span>
<span class="link-separator"> // </span>
<Link href="/login" class="auth-link">
LOGIN HERE
</Link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.auth-screen {
width: 100vw;
height: 100vh;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.08) 2px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 1;
}
.terminal-frame {
position: relative;
z-index: 2;
width: 480px;
background: #0d0d1a;
border: 2px solid #00d2ff;
box-shadow:
0 0 40px rgba(0, 210, 255, 0.25),
inset 0 0 40px rgba(0, 210, 255, 0.03);
}
.terminal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(0, 210, 255, 0.08);
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
.header-title {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #8892b0;
margin-left: auto;
letter-spacing: 2px;
}
.terminal-body {
padding: 28px 40px 32px;
}
.prompt-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.prompt-symbol {
font-family: 'VT323', monospace;
font-size: 20px;
color: #00ff88;
}
.prompt-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8892b0;
letter-spacing: 2px;
}
.cursor {
display: inline-block;
width: 10px;
height: 16px;
background: #00d2ff;
animation: blink 1s step-end infinite;
margin-left: 4px;
}
@keyframes blink {
50% { opacity: 0; }
}
.page-title {
font-family: 'Press Start 2P', monospace;
font-size: 20px;
color: #00d2ff;
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
letter-spacing: 4px;
margin: 0 0 16px 0;
}
.divider {
font-family: 'VT323', monospace;
font-size: 16px;
color: rgba(0, 210, 255, 0.3);
margin: 16px 0;
overflow: hidden;
}
.auth-form {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 18px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-family: 'VT323', monospace;
font-size: 18px;
color: #8892b0;
letter-spacing: 1px;
}
.label-prefix {
color: #00d2ff;
margin-right: 4px;
}
.field-input {
background: rgba(0, 210, 255, 0.04);
border: 1px solid rgba(0, 210, 255, 0.4);
color: #e8e8e8;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
padding: 10px 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
box-sizing: border-box;
}
.field-input::placeholder {
color: rgba(136, 146, 176, 0.4);
}
.field-input:focus {
border-color: #00d2ff;
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
}
.field-input.field-error {
border-color: #e94560;
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
}
.error-msg {
font-family: 'VT323', monospace;
font-size: 16px;
color: #e94560;
margin: 0;
}
.error-prefix {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: bold;
margin-right: 4px;
}
.submit-btn {
margin-top: 8px;
padding: 14px 24px;
background: rgba(0, 210, 255, 0.08);
border: 2px solid #00d2ff;
color: #00d2ff;
font-family: 'Press Start 2P', monospace;
font-size: 11px;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
width: 100%;
}
.submit-btn:hover:not(:disabled) {
background: rgba(0, 210, 255, 0.18);
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links {
text-align: center;
font-family: 'VT323', monospace;
font-size: 18px;
}
.links-text {
color: #8892b0;
letter-spacing: 1px;
}
.auth-link {
color: #8892b0;
text-decoration: none;
letter-spacing: 1px;
transition: color 0.2s;
}
.auth-link:hover {
color: #00d2ff;
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
}
.link-separator {
color: rgba(0, 210, 255, 0.3);
margin: 0 4px;
}
</style>

View File

@@ -0,0 +1,368 @@
<script setup>
import { useForm, Link } from '@inertiajs/vue3'
const props = defineProps({
token: {
type: String,
required: true,
},
email: {
type: String,
required: true,
},
})
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
})
const submit = () => {
form.post('/reset-password', {
onFinish: () => form.reset('password', 'password_confirmation'),
})
}
</script>
<template>
<div class="auth-screen">
<div class="scanlines" />
<div class="terminal-frame">
<div class="terminal-header">
<span class="header-dot red" />
<span class="header-dot yellow" />
<span class="header-dot green" />
<span class="header-title">AUTH.SYS v2.0</span>
</div>
<div class="terminal-body">
<div class="prompt-line">
<span class="prompt-symbol">&gt;</span>
<span class="prompt-text">RESET CREDENTIALS</span>
<span class="cursor" />
</div>
<h1 class="page-title">RESET<br>PASSWORD</h1>
<div class="divider">
<span></span>
</div>
<form @submit.prevent="submit" class="auth-form">
<div class="field-group">
<label class="field-label" for="email">
<span class="label-prefix">[01]</span> EMAIL_ADDRESS:
</label>
<input
id="email"
v-model="form.email"
type="email"
class="field-input"
:class="{ 'field-error': form.errors.email }"
autocomplete="username"
readonly
/>
<p v-if="form.errors.email" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
</p>
</div>
<div class="field-group">
<label class="field-label" for="password">
<span class="label-prefix">[02]</span> NEW_PASSWORD:
</label>
<input
id="password"
v-model="form.password"
type="password"
class="field-input"
:class="{ 'field-error': form.errors.password }"
autocomplete="new-password"
placeholder="••••••••••••"
autofocus
/>
<p v-if="form.errors.password" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.password }}
</p>
</div>
<div class="field-group">
<label class="field-label" for="password_confirmation">
<span class="label-prefix">[03]</span> CONFIRM_PASSWORD:
</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
class="field-input"
:class="{ 'field-error': form.errors.password_confirmation }"
autocomplete="new-password"
placeholder="••••••••••••"
/>
<p v-if="form.errors.password_confirmation" class="error-msg">
<span class="error-prefix">ERR:</span> {{ form.errors.password_confirmation }}
</p>
</div>
<button
type="submit"
class="submit-btn"
:disabled="form.processing"
>
<span v-if="form.processing">UPDATING...</span>
<span v-else>&gt;&gt; RESET PASSWORD</span>
</button>
</form>
<div class="divider">
<span></span>
</div>
<div class="links">
<Link href="/login" class="auth-link">
&lt;&lt; BACK TO LOGIN
</Link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.auth-screen {
width: 100vw;
height: 100vh;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.08) 2px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 1;
}
.terminal-frame {
position: relative;
z-index: 2;
width: 480px;
background: #0d0d1a;
border: 2px solid #00d2ff;
box-shadow:
0 0 40px rgba(0, 210, 255, 0.25),
inset 0 0 40px rgba(0, 210, 255, 0.03);
}
.terminal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(0, 210, 255, 0.08);
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
.header-title {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #8892b0;
margin-left: auto;
letter-spacing: 2px;
}
.terminal-body {
padding: 32px 40px;
}
.prompt-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.prompt-symbol {
font-family: 'VT323', monospace;
font-size: 20px;
color: #00ff88;
}
.prompt-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8892b0;
letter-spacing: 2px;
}
.cursor {
display: inline-block;
width: 10px;
height: 16px;
background: #00d2ff;
animation: blink 1s step-end infinite;
margin-left: 4px;
}
@keyframes blink {
50% { opacity: 0; }
}
.page-title {
font-family: 'Press Start 2P', monospace;
font-size: 18px;
color: #00d2ff;
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
letter-spacing: 3px;
line-height: 1.6;
margin: 0 0 16px 0;
}
.divider {
font-family: 'VT323', monospace;
font-size: 16px;
color: rgba(0, 210, 255, 0.3);
margin: 16px 0;
overflow: hidden;
}
.auth-form {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-family: 'VT323', monospace;
font-size: 18px;
color: #8892b0;
letter-spacing: 1px;
}
.label-prefix {
color: #00d2ff;
margin-right: 4px;
}
.field-input {
background: rgba(0, 210, 255, 0.04);
border: 1px solid rgba(0, 210, 255, 0.4);
color: #e8e8e8;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
padding: 10px 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
box-sizing: border-box;
}
.field-input[readonly] {
color: #8892b0;
border-color: rgba(0, 210, 255, 0.2);
cursor: not-allowed;
}
.field-input::placeholder {
color: rgba(136, 146, 176, 0.4);
}
.field-input:focus:not([readonly]) {
border-color: #00d2ff;
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
}
.field-input.field-error {
border-color: #e94560;
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
}
.error-msg {
font-family: 'VT323', monospace;
font-size: 16px;
color: #e94560;
margin: 0;
}
.error-prefix {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: bold;
margin-right: 4px;
}
.submit-btn {
margin-top: 8px;
padding: 14px 24px;
background: rgba(0, 210, 255, 0.08);
border: 2px solid #00d2ff;
color: #00d2ff;
font-family: 'Press Start 2P', monospace;
font-size: 11px;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
width: 100%;
}
.submit-btn:hover:not(:disabled) {
background: rgba(0, 210, 255, 0.18);
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links {
text-align: center;
font-family: 'VT323', monospace;
font-size: 18px;
}
.auth-link {
color: #8892b0;
text-decoration: none;
letter-spacing: 1px;
transition: color 0.2s;
}
.auth-link:hover {
color: #00d2ff;
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
}
</style>

View File

@@ -0,0 +1,344 @@
<script setup>
import { useForm, router } from '@inertiajs/vue3'
const props = defineProps({
status: String,
})
const form = useForm({})
const resend = () => {
form.post('/email/verification-notification')
}
const logout = () => {
router.post('/logout')
}
</script>
<template>
<div class="auth-screen">
<div class="scanlines" />
<div class="terminal-frame">
<div class="terminal-header">
<span class="header-dot red" />
<span class="header-dot yellow" />
<span class="header-dot green" />
<span class="header-title">AUTH.SYS v2.0</span>
</div>
<div class="terminal-body">
<div class="prompt-line">
<span class="prompt-symbol">&gt;</span>
<span class="prompt-text">EMAIL VERIFICATION</span>
<span class="cursor" />
</div>
<h1 class="page-title">VERIFY<br>EMAIL</h1>
<div class="divider">
<span></span>
</div>
<div class="info-block">
<div class="info-line">
<span class="info-icon">[!]</span>
<p class="info-text">
ACCOUNT ACTIVATION REQUIRED. A VERIFICATION LINK HAS BEEN TRANSMITTED TO YOUR EMAIL ADDRESS.
</p>
</div>
<p class="info-sub">
CHECK YOUR INBOX AND CLICK THE LINK TO ACTIVATE YOUR ACCOUNT.
</p>
</div>
<div v-if="status === 'verification-link-sent'" class="status-msg">
<span class="status-prefix">OK:</span> NEW VERIFICATION LINK TRANSMITTED.
</div>
<div class="actions">
<button
@click="resend"
class="submit-btn"
:disabled="form.processing"
>
<span v-if="form.processing">TRANSMITTING...</span>
<span v-else>&gt;&gt; RESEND VERIFICATION</span>
</button>
<button
@click="logout"
class="logout-btn"
>
LOGOUT
</button>
</div>
<div class="divider">
<span></span>
</div>
<div class="status-line">
<span class="status-label">SESSION STATUS:</span>
<span class="status-value blink-slow">AWAITING VERIFICATION</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.auth-screen {
width: 100vw;
height: 100vh;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.08) 2px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 1;
}
.terminal-frame {
position: relative;
z-index: 2;
width: 480px;
background: #0d0d1a;
border: 2px solid #00d2ff;
box-shadow:
0 0 40px rgba(0, 210, 255, 0.25),
inset 0 0 40px rgba(0, 210, 255, 0.03);
}
.terminal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(0, 210, 255, 0.08);
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
.header-title {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #8892b0;
margin-left: auto;
letter-spacing: 2px;
}
.terminal-body {
padding: 32px 40px;
}
.prompt-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.prompt-symbol {
font-family: 'VT323', monospace;
font-size: 20px;
color: #00ff88;
}
.prompt-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8892b0;
letter-spacing: 2px;
}
.cursor {
display: inline-block;
width: 10px;
height: 16px;
background: #00d2ff;
animation: blink 1s step-end infinite;
margin-left: 4px;
}
@keyframes blink {
50% { opacity: 0; }
}
.page-title {
font-family: 'Press Start 2P', monospace;
font-size: 18px;
color: #00d2ff;
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
letter-spacing: 3px;
line-height: 1.6;
margin: 0 0 16px 0;
}
.divider {
font-family: 'VT323', monospace;
font-size: 16px;
color: rgba(0, 210, 255, 0.3);
margin: 16px 0;
overflow: hidden;
}
.info-block {
margin: 20px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.info-line {
display: flex;
gap: 10px;
align-items: flex-start;
}
.info-icon {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
color: #f5a623;
flex-shrink: 0;
padding-top: 2px;
}
.info-text {
font-family: 'VT323', monospace;
font-size: 18px;
color: #e8e8e8;
line-height: 1.4;
margin: 0;
letter-spacing: 0.5px;
}
.info-sub {
font-family: 'VT323', monospace;
font-size: 16px;
color: #8892b0;
margin: 0;
padding-left: 28px;
letter-spacing: 0.5px;
}
.status-msg {
font-family: 'VT323', monospace;
font-size: 17px;
color: #00ff88;
background: rgba(0, 255, 136, 0.08);
border: 1px solid rgba(0, 255, 136, 0.3);
padding: 10px 14px;
margin-bottom: 16px;
}
.status-prefix {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: bold;
margin-right: 6px;
}
.actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
.submit-btn {
padding: 14px 24px;
background: rgba(0, 210, 255, 0.08);
border: 2px solid #00d2ff;
color: #00d2ff;
font-family: 'Press Start 2P', monospace;
font-size: 11px;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
width: 100%;
}
.submit-btn:hover:not(:disabled) {
background: rgba(0, 210, 255, 0.18);
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.logout-btn {
padding: 10px 24px;
background: transparent;
border: 1px solid rgba(233, 69, 96, 0.4);
color: #8892b0;
font-family: 'VT323', monospace;
font-size: 18px;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
width: 100%;
}
.logout-btn:hover {
border-color: #e94560;
color: #e94560;
background: rgba(233, 69, 96, 0.06);
}
.status-line {
display: flex;
gap: 12px;
align-items: center;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
}
.status-label {
color: #8892b0;
letter-spacing: 1px;
}
.status-value {
color: #f5a623;
letter-spacing: 1px;
}
.blink-slow {
animation: blink-slow 2s step-end infinite;
}
@keyframes blink-slow {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>

View File

@@ -1,10 +1,90 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { ref } from 'vue'
import { router, usePage } from '@inertiajs/vue3'
import CliBar from '@/Components/Cli/CliBar.vue'
const page = usePage()
const user = page.props.auth?.user
// Redirect to map (the map IS the dashboard)
const goToMap = () => router.visit('/map')
</script>
<template>
<AppLayout>
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600">Welkom bij het Innovatieplatform van het R&amp;D Lab.</p>
</AppLayout>
<div class="dashboard">
<div class="welcome">
<h1 class="title">INNOVATIEPLATFORM</h1>
<p class="subtitle">R&amp;D Lab Waterschap Brabantse Delta</p>
<div class="user-greeting" v-if="user">
<span class="greeting-text">Welkom, {{ user.name }}</span>
</div>
<button @click="goToMap" class="enter-btn">
OPEN METRO MAP &gt;&gt;
</button>
</div>
<CliBar />
</div>
</template>
<style scoped>
.dashboard {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a2e;
position: relative;
}
.welcome {
text-align: center;
}
.title {
font-family: 'Press Start 2P', monospace;
font-size: 24px;
color: #00d2ff;
text-shadow: 0 0 30px rgba(0, 210, 255, 0.4);
letter-spacing: 4px;
}
.subtitle {
font-family: 'VT323', monospace;
font-size: 20px;
color: #8892b0;
margin-top: 12px;
}
.user-greeting {
margin-top: 32px;
}
.greeting-text {
font-family: 'VT323', monospace;
font-size: 18px;
color: #00ff88;
}
.enter-btn {
margin-top: 40px;
padding: 14px 32px;
background: rgba(0, 210, 255, 0.1);
border: 2px solid #00d2ff;
color: #00d2ff;
font-family: 'Press Start 2P', monospace;
font-size: 12px;
cursor: pointer;
letter-spacing: 2px;
transition: all 0.3s;
}
.enter-btn:hover {
background: rgba(0, 210, 255, 0.2);
box-shadow: 0 0 30px rgba(0, 210, 255, 0.3);
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup>
import { ref, computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
import MetroCanvas from '@/Components/MetroMap/MetroCanvas.vue'
import Breadcrumb from '@/Components/MetroMap/Breadcrumb.vue'
import NodePreview from '@/Components/MetroMap/NodePreview.vue'
import CliBar from '@/Components/Cli/CliBar.vue'
const page = usePage()
// Navigation state
const currentLevel = ref(1)
const breadcrumbPath = ref([{ label: 'Strategie', level: 1, data: null }])
const selectedNode = ref(null)
const showPreview = ref(false)
// Demo data for Level 1: Strategy map
// Each theme is a metro line, projects are stations
const demoLines = ref([
{ id: 'water-quality', name: 'Waterkwaliteit', color: '#00d2ff' },
{ id: 'smart-infra', name: 'Slimme Infrastructuur', color: '#e94560' },
{ id: 'data-driven', name: 'Data-gedreven Beheer', color: '#00ff88' },
{ id: 'sustainability', name: 'Duurzaamheid', color: '#7b68ee' },
])
const demoNodes = ref([
// Water Quality line
{ id: 'wq1', name: 'Smart Sensors', lineId: 'water-quality', x: -200, y: -150, order: 1, status: 'actief', description: 'Slimme sensoren voor waterkwaliteitsmonitoring', owner: 'Jan Visser', children: 5, badge: 'Experiment' },
{ id: 'wq2', name: 'Biomonitoring', lineId: 'water-quality', x: 0, y: -150, order: 2, status: 'concept', description: 'Biologische monitoring methoden', owner: 'Sara Jansen', children: 3, badge: 'Concept' },
{ id: 'wq3', name: 'Voorspelmodel', lineId: 'water-quality', x: 200, y: -150, order: 3, status: 'signaal', description: 'Predictief model waterkwaliteit', badge: 'Signaal' },
// Smart Infrastructure line
{ id: 'si1', name: 'Edge Computing', lineId: 'smart-infra', x: -200, y: -20, order: 1, status: 'actief', description: 'Edge-layer evolutie voor OT-omgevingen', owner: 'Rene de Ren', children: 8, badge: 'Pilot' },
{ id: 'si2', name: 'Digital Twin', lineId: 'smart-infra', x: 50, y: -20, order: 2, status: 'verkenning', description: 'Digitale tweeling van zuiveringsinstallaties', children: 2, badge: 'Verkenning' },
{ id: 'si3', name: 'Predictive Maint.', lineId: 'smart-infra', x: 250, y: -20, order: 3, status: 'concept', description: 'Voorspellend onderhoud op basis van sensordata', badge: 'Concept' },
// Data-driven line
{ id: 'dd1', name: 'Data Platform', lineId: 'data-driven', x: -150, y: 110, order: 1, status: 'afgerond', description: 'Centraal dataplatform voor operationele data', owner: 'Lisa de Boer', badge: 'Afgerond' },
{ id: 'dd2', name: 'ML Pipeline', lineId: 'data-driven', x: 80, y: 110, order: 2, status: 'actief', description: 'Machine learning pipeline voor anomalie-detectie', owner: 'Tom Bakker', children: 4, badge: 'Experiment' },
{ id: 'dd3', name: 'Dashboard Suite', lineId: 'data-driven', x: 280, y: 110, order: 3, status: 'overdracht_bouwen', description: 'Operationele dashboards voor beheerders', badge: 'Overdracht' },
// Sustainability line
{ id: 'su1', name: 'Energietransitie', lineId: 'sustainability', x: -100, y: 240, order: 1, status: 'pilot', description: 'Energieneutraal zuiveren', owner: 'Mark de Vries', children: 6, badge: 'Pilot' },
{ id: 'su2', name: 'Circulair Water', lineId: 'sustainability', x: 150, y: 240, order: 2, status: 'verkenning', description: 'Circulaire waterbehandeling', badge: 'Verkenning' },
])
const demoConnections = ref([
{ from: 'wq1', to: 'dd2' }, // Smart sensors feeds ML Pipeline
{ from: 'si1', to: 'dd1' }, // Edge computing depends on data platform
{ from: 'dd2', to: 'si3' }, // ML pipeline enables predictive maintenance
])
// Handlers
const handleNodeClick = (node) => {
selectedNode.value = node
showPreview.value = true
}
const handleNodeHover = (node) => {
// Could highlight related nodes/connections
}
const handleNodeLeave = () => {
// Reset highlights
}
const handleZoomIn = (node) => {
showPreview.value = false
currentLevel.value++
breadcrumbPath.value.push({
label: node.name,
level: currentLevel.value,
data: node,
})
// In real app: load child nodes for this project
}
const handleBreadcrumbNavigate = (item, index) => {
breadcrumbPath.value = breadcrumbPath.value.slice(0, index + 1)
currentLevel.value = item.level
showPreview.value = false
selectedNode.value = null
}
const handleCliCommand = (command) => {
console.log('CLI command:', command)
// Will be connected to AI service
}
</script>
<template>
<div class="metro-map-page">
<Breadcrumb
:path="breadcrumbPath"
@navigate="handleBreadcrumbNavigate"
/>
<MetroCanvas
:nodes="demoNodes"
:lines="demoLines"
:connections="demoConnections"
:current-level="currentLevel"
@node-click="handleNodeClick"
@node-hover="handleNodeHover"
@node-leave="handleNodeLeave"
/>
<NodePreview
:node="selectedNode"
:visible="showPreview"
@close="showPreview = false"
@zoom-in="handleZoomIn"
/>
<CliBar @command="handleCliCommand" />
</div>
</template>
<style scoped>
.metro-map-page {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
background: #1a1a2e;
}
</style>

8
routes/api.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');

View File

@@ -1,12 +1,39 @@
<?php
use App\Http\Controllers\MapController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ThemaController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return Inertia::render('Dashboard');
});
// Redirect root to map
Route::get('/', fn () => redirect('/map'));
Route::get('/dashboard', function () {
return Inertia::render('Dashboard');
// Authenticated routes
Route::middleware(['auth', 'verified'])->group(function () {
// Metro Map
Route::get('/map', [MapController::class, 'index'])->name('map');
Route::get('/map/project/{project}', [MapController::class, 'project'])->name('map.project');
// API endpoints for dynamic map data
Route::prefix('api')->group(function () {
Route::get('/map/strategy', [MapController::class, 'apiStrategy'])->name('api.map.strategy');
Route::get('/map/project/{project}', [MapController::class, 'apiProject'])->name('api.map.project');
});
// Projects
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::put('/projects/{project}', [ProjectController::class, 'update'])->name('projects.update');
Route::post('/projects/{project}/transition', [ProjectController::class, 'transition'])->name('projects.transition');
Route::post('/projects/{project}/park', [ProjectController::class, 'park'])->name('projects.park');
Route::post('/projects/{project}/stop', [ProjectController::class, 'stop'])->name('projects.stop');
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('projects.destroy');
// Themas
Route::get('/themas', [ThemaController::class, 'index'])->name('themas.index');
Route::post('/themas', [ThemaController::class, 'store'])->name('themas.store');
Route::put('/themas/{thema}', [ThemaController::class, 'update'])->name('themas.update');
// Dashboard (redirects to map)
Route::get('/dashboard', fn () => redirect('/map'))->name('dashboard');
});