diff --git a/README.md b/README.md new file mode 100644 index 0000000..aad01f7 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# StaticHTMLSites + +A Laravel 12 application for hosting static HTML mini-sites. +Each page is accessible via subdomain (`{slug}.statichtmlsites.mtex.dev`) and path URL (`statichtmlsites.mtex.dev/{slug}/...`). + +--- + +## Tech Stack + +| Layer | Technology | +|----------|----------------------------------| +| Backend | Laravel 12, PHP 8.2+ | +| Frontend | Tailwind CSS 3, Alpine.js 3 | +| Editor | CodeMirror 6 (npm) | +| Build | Vite 5 + laravel-vite-plugin | +| Auth | Laravel Breeze (blade stack) | +| Database | SQLite (default) or MySQL | +| Storage | Local filesystem | + +--- + +## Installation + +```bash +# 1. Install PHP + JS dependencies +composer install +npm install + +# 2. Environment +cp .env.example .env +php artisan key:generate + +# 3. Migrate +php artisan migrate + +# 4. Install Breeze auth scaffolding +php artisan breeze:install blade +php artisan migrate + +# 5. Build assets +npm run build +``` + +Key `.env` variables: + +| Variable | Value | +|-------------------|--------------------------------------| +| APP_URL | https://statichtmlsites.mtex.dev | +| APP_BASE_DOMAIN | statichtmlsites.mtex.dev | +| MAX_UPLOAD_MB | 50 | + +--- + +## Wildcard Subdomain (DNS + Nginx) + +**DNS:** +``` +*.statichtmlsites.mtex.dev CNAME statichtmlsites.mtex.dev +``` + +**Nginx:** +```nginx +server { + listen 443 ssl; + server_name statichtmlsites.mtex.dev *.statichtmlsites.mtex.dev; + ssl_certificate /path/to/wildcard.crt; + ssl_certificate_key /path/to/wildcard.key; + root /var/www/statichtmlsites/public; + index index.php; + location / { try_files $uri $uri/ /index.php?$query_string; } + location ~ \.php$ { + fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } +} +``` + +**config/filesystems.php** — add to the returned array: +```php +'max_upload_mb' => env('MAX_UPLOAD_MB', 50), +``` + +--- + +## Project Structure + +``` +app/ + Http/Controllers/ + DashboardController.php Dashboard page + FileManagerController.php File CRUD JSON API + PageController.php Create / update / delete pages + PageServeController.php Serve static files + injection + Models/ + Page.php ULID model, storage helpers, slug uniqueness + User.php Breeze user + pages() HasMany + Policies/PagePolicy.php Ownership checks + Providers/AppServiceProvider.php Register FileManagerService singleton + policy + Services/FileManagerService.php All file ops: tree, read, save, upload, delete, rename + +resources/ + css/app.css Tailwind + CodeMirror overrides + js/ + app.js Global entry (Alpine on non-manager pages) + file-manager.js CodeMirror 6 + Alpine file-tree component + views/ + layouts/app.blade.php App shell with nav + welcome.blade.php Marketing landing + pages/ + dashboard.blade.php Page grid + create.blade.php New page form + manager.blade.php Full-screen file manager + +database/migrations/ + ..._create_pages_table.php + +routes/web.php All routes + subdomain group +``` + +--- + +## Key Design Decisions + +- **`` injection** — `PageServeController` detects subdomain vs. path access and injects `` into every HTML file before serving, keeping relative asset paths correct under both URL shapes. +- **Path traversal guard** — `FileManagerService::guard()` and `PageServeController` both resolve real paths and assert they sit inside the page's storage root. +- **Storage isolation** — `storage/app/pages/{slug}/`. Directories are auto-created on page creation, renamed on slug change, and deleted on page deletion via Eloquent model events. +- **File tree** — Pure PHP recursive directory scan returned as JSON, rendered by an Alpine component with inline HTML strings. + +--- + +## License +MIT diff --git a/app/Console/Commands/CleanupPageStorage.php b/app/Console/Commands/CleanupPageStorage.php new file mode 100644 index 0000000..62aa56f --- /dev/null +++ b/app/Console/Commands/CleanupPageStorage.php @@ -0,0 +1,52 @@ +directories('/'); + + $slugs = Page::pluck('slug')->flip(); // O(1) lookups + $orphans = collect($dirs)->filter(fn ($d) => ! $slugs->has($d)); + + if ($orphans->isEmpty()) { + $this->info('No orphaned directories found.'); + return self::SUCCESS; + } + + $dryRun = $this->option('dry-run'); + + foreach ($orphans as $dir) { + if ($dryRun) { + $this->line(" [dry-run] Would delete: {$dir}"); + } else { + $disk->deleteDirectory($dir); + $this->line(" Deleted: {$dir}"); + } + } + + $count = $orphans->count(); + $verb = $dryRun ? 'Found' : 'Cleaned up'; + $this->info("{$verb} {$count} orphaned " . str('directory')->plural($count) . '.'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..ed07e05 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,36 @@ +authenticate(); + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard', absolute: false)); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 0000000..c3ac5b7 --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,34 @@ +validate([ + 'email' => $request->user()->email, + 'password' => $request->password, + ])) { + throw ValidationException::withMessages([ + 'password' => __('auth.password'), + ]); + } + + $request->session()->put('auth.password_confirmed_at', time()); + + return redirect()->intended(route('dashboard', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..f8fdebb --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,21 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 0000000..0cc9c66 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,23 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : view('auth.verify-email'); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..5c53f0e --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,47 @@ + $request]); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + return $status === Password::PASSWORD_RESET + ? redirect()->route('login')->with('status', __($status)) + : back()->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 0000000..7985ba5 --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,29 @@ +validateWithBag('updatePassword', [ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return back()->with('status', 'password-updated'); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..4af48ca --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,33 @@ +validate([ + 'email' => ['required', 'email'], + ]); + + $status = Password::sendResetLink( + $request->only('email') + ); + + return $status === Password::RESET_LINK_SENT + ? back()->with('status', __($status)) + : back()->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..99927f5 --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,42 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + event(new Registered($user)); + + Auth::login($user); + + return redirect(route('dashboard', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..bfdd971 --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,27 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false) . '?verified=1'); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect()->intended(route('dashboard', absolute: false) . '?verified=1'); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..f296637 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,19 @@ +user() + ->pages() + ->latest() + ->get(); + + return view('pages.dashboard', compact('pages')); + } +} diff --git a/app/Http/Controllers/FileManagerController.php b/app/Http/Controllers/FileManagerController.php new file mode 100644 index 0000000..32376ca --- /dev/null +++ b/app/Http/Controllers/FileManagerController.php @@ -0,0 +1,124 @@ +ownedPage($request, $slug); + return view('pages.manager', compact('page')); + } + + // ── JSON endpoints ──────────────────────────────────────────────────────── + + public function list(Request $request, string $slug): JsonResponse + { + return $this->run(fn () => $this->fm->tree($this->ownedPage($request, $slug))); + } + + public function read(Request $request, string $slug): JsonResponse + { + return $this->run(function () use ($request, $slug) { + $page = $this->ownedPage($request, $slug); + $path = $request->query('path', ''); + return ['content' => $this->fm->read($page, $path), 'path' => $path]; + }); + } + + public function save(Request $request, string $slug): JsonResponse + { + return $this->run(function () use ($request, $slug) { + $data = $request->validate(['path' => 'required|string', 'content' => 'required|string']); + $this->fm->save($this->ownedPage($request, $slug), $data['path'], $data['content']); + return ['ok' => true]; + }); + } + + public function create(Request $request, string $slug): JsonResponse + { + return $this->run(function () use ($request, $slug) { + $data = $request->validate(['path' => 'required|string']); + $page = $this->ownedPage($request, $slug); + $this->fm->createFile($page, $data['path']); + return ['ok' => true, 'tree' => $this->fm->tree($page)]; + }); + } + + public function upload(Request $request, string $slug): JsonResponse + { + return $this->run(function () use ($request, $slug) { + $maxMb = (int) config('filesystems.max_upload_mb', 50); + $request->validate([ + 'file' => "required|file|max:{$maxMb}000", + 'folder' => 'nullable|string', + ]); + $page = $this->ownedPage($request, $slug); + $this->fm->upload($page, $request->file('file'), $request->input('folder', '')); + return ['ok' => true, 'tree' => $this->fm->tree($page)]; + }); + } + + public function delete(Request $request, string $slug): JsonResponse + { + return $this->run(function () use ($request, $slug) { + $data = $request->validate(['path' => 'required|string']); + $page = $this->ownedPage($request, $slug); + $this->fm->delete($page, $data['path']); + return ['ok' => true, 'tree' => $this->fm->tree($page)]; + }); + } + + public function createFolder(Request $request, string $slug): JsonResponse + { + return $this->run(function () use ($request, $slug) { + $data = $request->validate(['path' => 'required|string']); + $page = $this->ownedPage($request, $slug); + $this->fm->createFolder($page, $data['path']); + return ['ok' => true, 'tree' => $this->fm->tree($page)]; + }); + } + + public function rename(Request $request, string $slug): JsonResponse + { + return $this->run(function () use ($request, $slug) { + $data = $request->validate(['from' => 'required|string', 'to' => 'required|string']); + $page = $this->ownedPage($request, $slug); + $this->fm->rename($page, $data['from'], $data['to']); + return ['ok' => true, 'tree' => $this->fm->tree($page)]; + }); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function ownedPage(Request $request, string $slug): Page + { + return $request->user()->pages()->where('slug', $slug)->firstOrFail(); + } + + private function run(callable $fn): JsonResponse + { + try { + $result = $fn(); + return response()->json($result); + } catch (RuntimeException $e) { + return response()->json(['error' => $e->getMessage()], 422); + } + } +} diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php new file mode 100644 index 0000000..c7b837c --- /dev/null +++ b/app/Http/Controllers/PageController.php @@ -0,0 +1,79 @@ +validate([ + 'name' => ['required', 'string', 'max:100'], + 'slug' => ['nullable', 'string', 'max:60', 'regex:/^[a-z0-9\-_]+$/'], + ]); + + $slug = $data['slug'] ?? Page::makeUniqueSlug($data['name']); + + // Ensure uniqueness even if user provided a slug + if (Page::where('slug', $slug)->exists()) { + return back() + ->withInput() + ->withErrors(['slug' => 'This slug is already taken.']); + } + + $page = $request->user()->pages()->create([ + 'name' => $data['name'], + 'slug' => $slug, + 'is_public' => true, + ]); + + return redirect() + ->route('pages.manager', $page->slug) + ->with('success', "Page \"{$page->name}\" created!"); + } + + public function updateSettings(Request $request, string $slug): RedirectResponse + { + $page = $request->user()->pages()->where('slug', $slug)->firstOrFail(); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'slug' => ['required', 'string', 'max:60', 'regex:/^[a-z0-9\-_]+$/'], + 'is_public' => ['boolean'], + ]); + + // Slug uniqueness check (excluding current page) + if ( + $data['slug'] !== $page->slug && + Page::where('slug', $data['slug'])->exists() + ) { + return back()->withErrors(['slug' => 'This slug is already taken.']); + } + + $page->update($data); + + return redirect() + ->route('pages.manager', $page->slug) + ->with('success', 'Settings updated.'); + } + + public function destroy(Request $request, string $slug): RedirectResponse + { + $page = $request->user()->pages()->where('slug', $slug)->firstOrFail(); + $page->delete(); + + return redirect() + ->route('dashboard') + ->with('success', "Page \"{$page->name}\" deleted."); + } +} diff --git a/app/Http/Controllers/PagePreviewController.php b/app/Http/Controllers/PagePreviewController.php new file mode 100644 index 0000000..b3da9a8 --- /dev/null +++ b/app/Http/Controllers/PagePreviewController.php @@ -0,0 +1,22 @@ +firstOrFail(); + Gate::authorize('update', $page); + + return view('pages.preview', compact('page')); + } +} diff --git a/app/Http/Controllers/PageServeController.php b/app/Http/Controllers/PageServeController.php new file mode 100644 index 0000000..d09e16f --- /dev/null +++ b/app/Http/Controllers/PageServeController.php @@ -0,0 +1,136 @@ +firstOrFail(); + + if (! $page->is_public && ! auth()->check()) { + abort(403, 'This page is private.'); + } + + // Resolve the disk path and guard against traversal + $diskPath = $this->resolveDiskPath($slug, $path); + $this->guardTraversal($slug, $diskPath); + + $disk = Storage::disk('pages'); + + if (! $disk->exists($diskPath)) { + abort(404, 'File not found.'); + } + + $ext = strtolower(pathinfo($diskPath, PATHINFO_EXTENSION)); + $mime = $this->detectMime($ext); + + // Inject tag into HTML responses + if ($ext === 'html') { + $html = $disk->get($diskPath); + $html = $this->injectBase($html, $page, $request); + return response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']); + } + + return response()->file($disk->path($diskPath), ['Content-Type' => $mime]); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Resolve a path within the page's slug directory on the pages disk. + * Handles directory index resolution. + */ + private function resolveDiskPath(string $slug, string $path): string + { + $path = ltrim($path, '/'); + + if ($path === '' || $path === '/') { + return "{$slug}/index.html"; + } + + // No extension → try index.html inside that directory + if (! pathinfo($path, PATHINFO_EXTENSION)) { + $candidate = "{$slug}/" . rtrim($path, '/') . '/index.html'; + if (Storage::disk('pages')->exists($candidate)) { + return $candidate; + } + } + + return "{$slug}/{$path}"; + } + + /** + * Prevent path traversal by checking the real path stays inside the slug dir. + */ + private function guardTraversal(string $slug, string $diskPath): void + { + $disk = Storage::disk('pages'); + $root = realpath($disk->path($slug)); + $realFile = realpath($disk->path($diskPath)); + + // realpath returns false for non-existent files – only check when both resolve + if ($root && $realFile && ! str_starts_with($realFile, $root)) { + abort(403, 'Path traversal detected.'); + } + } + + /** + * Inject as the first child of . + * Detects subdomain vs path-based access automatically. + */ + private function injectBase(string $html, Page $page, Request $request): string + { + $host = $request->getHost(); + $baseDomain = config('app.base_domain', 'statichtmlsites.mtex.dev'); + $isSubdomain = str_ends_with($host, ".{$baseDomain}"); + + $baseUrl = $isSubdomain ? $page->subdomainUrl() : $page->pathUrl(); + $baseTag = "\n"; + + if (preg_match('/]*)>/i', $html)) { + return preg_replace('/]*)>/i', "\n{$baseTag}", $html, 1); + } + + return $baseTag . $html; + } + + private function detectMime(string $ext): string + { + return [ + 'html' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'mjs' => 'application/javascript', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'svg' => 'image/svg+xml', + 'txt' => 'text/plain', + 'md' => 'text/plain', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'ico' => 'image/x-icon', + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'ogg' => 'audio/ogg', + 'pdf' => 'application/pdf', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + ][$ext] ?? 'application/octet-stream'; + } +} diff --git a/app/Http/Middleware/HandleAppearance.php b/app/Http/Middleware/HandleAppearance.php new file mode 100644 index 0000000..45491c6 --- /dev/null +++ b/app/Http/Middleware/HandleAppearance.php @@ -0,0 +1,19 @@ + ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + /** + * Attempt authentication; throw on failure or rate-limit breach. + */ + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip()); + } +} diff --git a/app/Livewire/FileManager.php b/app/Livewire/FileManager.php new file mode 100644 index 0000000..81ff2e6 --- /dev/null +++ b/app/Livewire/FileManager.php @@ -0,0 +1,417 @@ +page = Page::where('slug', $slug)->firstOrFail(); + Gate::authorize('update', $this->page); + + $this->page->ensureStorageExists(); + + $this->pageName = $this->page->name; + $this->pageSlug = $this->page->slug; + $this->pageIsPublic = $this->page->is_public; + + if ($this->disk()->exists($this->page->storagePath('index.html'))) { + $this->openFile('index.html'); + } + } + + // ─── Computed ───────────────────────────────────────────────────────────── + + #[Computed] + public function fileTree(): array + { + $dir = $this->page->storagePath($this->currentDir ?: ''); + $prefix = $this->page->storagePath() . '/'; + return $this->buildTree($dir, $prefix); + } + + private function buildTree(string $dir, string $prefix): array + { + $entries = []; + + foreach ($this->disk()->directories($dir) as $d) { + $entries[] = [ + 'type' => 'dir', + 'name' => basename($d), + 'path' => $this->relative($d, $prefix), + 'children' => $this->buildTree($d, $prefix), + ]; + } + + foreach ($this->disk()->files($dir) as $f) { + $rel = $this->relative($f, $prefix); + $entries[] = [ + 'type' => 'file', + 'name' => basename($f), + 'path' => $rel, + 'active' => $rel === $this->activeFile, + 'isIndex' => basename($f) === 'index.html', + ]; + } + + usort($entries, fn ($a, $b) => + [$a['type'] === 'file' ? 1 : 0, $a['name']] + <=> + [$b['type'] === 'file' ? 1 : 0, $b['name']] + ); + + return $entries; + } + + #[Computed] + public function editorLanguage(): string + { + return match (strtolower(pathinfo($this->activeFile, PATHINFO_EXTENSION))) { + 'html', 'htm' => 'html', + 'css' => 'css', + 'js' => 'javascript', + 'json' => 'json', + 'xml', 'svg' => 'xml', + default => 'text', + }; + } + + #[Computed] + public function isEditable(): bool + { + return in_array( + strtolower(pathinfo($this->activeFile, PATHINFO_EXTENSION)), + ['html', 'htm', 'css', 'js', 'json', 'txt', 'xml', 'svg', 'md'], + true + ); + } + + #[Computed] + public function isPreviewable(): bool + { + return in_array( + strtolower(pathinfo($this->activeFile, PATHINFO_EXTENSION)), + ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'mp4', 'webm', 'mp3', 'wav', 'ogg', 'pdf'], + true + ); + } + + #[Computed] + public function previewUrl(): string + { + return $this->page->subdomainUrl($this->activeFile); + } + + // ─── File operations ────────────────────────────────────────────────────── + + public function openFile(string $path): void + { + $this->guardPath($path); + $full = $this->page->storagePath($path); + + if (! $this->disk()->exists($full)) { + $this->showFlash("File not found: {$path}", 'error'); + return; + } + + $this->activeFile = $path; + $this->fileContent = $this->disk()->get($full) ?? ''; + $this->isDirty = false; + + $this->dispatch('load-editor', + content: $this->fileContent, + language: $this->editorLanguage + ); + } + + public function saveFile(string $content): void + { + if ($this->activeFile === '') return; + + $this->guardPath($this->activeFile); + $this->disk()->put($this->page->storagePath($this->activeFile), $content); + + $this->fileContent = $content; + $this->isDirty = false; + $this->showFlash('Saved ✓'); + } + + public function createFile(): void + { + $name = $this->sanitiseName($this->newFileName); + + if ($name === '') { + $this->showFlash('File name cannot be empty.', 'error'); + return; + } + + $relative = $this->buildRelative($name); + $full = $this->page->storagePath($relative); + + if ($this->disk()->exists($full)) { + $this->showFlash('A file with that name already exists.', 'error'); + return; + } + + $this->disk()->put($full, ''); + $this->newFileName = ''; + $this->openFile($relative); + unset($this->fileTree); + } + + /** Create file and close the modal. */ + public function createFileAndClose(): void + { + $name = $this->sanitiseName($this->newFileName); + if ($name === '') { $this->showFlash('File name cannot be empty.', 'error'); return; } + + $relative = $this->buildRelative($name); + $full = $this->page->storagePath($relative); + + if ($this->disk()->exists($full)) { + $this->showFlash('A file with that name already exists.', 'error'); + return; + } + + $this->disk()->put($full, ''); + $this->newFileName = ''; + Flux::modal('new-file')->close(); + $this->openFile($relative); + unset($this->fileTree); + } + + public function createFolder(): void + { + $name = $this->sanitiseName($this->newFolderName); + if ($name === '') { $this->showFlash('Folder name cannot be empty.', 'error'); return; } + + $relative = $this->buildRelative($name); + $this->disk()->makeDirectory($this->page->storagePath($relative)); + + $this->newFolderName = ''; + $this->showFlash("Folder '{$name}' created."); + unset($this->fileTree); + } + + /** Create folder and close the modal. */ + public function createFolderAndClose(): void + { + $name = $this->sanitiseName($this->newFolderName); + if ($name === '') { $this->showFlash('Folder name cannot be empty.', 'error'); return; } + + $relative = $this->buildRelative($name); + $this->disk()->makeDirectory($this->page->storagePath($relative)); + + $this->newFolderName = ''; + Flux::modal('new-folder')->close(); + $this->showFlash("Folder '{$name}' created."); + unset($this->fileTree); + } + + /** Show delete confirmation modal. */ + public function prepareDelete(string $path, string $type = 'file'): void + { + $this->guardPath($path); + $this->deleteTarget = $path; + $this->deleteTargetName = basename($path); + $this->deleteTargetType = $type; + Flux::modal('delete-confirm')->show(); + } + + /** Execute the pending delete after modal confirmation. */ + public function executeDelete(): void + { + if ($this->deleteTarget === '') return; + + $full = $this->page->storagePath($this->deleteTarget); + + if ($this->disk()->directoryExists($full)) { + $this->disk()->deleteDirectory($full); + } else { + $this->disk()->delete($full); + } + + if ($this->activeFile === $this->deleteTarget) { + $this->activeFile = ''; + $this->fileContent = ''; + $this->isDirty = false; + } + + $this->showFlash("Deleted {$this->deleteTargetName}."); + $this->deleteTarget = ''; + $this->deleteTargetName = ''; + Flux::modal('delete-confirm')->close(); + unset($this->fileTree); + } + + /** Prepare rename (shows inline form in sidebar). */ + public function prepareRename(string $path): void + { + $this->guardPath($path); + $this->renameTarget = $path; + $this->renameTo = basename($path); + } + + public function confirmRename(): void + { + if ($this->renameTarget === null) return; + + $this->guardPath($this->renameTarget); + $newName = $this->sanitiseName($this->renameTo); + if ($newName === '') return; + + $dir = dirname($this->renameTarget); + $newPath = ($dir === '.' ? '' : $dir . '/') . $newName; + + $this->disk()->move( + $this->page->storagePath($this->renameTarget), + $this->page->storagePath($newPath) + ); + + if ($this->activeFile === $this->renameTarget) { + $this->openFile($newPath); + } + + $this->renameTarget = null; + $this->renameTo = ''; + $this->showFlash("Renamed to {$newName}."); + unset($this->fileTree); + } + + public function enterDirectory(string $path): void + { + $this->currentDir = $path; + unset($this->fileTree); + } + + public function goUp(): void + { + $parent = dirname($this->currentDir); + $this->currentDir = ($parent === '.' || $parent === '') ? '' : $parent; + unset($this->fileTree); + } + + // ─── Upload ─────────────────────────────────────────────────────────────── + + public function uploadFile(): void + { + $maxKb = (int) config('filesystems.max_upload_mb', 50) * 1024; + $this->validate(['upload' => "required|file|max:{$maxKb}"]); + + $name = $this->sanitiseName($this->upload->getClientOriginalName()); + $storeDir = $this->page->storagePath($this->currentDir ?: ''); + $this->disk()->putFileAs($storeDir, $this->upload, $name); + + $this->upload = null; + $this->showFlash("Uploaded {$name}."); + Flux::modal('upload')->close(); + unset($this->fileTree); + } + + // ─── Page settings ──────────────────────────────────────────────────────── + + public function saveSettings(): void + { + $this->validate([ + 'pageName' => 'required|string|max:255', + 'pageSlug' => "required|string|max:100|regex:/^[a-z0-9\\-]+$/|unique:pages,slug,{$this->page->id}", + 'pageIsPublic' => 'boolean', + ]); + + $this->page->update([ + 'name' => $this->pageName, + 'slug' => $this->pageSlug, + 'is_public' => $this->pageIsPublic, + ]); + + $this->page->refresh(); + Flux::modal('settings')->close(); + $this->showFlash('Settings saved.'); + + if ($this->page->wasChanged('slug')) { + $this->redirect(route('pages.manager', $this->page->slug), navigate: true); + } + } + + public function deletePage(): void + { + $this->page->delete(); + $this->redirect(route('dashboard'), navigate: true); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private function showFlash(string $message, string $type = 'success'): void + { + $this->dispatch('show-flash', message: $message, type: $type); + } + + private function disk(): \Illuminate\Contracts\Filesystem\Filesystem + { + return Storage::disk('pages'); + } + + private function guardPath(string $path): void + { + if (str_contains($path, '..') || str_starts_with($path, '/')) { + abort(400, 'Invalid path.'); + } + } + + private function buildRelative(string $name): string + { + return $this->currentDir ? "{$this->currentDir}/{$name}" : $name; + } + + private function relative(string $fullPath, string $prefix): string + { + return ltrim(str_replace($prefix, '', $fullPath), '/'); + } + + private function sanitiseName(string $name): string + { + return preg_replace('/[^a-zA-Z0-9\-_.]/', '-', trim($name)); + } + + // ─── Render ─────────────────────────────────────────────────────────────── + + public function render() + { + return view('livewire.file-manager'); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 0000000..1d659a2 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,140 @@ + 'boolean']; + + // ── Relationships ───────────────────────────────────────────────────────── + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // ── Storage path helpers ────────────────────────────────────────────────── + + /** + * Path relative to the "pages" disk root (storage/app/pages/). + * Used with Storage::disk('pages'). + * e.g. my-site/index.html + */ + public function storagePath(string $relative = ''): string + { + return $relative + ? $this->slug . '/' . ltrim($relative, '/') + : $this->slug; + } + + /** + * Path relative to the default "local" disk root (storage/app/). + * Used with Storage::disk('local') / the Storage facade without a disk. + * e.g. pages/my-site/index.html + */ + public function diskPath(string $relative = ''): string + { + $base = 'pages/' . $this->slug; + return $relative ? $base . '/' . ltrim($relative, '/') : $base; + } + + /** + * Ensure the storage directory exists. + */ + public function ensureStorageExists(): void + { + Storage::disk('pages')->makeDirectory($this->slug); + } + + // ── URL helpers ─────────────────────────────────────────────────────────── + + public function subdomainUrl(string $path = ''): string + { + $base = 'https://' . $this->slug . '.' . config('app.base_domain', 'statichtmlsites.mtex.dev'); + return $path ? rtrim($base, '/') . '/' . ltrim($path, '/') : $base . '/'; + } + + public function pathUrl(string $path = ''): string + { + $base = rtrim(config('app.url'), '/') . '/' . $this->slug; + return $path ? $base . '/' . ltrim($path, '/') : $base . '/'; + } + + // ── Slug factory ────────────────────────────────────────────────────────── + + public static function makeUniqueSlug(string $name): string + { + $slug = Str::slug($name); + $original = $slug; + $counter = 1; + + while (static::where('slug', $slug)->exists()) { + $slug = "{$original}-{$counter}"; + $counter++; + } + + return $slug; + } + + // ── Model events ────────────────────────────────────────────────────────── + + protected static function booted(): void + { + static::created(function (Page $page) { + Storage::disk('pages')->makeDirectory($page->slug); + + if (! Storage::disk('pages')->exists($page->storagePath('index.html'))) { + Storage::disk('pages')->put( + $page->storagePath('index.html'), + static::defaultHtml($page->name) + ); + } + }); + + static::deleted(function (Page $page) { + Storage::disk('pages')->deleteDirectory($page->slug); + }); + + static::updating(function (Page $page) { + if ($page->isDirty('slug')) { + $old = $page->getOriginal('slug'); + $new = $page->slug; + Storage::disk('pages')->move($old, $new); + } + }); + } + + private static function defaultHtml(string $name): string + { + $escaped = htmlspecialchars($name, ENT_QUOTES); + return << + + + + + {$escaped} + + + +

Welcome to {$escaped}

+ + + HTML; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6e0ea6e..c9f95c3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,64 +2,28 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; -use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Illuminate\Support\Str; -use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable { - /** @use HasFactory */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasFactory, Notifiable; - /** - * The attributes that are mass assignable. - * - * @var list - */ - protected $fillable = [ - 'name', - 'email', - 'password', - ]; + protected $fillable = ['name', 'email', 'password']; + protected $hidden = ['password', 'remember_token']; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - protected $hidden = [ - 'password', - 'two_factor_secret', - 'two_factor_recovery_codes', - 'remember_token', - ]; - - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'password' => 'hashed', ]; } - /** - * Get the user's initials - */ - public function initials(): string + public function pages(): HasMany { - return Str::of($this->name) - ->explode(' ') - ->take(2) - ->map(fn ($word) => Str::substr($word, 0, 1)) - ->implode(''); + return $this->hasMany(Page::class); } } diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 0000000..b6f411d --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,16 @@ +id === $page->user_id; } + public function update(User $user, Page $page): bool { return $user->id === $page->user_id; } + public function delete(User $user, Page $page): bool { return $user->id === $page->user_id; } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..27769d9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,9 @@ use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use App\Models\Page; +use App\Policies\PagePolicy; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -24,6 +27,7 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + Gate::policy(Page::class, PagePolicy::class); } /** @@ -47,4 +51,4 @@ protected function configureDefaults(): void : null, ); } -} +} \ No newline at end of file diff --git a/app/Services/FileManagerService.php b/app/Services/FileManagerService.php new file mode 100644 index 0000000..a858ba5 --- /dev/null +++ b/app/Services/FileManagerService.php @@ -0,0 +1,220 @@ +buildTree($page->slug, $page->slug); + } + + private function buildTree(string $dir, string $root): array + { + $items = []; + + foreach ($this->disk()->directories($dir) as $d) { + $name = basename($d); + $relative = $this->relative($d, $root); + $items[] = [ + 'type' => 'directory', + 'name' => $name, + 'path' => $relative, + 'children' => $this->buildTree($d, $root), + ]; + } + + foreach ($this->disk()->files($dir) as $f) { + $name = basename($f); + $relative = $this->relative($f, $root); + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $items[] = [ + 'type' => 'file', + 'name' => $name, + 'path' => $relative, + 'editable' => in_array($ext, self::EDITABLE_EXT, true), + 'ext' => $ext, + 'size' => $this->disk()->size($f), + ]; + } + + usort($items, fn ($a, $b) => + [$a['type'] === 'file' ? 1 : 0, $a['name']] + <=> + [$b['type'] === 'file' ? 1 : 0, $b['name']] + ); + + return $items; + } + + // ── Read / Write ────────────────────────────────────────────────────────── + + public function read(Page $page, string $relative): string + { + $path = $page->storagePath($relative); + $this->guard($page, $relative); + + if (! $this->disk()->exists($path)) { + throw new RuntimeException("File not found: {$relative}"); + } + + return $this->disk()->get($path); + } + + public function save(Page $page, string $relative, string $content): void + { + $this->guard($page, $relative); + $this->disk()->put($page->storagePath($relative), $content); + } + + public function createFile(Page $page, string $relative): void + { + $relative = $this->sanitizePath($relative); + $path = $page->storagePath($relative); + $this->guard($page, $relative); + + if ($this->disk()->exists($path)) { + throw new RuntimeException("File already exists: {$relative}"); + } + + $this->disk()->put($path, ''); + } + + // ── Upload ──────────────────────────────────────────────────────────────── + + public function upload(Page $page, UploadedFile $file, string $folder = ''): void + { + $name = $this->sanitizeName($file->getClientOriginalName()); + $ext = strtolower($file->getClientOriginalExtension()); + + if (! in_array($ext, self::ALLOWED_EXT, true)) { + throw new RuntimeException("File type .{$ext} is not allowed."); + } + + $folder = $folder ? $this->sanitizePath($folder) : ''; + $diskDir = $folder + ? $page->storagePath($folder) + : $page->slug; + + $this->disk()->putFileAs($diskDir, $file, $name); + } + + // ── Delete ──────────────────────────────────────────────────────────────── + + public function delete(Page $page, string $relative): void + { + $path = $page->storagePath($relative); + $this->guard($page, $relative); + + if ($this->disk()->directoryExists($path)) { + $this->disk()->deleteDirectory($path); + } elseif ($this->disk()->exists($path)) { + $this->disk()->delete($path); + } else { + throw new RuntimeException("Path not found: {$relative}"); + } + } + + // ── Folder ──────────────────────────────────────────────────────────────── + + public function createFolder(Page $page, string $relative): void + { + $relative = $this->sanitizePath($relative); + $this->guard($page, $relative); + $this->disk()->makeDirectory($page->storagePath($relative)); + } + + // ── Rename ──────────────────────────────────────────────────────────────── + + public function rename(Page $page, string $from, string $to): void + { + $from = $this->sanitizePath($from); + $to = $this->sanitizePath($to); + + $this->guard($page, $from); + $this->guard($page, $to); + + $fromPath = $page->storagePath($from); + $toPath = $page->storagePath($to); + + if ($this->disk()->directoryExists($fromPath)) { + $this->moveDirectory($fromPath, $toPath); + } elseif ($this->disk()->exists($fromPath)) { + $this->disk()->move($fromPath, $toPath); + } else { + throw new RuntimeException("Path not found: {$from}"); + } + } + + private function moveDirectory(string $from, string $to): void + { + $this->disk()->makeDirectory($to); + + foreach ($this->disk()->allFiles($from) as $file) { + $relative = Str::after($file, $from . '/'); + $this->disk()->move($file, $to . '/' . $relative); + } + + $this->disk()->deleteDirectory($from); + } + + // ── Security ────────────────────────────────────────────────────────────── + + /** + * Prevent path traversal. 'relative' must not escape the page slug directory. + */ + private function guard(Page $page, string $relative): void + { + if (str_contains($relative, '..') || str_starts_with($relative, '/')) { + throw new RuntimeException('Path traversal detected.'); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function relative(string $full, string $root): string + { + return ltrim(Str::after($full, $root . '/'), '/'); + } + + private function sanitizeName(string $name): string + { + $ext = pathinfo($name, PATHINFO_EXTENSION); + $stem = pathinfo($name, PATHINFO_FILENAME); + $safe = preg_replace('/[^a-zA-Z0-9\-_]/', '-', $stem); + return $ext ? "{$safe}.{$ext}" : $safe; + } + + private function sanitizePath(string $path): string + { + $segments = array_filter(explode('/', str_replace('\\', '/', $path))); + $safe = array_map(fn ($s) => preg_replace('/[^a-zA-Z0-9\-_.]/', '-', $s), $segments); + return implode('/', $safe); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..8aef765 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,16 +3,33 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__.'/../routes/web.php', - commands: __DIR__.'/../routes/console.php', + web: __DIR__ . '/../routes/web.php', + commands: __DIR__ . '/../routes/console.php', health: '/up', ) - ->withMiddleware(function (Middleware $middleware): void { - // + ->withMiddleware(function (Middleware $middleware) { + $middleware->web(append: [ + \App\Http\Middleware\HandleAppearance::class, + ]); + + $middleware->alias([ + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + ]); + + // Exclude page-serving routes from CSRF so subdomain requests work + $middleware->validateCsrfTokens(except: [ + // CSRF is only needed for state-mutating requests from the browser, + // but we serve pages publicly so no exclusions needed here. + ]); + }) + ->withExceptions(function (Exceptions $exceptions) { + // Return JSON for API-like file manager requests + $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('*/files*') || $request->expectsJson(); + }); }) - ->withExceptions(function (Exceptions $exceptions): void { - // - })->create(); + ->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 5ffd769..38b258d 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,9 +1,5 @@ env('APP_FAKER_LOCALE', 'en_US'), + /* + |-------------------------------------------------------------------------- + | Base domain + | Used for subdomain routing and URL helpers. + |-------------------------------------------------------------------------- + */ + 'base_domain' => env('APP_BASE_DOMAIN', 'statichtmlsites.mtex.dev'), + + /* + |-------------------------------------------------------------------------- + | Max upload size (MB) + |-------------------------------------------------------------------------- + */ + 'max_upload_mb' => (int) env('MAX_UPLOAD_MB', 50), + /* |-------------------------------------------------------------------------- | Encryption Key @@ -123,4 +141,12 @@ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + + 'providers' => ServiceProvider::defaultProviders()->merge([ + App\Providers\AppServiceProvider::class, + ])->toArray(), + + 'aliases' => Facade::defaultAliases()->merge([ + // 'Example' => App\Facades\Example::class, + ])->toArray(), ]; diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..023680d 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -47,6 +47,20 @@ 'report' => false, ], + /* + |---------------------------------------------------------------------- + | Pages disk + | All user page files live here, scoped per slug: + | storage/app/pages/{slug}/ + |---------------------------------------------------------------------- + */ + 'pages' => [ + 'driver' => 'local', + 'root' => storage_path('app/pages'), + 'visibility' => 'public', + 'throw' => true, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 0000000..e7721f6 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,31 @@ +unique()->words(3, true); + return [ + 'user_id' => User::factory(), + 'name' => ucwords($name), + 'slug' => Str::slug($name), + 'is_public' => true, + ]; + } + + public function private(): static + { + return $this->state(['is_public' => false]); + } + + public function withSlug(string $slug): static + { + return $this->state(['slug' => $slug]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 98825c8..29c4d3f 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,59 +2,29 @@ namespace Database\Factories; -use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; -/** - * @extends Factory - */ class UserFactory extends Factory { - /** - * The current password being used by the factory. - */ protected static ?string $password; - /** - * Define the model's default state. - * - * @return array - */ public function definition(): array { return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - 'two_factor_secret' => null, - 'two_factor_recovery_codes' => null, - 'two_factor_confirmed_at' => null, + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), ]; } - /** - * Indicate that the model's email address should be unverified. - */ public function unverified(): static { return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); } - - /** - * Indicate that the model has two-factor authentication configured. - */ - public function withTwoFactor(): static - { - return $this->state(fn (array $attributes) => [ - 'two_factor_secret' => encrypt('secret'), - 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])), - 'two_factor_confirmed_at' => now(), - ]); - } } diff --git a/database/migrations/2026_01_01_000001_create_pages_table.php b/database/migrations/2026_01_01_000001_create_pages_table.php new file mode 100644 index 0000000..522dadb --- /dev/null +++ b/database/migrations/2026_01_01_000001_create_pages_table.php @@ -0,0 +1,25 @@ +ulid('id')->primary(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->boolean('is_public')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..a47f669 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,20 +3,14 @@ namespace Database\Seeders; use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - User::factory()->create([ - 'name' => 'Test User', + 'name' => 'Test User', 'email' => 'test@example.com', ]); } diff --git a/package-lock.json b/package-lock.json index 40b5dd4..ef15d56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,11 +5,25 @@ "packages": { "": { "dependencies": { + "@codemirror/commands": "^6.6.0", + "@codemirror/lang-css": "^6.2.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.10.1", + "@codemirror/state": "^6.4.1", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.33.0", + "alpinejs": "^3.14.0", + "axios": "^1.7.4" + }, + "devDependencies": { "@tailwindcss/vite": "^4.1.11", "autoprefixer": "^10.4.20", - "axios": "^1.7.4", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0", + "postcss": "^8.4.38", "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, @@ -19,6 +33,157 @@ "lightningcss-linux-x64-gnu": "^1.29.1" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -26,6 +191,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -42,6 +208,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -58,6 +225,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -74,6 +242,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -90,6 +259,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -106,6 +276,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -122,6 +293,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -138,6 +310,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -154,6 +327,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -170,6 +344,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -186,6 +361,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -202,6 +378,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -218,6 +395,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -234,6 +412,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -250,6 +429,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -266,6 +446,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -282,6 +463,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -298,6 +480,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -314,6 +497,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -330,6 +514,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -346,6 +531,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -362,6 +548,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -378,6 +565,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -394,6 +582,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -410,6 +599,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -426,6 +616,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -439,6 +630,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -449,6 +641,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -459,6 +652,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -468,18 +662,105 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -487,6 +768,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -500,6 +782,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -513,6 +796,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -526,6 +810,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -539,6 +824,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -552,6 +838,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -565,6 +852,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -578,6 +866,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -591,6 +880,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -604,6 +894,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,6 +908,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -630,6 +922,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,6 +936,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -656,6 +950,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -669,6 +964,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -682,6 +978,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -695,6 +992,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -721,6 +1019,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -734,6 +1033,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -747,6 +1047,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -760,6 +1061,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -773,6 +1075,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -786,6 +1089,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -799,6 +1103,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -809,6 +1114,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -824,6 +1130,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20" @@ -850,6 +1157,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -866,6 +1174,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -882,6 +1191,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -898,6 +1208,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -914,6 +1225,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -930,6 +1242,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -946,6 +1259,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -978,6 +1292,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1002,6 +1317,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1023,6 +1339,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1039,6 +1356,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1052,6 +1370,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, "license": "MIT", "dependencies": { "@tailwindcss/node": "4.2.1", @@ -1066,12 +1385,38 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", "license": "MIT" }, + "node_modules/alpinejs": { + "version": "3.15.8", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.8.tgz", + "integrity": "sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1081,6 +1426,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1102,6 +1448,7 @@ "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1149,6 +1496,7 @@ "version": "2.10.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1161,6 +1509,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1207,6 +1556,7 @@ "version": "1.0.30001778", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1227,6 +1577,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -1243,6 +1594,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -1255,6 +1607,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -1269,6 +1622,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1281,6 +1635,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -1299,6 +1654,7 @@ "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", @@ -1319,6 +1675,12 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1332,6 +1694,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1355,18 +1718,21 @@ "version": "1.5.313", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1425,6 +1791,7 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1466,6 +1833,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1475,6 +1843,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1528,6 +1897,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -1541,6 +1911,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1564,6 +1935,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -1622,12 +1994,14 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1676,6 +2050,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1685,6 +2060,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1694,6 +2070,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.0.0", @@ -1713,6 +2090,7 @@ "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1745,6 +2123,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1765,6 +2144,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1785,6 +2165,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1805,6 +2186,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1825,6 +2207,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1845,6 +2228,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1865,6 +2249,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1905,6 +2290,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1925,6 +2311,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1945,6 +2332,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1965,6 +2353,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1982,6 +2371,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2021,6 +2411,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -2039,18 +2430,21 @@ "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2063,6 +2457,7 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2091,6 +2486,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/proxy-from-env": { @@ -2103,6 +2499,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2112,6 +2509,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -2159,6 +2557,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2169,6 +2568,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -2178,6 +2578,7 @@ "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2190,6 +2591,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2199,6 +2601,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -2213,6 +2616,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -2221,10 +2625,17 @@ "node": ">=8" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -2240,12 +2651,14 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2259,6 +2672,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2275,6 +2689,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, "license": "MIT", "bin": { "tree-kill": "cli.js" @@ -2284,12 +2699,14 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2320,6 +2737,7 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -2394,6 +2812,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.0.0", @@ -2404,6 +2823,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -2412,10 +2832,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -2433,6 +2860,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -2442,6 +2870,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -2460,6 +2889,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 688bea8..df56eeb 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,4 @@ { - "$schema": "https://www.schemastore.org/package.json", "private": true, "type": "module", "scripts": { @@ -7,11 +6,25 @@ "dev": "vite" }, "dependencies": { + "@codemirror/commands": "^6.6.0", + "@codemirror/lang-css": "^6.2.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.10.1", + "@codemirror/state": "^6.4.1", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.33.0", + "alpinejs": "^3.14.0", + "axios": "^1.7.4" + }, + "devDependencies": { "@tailwindcss/vite": "^4.1.11", "autoprefixer": "^10.4.20", - "axios": "^1.7.4", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0", + "postcss": "^8.4.38", "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..95d5cc8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -2,8 +2,8 @@ + colors="true"> + tests/Unit @@ -12,25 +12,26 @@ tests/Feature + app + - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeed..5b11777 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -9,9 +9,9 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - - --color-zinc-50: #fafafa; + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --color-zinc-50: #fafafa; --color-zinc-100: #f5f5f5; --color-zinc-200: #e5e5e5; --color-zinc-300: #d4d4d4; @@ -22,22 +22,20 @@ --color-zinc-800: #262626; --color-zinc-900: #171717; --color-zinc-950: #0a0a0a; - - --color-accent: var(--color-neutral-800); - --color-accent-content: var(--color-neutral-800); - --color-accent-foreground: var(--color-white); + --color-accent: var(--color-neutral-800); + --color-accent-content: var(--color-neutral-800); + --color-accent-foreground: var(--color-white); } @layer theme { .dark { - --color-accent: var(--color-white); - --color-accent-content: var(--color-white); - --color-accent-foreground: var(--color-neutral-800); + --color-accent: var(--color-white); + --color-accent-content: var(--color-white); + --color-accent-foreground: var(--color-neutral-800); } } @layer base { - *, ::after, ::before, @@ -52,7 +50,7 @@ } [data-flux-label] { - @apply !mb-0 !leading-tight; + @apply !mb-0 !leading-tight; } input:focus[data-flux-control], @@ -61,6 +59,13 @@ select:focus[data-flux-control] { @apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground; } -/* \[:where(&)\]:size-4 { - @apply size-4; -} */ +/* ── CodeMirror 6 ─────────────────────────────────────────────────────────── */ +.cm-editor { + height: 100%; + font-size: 0.85rem; +} + +.cm-scroller { + overflow: auto; + font-family: ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace; +} diff --git a/resources/js/app.js b/resources/js/app.js index e69de29..286830d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -0,0 +1,7 @@ +/** + * app.js — global entry point loaded on every page. + * + * Alpine is provided by @fluxScripts (Livewire/Flux bundle). + * Do NOT import or start Alpine here. + */ +import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..953d266 --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,3 @@ +import axios from 'axios'; +window.axios = axios; +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/js/file-manager.js b/resources/js/file-manager.js new file mode 100644 index 0000000..96a70a3 --- /dev/null +++ b/resources/js/file-manager.js @@ -0,0 +1,164 @@ +/** + * file-manager.js — loaded only on the file manager page. + * + * Alpine is provided by @fluxScripts (bundled with Livewire/Flux). + * We must NOT import or start our own Alpine — Flux registers its modal + * plugin on its own Alpine instance. We hook into it via alpine:init. + * + * CodeMirror 6 is tree-shaken and bundled here by Vite. + */ + +import { EditorState, Compartment } from '@codemirror/state'; +import { + EditorView, keymap, lineNumbers, + highlightActiveLineGutter, highlightActiveLine, + drawSelection, dropCursor, rectangularSelection, + crosshairCursor, highlightSpecialChars, +} from '@codemirror/view'; +import { + defaultKeymap, history, historyKeymap, indentWithTab, +} from '@codemirror/commands'; +import { + indentOnInput, bracketMatching, foldGutter, + syntaxHighlighting, defaultHighlightStyle, +} from '@codemirror/language'; +import { html } from '@codemirror/lang-html'; +import { css } from '@codemirror/lang-css'; +import { javascript } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { xml } from '@codemirror/lang-xml'; +import { oneDark } from '@codemirror/theme-one-dark'; + +// ── CodeMirror state ────────────────────────────────────────────────────────── + +const langCompartment = new Compartment(); +let editorView = null; +let saveDebounce = null; + +function publishCursor(state) { + const head = state.selection.main.head; + const line = state.doc.lineAt(head); + window.dispatchEvent(new CustomEvent('cm-cursor', { + detail: { line: line.number, col: head - line.from + 1, total: state.doc.lines }, + })); +} + +function langExt(lang) { + switch (lang) { + case 'html': return html({ matchClosingTags: true, autoCloseTags: true }); + case 'css': return css(); + case 'javascript': return javascript(); + case 'json': return json(); + case 'xml': return xml(); + default: return []; + } +} + +function getWire() { + const el = document.querySelector('[wire\\:id]'); + return el ? window.Livewire?.find(el.getAttribute('wire:id')) : null; +} + +function triggerSave() { + if (!editorView) return; + getWire()?.call('saveFile', editorView.state.doc.toString()); +} + +export function createEditor(content, lang) { + const host = document.getElementById('cm-host'); + if (!host) return; + + editorView?.destroy(); + editorView = null; + + editorView = new EditorView({ + state: EditorState.create({ + doc: content, + extensions: [ + oneDark, + EditorView.theme({ + '&': { height: '100%' }, + '.cm-scroller': { overflow: 'auto', fontFamily: "ui-monospace, 'Cascadia Code', 'JetBrains Mono', Menlo, monospace" }, + '.cm-content': { padding: '8px 0' }, + '.cm-gutters': { background: 'transparent', border: 'none' }, + }), + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + highlightSpecialChars(), + drawSelection(), + dropCursor(), + history(), + indentOnInput(), + bracketMatching(), + foldGutter(), + rectangularSelection(), + crosshairCursor(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + langCompartment.of(langExt(lang)), + keymap.of([ + { key: 'Mod-s', run() { triggerSave(); return true; } }, + ...defaultKeymap, + ...historyKeymap, + indentWithTab, + ]), + EditorView.updateListener.of(update => { + if (update.selectionSet) publishCursor(update.state); + if (!update.docChanged) return; + getWire()?.set('isDirty', true); + clearTimeout(saveDebounce); + saveDebounce = setTimeout(triggerSave, 1200); + }), + ], + }), + parent: host, + }); + + publishCursor(editorView.state); +} + +// ── Alpine component — registered on Flux/Livewire's Alpine instance ────────── +// +// We use alpine:init so our data component is registered BEFORE Alpine +// processes the DOM, but AFTER Flux has registered its own plugins +// (including x-flux-modal and $dispatch helpers). + +document.addEventListener('alpine:init', () => { + // Safety guard: window.Alpine is set by @fluxScripts before this fires. + if (!window.Alpine) return; + + window.Alpine.data('fileManager', () => ({ + openDirs: [], + ctx: { show: false, x: 0, y: 0, item: null }, + cursor: { line: 1, col: 1 }, + + isOpen(path) { return this.openDirs.includes(path); }, + toggleDir(path) { + this.openDirs = this.isOpen(path) + ? this.openDirs.filter(p => p !== path) + : [...this.openDirs, path]; + }, + + openContext(e, item) { + this.ctx = { show: true, x: e.clientX, y: e.clientY, item }; + }, + closeContext() { + this.ctx = { show: false, x: 0, y: 0, item: null }; + }, + + init() { + window.addEventListener('cm-cursor', e => { this.cursor = e.detail; }); + + window.addEventListener('load-editor', e => { + createEditor(e.detail.content, e.detail.language); + }); + + window.addEventListener('click', () => this.closeContext()); + window.addEventListener('keydown', e => { + if (e.key === 'Escape') this.closeContext(); + }); + }, + + save() { triggerSave(); }, + })); +}); diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php new file mode 100644 index 0000000..fe7950a --- /dev/null +++ b/resources/views/auth/confirm-password.blade.php @@ -0,0 +1,25 @@ + + + Confirm your password +

+ Please confirm your password before continuing. +

+ +
+ @csrf + + + Password + + @error('password') + {{ $message }} + @enderror + + + + Confirm + +
+ +
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..6d2ad83 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,35 @@ + + + Reset your password +

+ Enter your email and we'll send a reset link. +

+ + @if (session('status')) + + {{ session('status') }} + + @endif + +
+ @csrf + + + Email address + + @error('email') + {{ $message }} + @enderror + + + + Send reset link + +
+ +

+ ← Back to login +

+ +
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..bfb627e --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,51 @@ + + + Welcome back + + @if (session('status')) + + {{ session('status') }} + + @endif + +
+ @csrf + + + Email address + + @error('email') + {{ $message }} + @enderror + + + +
+ Password + @if (Route::has('password.request')) + + Forgot password? + + @endif +
+ + @error('password') + {{ $message }} + @enderror +
+ + + + + Log in + + + +

+ No account? + Register +

+ +
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..842ce4a --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,54 @@ + + + Create your account + +
+ @csrf + + + Name + + @error('name') + {{ $message }} + @enderror + + + + Email address + + @error('email') + {{ $message }} + @enderror + + + + Password + + @error('password') + {{ $message }} + @enderror + + + + Confirm password + + @error('password_confirmation') + {{ $message }} + @enderror + + + + Create account + +
+ +

+ Already have an account? + Log in +

+ +
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..0df05cb --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,39 @@ + + + Choose a new password + +
+ @csrf + + + + Email address + + @error('email') + {{ $message }} + @enderror + + + + New password + + @error('password') + {{ $message }} + @enderror + + + + Confirm new password + + + + + Reset password + +
+ +
diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 0000000..e0f9608 --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,30 @@ + + + Verify your email +

+ Check your inbox for a verification link. Didn't get one? +

+ + @if (session('status') === 'verification-link-sent') + + A new verification link has been sent to your email address. + + @endif + +
+
+ @csrf + + Resend verification email + +
+ +
+ @csrf + + Log out + +
+
+ +
diff --git a/resources/views/components/action-message.blade.php b/resources/views/components/action-message.blade.php deleted file mode 100644 index d313ee6..0000000 --- a/resources/views/components/action-message.blade.php +++ /dev/null @@ -1,14 +0,0 @@ -@props([ - 'on', -]) - -
merge(['class' => 'text-sm']) }} -> - {{ $slot->isEmpty() ? __('Saved.') : $slot }} -
diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php new file mode 100644 index 0000000..eb49845 --- /dev/null +++ b/resources/views/components/app-layout.blade.php @@ -0,0 +1,70 @@ +@props(['title' => config('app.name')]) + + + + + + + + {{ $title }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + @stack('head') + + + + + + + + + + + + + Dashboard + + + + + + + New Page + + +
+ @csrf + Logout +
+
+ + @if (session('success')) +
+ {{ session('success') }} + +
+ @endif + + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
{{ $slot }}
+ + @livewireScripts + @stack('scripts') + + diff --git a/resources/views/components/app-logo-icon.blade.php b/resources/views/components/app-logo-icon.blade.php deleted file mode 100644 index 0adc3a2..0000000 --- a/resources/views/components/app-logo-icon.blade.php +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php deleted file mode 100644 index 26e8f68..0000000 --- a/resources/views/components/app-logo.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -@props([ - 'sidebar' => false, -]) - -@if($sidebar) - - - - - -@else - - - - - -@endif diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php deleted file mode 100644 index e596a3f..0000000 --- a/resources/views/components/auth-header.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props([ - 'title', - 'description', -]) - -
- {{ $title }} - {{ $description }} -
diff --git a/resources/views/components/auth-layout.blade.php b/resources/views/components/auth-layout.blade.php new file mode 100644 index 0000000..6bc5859 --- /dev/null +++ b/resources/views/components/auth-layout.blade.php @@ -0,0 +1,29 @@ +@props(['title' => config('app.name')]) + + + + + + + + {{ $title }} — {{ config('app.name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + +
+ + +
+ {{ $slot }} +
+
+ + + diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php deleted file mode 100644 index 98e0011..0000000 --- a/resources/views/components/auth-session-status.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props([ - 'status', -]) - -@if ($status) -
merge(['class' => 'font-medium text-sm text-green-600']) }}> - {{ $status }} -
-@endif diff --git a/resources/views/components/desktop-user-menu.blade.php b/resources/views/components/desktop-user-menu.blade.php deleted file mode 100644 index 7fb3706..0000000 --- a/resources/views/components/desktop-user-menu.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - - - -
- -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
- - - - {{ __('Settings') }} - -
- @csrf - - {{ __('Log out') }} - -
-
-
-
diff --git a/resources/views/components/placeholder-pattern.blade.php b/resources/views/components/placeholder-pattern.blade.php deleted file mode 100644 index 8a434f0..0000000 --- a/resources/views/components/placeholder-pattern.blade.php +++ /dev/null @@ -1,12 +0,0 @@ -@props([ - 'id' => uniqid(), -]) - - - - - - - - - diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php deleted file mode 100644 index 3a65247..0000000 --- a/resources/views/components/settings/layout.blade.php +++ /dev/null @@ -1,20 +0,0 @@ -
-
- - {{ __('Profile') }} - {{ __('Security') }} - {{ __('Appearance') }} - -
- - - -
- {{ $heading ?? '' }} - {{ $subheading ?? '' }} - -
- {{ $slot }} -
-
-
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php deleted file mode 100644 index 8f08c05..0000000 --- a/resources/views/dashboard.blade.php +++ /dev/null @@ -1,18 +0,0 @@ - -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 0000000..02f090b --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,12 @@ +@php +$map = [ + 403 => ['title' => 'Access Forbidden', 'msg' => 'You don\'t have permission to view this page.'], + 404 => ['title' => 'Page Not Found', 'msg' => 'The page or file you\'re looking for doesn\'t exist.'], + 419 => ['title' => 'Session Expired', 'msg' => 'Your session has expired. Please refresh and try again.'], + 429 => ['title' => 'Too Many Requests', 'msg' => 'You\'re doing that too fast. Please wait a moment.'], + 500 => ['title' => 'Server Error', 'msg' => 'Something went wrong on our end. We\'ve been notified.'], + 503 => ['title' => 'Service Unavailable', 'msg' => 'We\'re doing some maintenance. Back shortly!'], +]; +$info = $map[403] ?? ['title' => 'Error', 'msg' => 'An unexpected error occurred.']; +@endphp +@include('errors.layout', ['code' => 403, 'title' => $info['title'], 'message' => $info['msg']]) diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 0000000..f7ccdea --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,12 @@ +@php +$map = [ + 403 => ['title' => 'Access Forbidden', 'msg' => 'You don\'t have permission to view this page.'], + 404 => ['title' => 'Page Not Found', 'msg' => 'The page or file you\'re looking for doesn\'t exist.'], + 419 => ['title' => 'Session Expired', 'msg' => 'Your session has expired. Please refresh and try again.'], + 429 => ['title' => 'Too Many Requests', 'msg' => 'You\'re doing that too fast. Please wait a moment.'], + 500 => ['title' => 'Server Error', 'msg' => 'Something went wrong on our end. We\'ve been notified.'], + 503 => ['title' => 'Service Unavailable', 'msg' => 'We\'re doing some maintenance. Back shortly!'], +]; +$info = $map[404] ?? ['title' => 'Error', 'msg' => 'An unexpected error occurred.']; +@endphp +@include('errors.layout', ['code' => 404, 'title' => $info['title'], 'message' => $info['msg']]) diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php new file mode 100644 index 0000000..781a3a1 --- /dev/null +++ b/resources/views/errors/419.blade.php @@ -0,0 +1,12 @@ +@php +$map = [ + 403 => ['title' => 'Access Forbidden', 'msg' => 'You don\'t have permission to view this page.'], + 404 => ['title' => 'Page Not Found', 'msg' => 'The page or file you\'re looking for doesn\'t exist.'], + 419 => ['title' => 'Session Expired', 'msg' => 'Your session has expired. Please refresh and try again.'], + 429 => ['title' => 'Too Many Requests', 'msg' => 'You\'re doing that too fast. Please wait a moment.'], + 500 => ['title' => 'Server Error', 'msg' => 'Something went wrong on our end. We\'ve been notified.'], + 503 => ['title' => 'Service Unavailable', 'msg' => 'We\'re doing some maintenance. Back shortly!'], +]; +$info = $map[419] ?? ['title' => 'Error', 'msg' => 'An unexpected error occurred.']; +@endphp +@include('errors.layout', ['code' => 419, 'title' => $info['title'], 'message' => $info['msg']]) diff --git a/resources/views/errors/429.blade.php b/resources/views/errors/429.blade.php new file mode 100644 index 0000000..ca20a7d --- /dev/null +++ b/resources/views/errors/429.blade.php @@ -0,0 +1,12 @@ +@php +$map = [ + 403 => ['title' => 'Access Forbidden', 'msg' => 'You don\'t have permission to view this page.'], + 404 => ['title' => 'Page Not Found', 'msg' => 'The page or file you\'re looking for doesn\'t exist.'], + 419 => ['title' => 'Session Expired', 'msg' => 'Your session has expired. Please refresh and try again.'], + 429 => ['title' => 'Too Many Requests', 'msg' => 'You\'re doing that too fast. Please wait a moment.'], + 500 => ['title' => 'Server Error', 'msg' => 'Something went wrong on our end. We\'ve been notified.'], + 503 => ['title' => 'Service Unavailable', 'msg' => 'We\'re doing some maintenance. Back shortly!'], +]; +$info = $map[429] ?? ['title' => 'Error', 'msg' => 'An unexpected error occurred.']; +@endphp +@include('errors.layout', ['code' => 429, 'title' => $info['title'], 'message' => $info['msg']]) diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php new file mode 100644 index 0000000..84155d2 --- /dev/null +++ b/resources/views/errors/500.blade.php @@ -0,0 +1,12 @@ +@php +$map = [ + 403 => ['title' => 'Access Forbidden', 'msg' => 'You don\'t have permission to view this page.'], + 404 => ['title' => 'Page Not Found', 'msg' => 'The page or file you\'re looking for doesn\'t exist.'], + 419 => ['title' => 'Session Expired', 'msg' => 'Your session has expired. Please refresh and try again.'], + 429 => ['title' => 'Too Many Requests', 'msg' => 'You\'re doing that too fast. Please wait a moment.'], + 500 => ['title' => 'Server Error', 'msg' => 'Something went wrong on our end. We\'ve been notified.'], + 503 => ['title' => 'Service Unavailable', 'msg' => 'We\'re doing some maintenance. Back shortly!'], +]; +$info = $map[500] ?? ['title' => 'Error', 'msg' => 'An unexpected error occurred.']; +@endphp +@include('errors.layout', ['code' => 500, 'title' => $info['title'], 'message' => $info['msg']]) diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 0000000..3c9989f --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,12 @@ +@php +$map = [ + 403 => ['title' => 'Access Forbidden', 'msg' => 'You don\'t have permission to view this page.'], + 404 => ['title' => 'Page Not Found', 'msg' => 'The page or file you\'re looking for doesn\'t exist.'], + 419 => ['title' => 'Session Expired', 'msg' => 'Your session has expired. Please refresh and try again.'], + 429 => ['title' => 'Too Many Requests', 'msg' => 'You\'re doing that too fast. Please wait a moment.'], + 500 => ['title' => 'Server Error', 'msg' => 'Something went wrong on our end. We\'ve been notified.'], + 503 => ['title' => 'Service Unavailable', 'msg' => 'We\'re doing some maintenance. Back shortly!'], +]; +$info = $map[503] ?? ['title' => 'Error', 'msg' => 'An unexpected error occurred.']; +@endphp +@include('errors.layout', ['code' => 503, 'title' => $info['title'], 'message' => $info['msg']]) diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php new file mode 100644 index 0000000..f5a09ae --- /dev/null +++ b/resources/views/errors/layout.blade.php @@ -0,0 +1,43 @@ + + + + + + {{ $code }} — {{ config('app.name') }} + + + +
+
{{ $code }}
+

{{ $title }}

+

{{ $message }}

+ ← Back home +
+ + diff --git a/resources/views/flux/icon/book-open-text.blade.php b/resources/views/flux/icon/book-open-text.blade.php deleted file mode 100644 index bff20a3..0000000 --- a/resources/views/flux/icon/book-open-text.blade.php +++ /dev/null @@ -1,47 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - - - diff --git a/resources/views/flux/icon/chevrons-up-down.blade.php b/resources/views/flux/icon/chevrons-up-down.blade.php deleted file mode 100644 index bf1ba2b..0000000 --- a/resources/views/flux/icon/chevrons-up-down.blade.php +++ /dev/null @@ -1,43 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - diff --git a/resources/views/flux/icon/folder-git-2.blade.php b/resources/views/flux/icon/folder-git-2.blade.php deleted file mode 100644 index 292171b..0000000 --- a/resources/views/flux/icon/folder-git-2.blade.php +++ /dev/null @@ -1,45 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - diff --git a/resources/views/flux/icon/layout-grid.blade.php b/resources/views/flux/icon/layout-grid.blade.php deleted file mode 100644 index 88c5698..0000000 --- a/resources/views/flux/icon/layout-grid.blade.php +++ /dev/null @@ -1,45 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - diff --git a/resources/views/flux/navlist/group.blade.php b/resources/views/flux/navlist/group.blade.php deleted file mode 100644 index 5e691a2..0000000 --- a/resources/views/flux/navlist/group.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -@props([ - 'expandable' => false, - 'expanded' => true, - 'heading' => null, -]) - - - -class('group/disclosure') }} - @if ($expanded === true) open @endif - data-flux-navlist-group -> - - - - - - - -
class('block space-y-[2px]') }}> -
-
{{ $heading }}
-
- -
- {{ $slot }} -
-
- - - -
class('block space-y-[2px]') }}> - {{ $slot }} -
- - diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php deleted file mode 100644 index 037dd1b..0000000 --- a/resources/views/layouts/app.blade.php +++ /dev/null @@ -1,5 +0,0 @@ - - - {{ $slot }} - - diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php deleted file mode 100644 index e1f84d9..0000000 --- a/resources/views/layouts/app/header.blade.php +++ /dev/null @@ -1,78 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - {{ __('Repository') }} - - - {{ __('Documentation') }} - - - - - {{ $slot }} - - @fluxScripts - - diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php deleted file mode 100644 index 3d04ed1..0000000 --- a/resources/views/layouts/app/sidebar.blade.php +++ /dev/null @@ -1,95 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - {{ __('Repository') }} - - - - {{ __('Documentation') }} - - - - - - - - - - - - - - - - -
-
- - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - - {{ __('Settings') }} - - - - - -
- @csrf - - {{ __('Log out') }} - -
-
-
-
- - {{ $slot }} - - @fluxScripts - - diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php deleted file mode 100644 index 7150091..0000000 --- a/resources/views/layouts/auth.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - {{ $slot }} - diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php deleted file mode 100644 index db94716..0000000 --- a/resources/views/layouts/auth/card.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - @include('partials.head') - - - - @fluxScripts - - diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php deleted file mode 100644 index 6e0d909..0000000 --- a/resources/views/layouts/auth/simple.blade.php +++ /dev/null @@ -1,22 +0,0 @@ - - - - @include('partials.head') - - - - @fluxScripts - - diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php deleted file mode 100644 index 4e9788b..0000000 --- a/resources/views/layouts/auth/split.blade.php +++ /dev/null @@ -1,43 +0,0 @@ - - - - @include('partials.head') - - -
- - -
- @fluxScripts - - diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php deleted file mode 100644 index 13dd110..0000000 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ /dev/null @@ -1,28 +0,0 @@ - -
- - - - -
- @csrf - - - - - {{ __('Confirm') }} - - -
-
diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php deleted file mode 100644 index 5f1cdad..0000000 --- a/resources/views/livewire/auth/forgot-password.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - -
- - - - - -
- @csrf - - - - - - {{ __('Email password reset link') }} - - - -
- {{ __('Or, return to') }} - {{ __('log in') }} -
-
-
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php deleted file mode 100644 index e9167da..0000000 --- a/resources/views/livewire/auth/login.blade.php +++ /dev/null @@ -1,59 +0,0 @@ - -
- - - - - -
- @csrf - - - - - -
- - - @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif -
- - - - -
- - {{ __('Log in') }} - -
- - - @if (Route::has('register')) -
- {{ __('Don\'t have an account?') }} - {{ __('Sign up') }} -
- @endif -
-
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php deleted file mode 100644 index 6c12579..0000000 --- a/resources/views/livewire/auth/register.blade.php +++ /dev/null @@ -1,67 +0,0 @@ - -
- - - - - -
- @csrf - - - - - - - - - - - - -
- - {{ __('Create account') }} - -
- - -
- {{ __('Already have an account?') }} - {{ __('Log in') }} -
-
-
diff --git a/resources/views/livewire/auth/reset-password.blade.php b/resources/views/livewire/auth/reset-password.blade.php deleted file mode 100644 index e649471..0000000 --- a/resources/views/livewire/auth/reset-password.blade.php +++ /dev/null @@ -1,52 +0,0 @@ - -
- - - - - -
- @csrf - - - - - - - - - - - - -
- - {{ __('Reset password') }} - -
- -
-
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php deleted file mode 100644 index 0726c0b..0000000 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ /dev/null @@ -1,95 +0,0 @@ - -
-
-
- -
- -
- -
- -
- @csrf - -
-
-
- -
-
- -
-
- -
- - @error('recovery_code') - - {{ $message }} - - @enderror -
- - - {{ __('Continue') }} - -
- -
- {{ __('or you can') }} -
- {{ __('login using a recovery code') }} - {{ __('login using an authentication code') }} -
-
-
-
-
-
diff --git a/resources/views/livewire/auth/verify-email.blade.php b/resources/views/livewire/auth/verify-email.blade.php deleted file mode 100644 index 50e7b44..0000000 --- a/resources/views/livewire/auth/verify-email.blade.php +++ /dev/null @@ -1,29 +0,0 @@ - -
- - {{ __('Please verify your email address by clicking on the link we just emailed to you.') }} - - - @if (session('status') == 'verification-link-sent') - - {{ __('A new verification link has been sent to the email address you provided during registration.') }} - - @endif - -
-
- @csrf - - {{ __('Resend verification email') }} - -
- -
- @csrf - - {{ __('Log out') }} - -
-
-
-
diff --git a/resources/views/livewire/file-manager.blade.php b/resources/views/livewire/file-manager.blade.php new file mode 100644 index 0000000..8c247e2 --- /dev/null +++ b/resources/views/livewire/file-manager.blade.php @@ -0,0 +1,660 @@ +{{-- + File Manager — full-screen IDE layout. + + Alpine root: fileManager() from resources/js/file-manager.js + Livewire: App\Livewire\FileManager + + Conventions: + - x-on: prefix everywhere (avoids @livewire Blade directive collision) + - Flux modals for new-file, new-folder, rename, delete-confirm, settings + - Context menu fully in Alpine +--}} + +
+ + {{-- ═══════════════════════════════════════════════════════════════════════ + TOP BAR + ═══════════════════════════════════════════════════════════════════════ --}} +
+ + {{-- Back --}} + + + + + +
+ + {{-- Page info --}} +
+ {{ $page->name }} + + {{ $page->slug }} + + @if ($page->is_public) + + @else + + @endif +
+ +
+ + {{-- Unsaved indicator --}} + @if ($isDirty) + + + Unsaved + + @endif + + {{-- Preview --}} + + + {{-- Open in new tab --}} + + + {{-- Settings --}} + +
+ + + {{-- ═══════════════════════════════════════════════════════════════════════ + MAIN SPLIT PANE + ═══════════════════════════════════════════════════════════════════════ --}} +
+ + {{-- ── LEFT SIDEBAR ──────────────────────────────────────────────── --}} + + + + {{-- ── CENTER EDITOR PANE ──────────────────────────────────────── --}} +
+ + {{-- Editor toolbar --}} +
+ @if ($activeFile) + + + {{ $activeFile }} + + @else + No file open + @endif + +
+ @if ($isDirty) + + @elseif ($activeFile && $this->isEditable) + + + Saved + + @endif + + @if ($activeFile && $this->isPreviewable && !$this->isEditable) + + + Open + + @endif +
+
+ + {{-- ── Content area ── --}} + + @if ($this->isEditable && $activeFile) + {{-- CodeMirror host --}} +
+ + @elseif ($this->isPreviewable && $activeFile) + {{-- Media / binary preview --}} + @php $ext = strtolower(pathinfo($activeFile, PATHINFO_EXTENSION)); @endphp +
+ @if (in_array($ext, ['png','jpg','jpeg','gif','webp','ico'])) + + @elseif ($ext === 'svg') + + @elseif (in_array($ext, ['mp4','webm'])) + + @elseif (in_array($ext, ['mp3','wav','ogg'])) +
+ +

{{ basename($activeFile) }}

+ +
+ @elseif ($ext === 'pdf') + + @endif +
+ + @elseif ($activeFile) + {{-- Non-previewable binary --}} +
+ +

{{ basename($activeFile) }}

+

Binary file — cannot be edited in browser

+
+ + @else + {{-- Empty state --}} +
+ +

No file open

+

Select a file from the explorer to start editing

+ +
+ @endif + +
+ +
+ + + {{-- ═══════════════════════════════════════════════════════════════════════ + STATUS BAR + ═══════════════════════════════════════════════════════════════════════ --}} +
+
+ @if ($activeFile) + {{ basename($activeFile) }} + @if ($this->isEditable) + + Ln 1, + Col 1 + + {{ $this->editorLanguage }} + @endif + @else + {{ config('app.name') }} + @endif +
+
+ {{ $page->slug }} + + {{ $page->is_public ? '🌐 Public' : '🔒 Private' }} + +
+
+ + + {{-- ═══════════════════════════════════════════════════════════════════════ + CONTEXT MENU (Alpine, fully client-side) + ═══════════════════════════════════════════════════════════════════════ --}} + + + + {{-- ═══════════════════════════════════════════════════════════════════════ + FLASH TOAST + ═══════════════════════════════════════════════════════════════════════ --}} + + + + {{-- ═══════════════════════════════════════════════════════════════════════ + MODALS + ═══════════════════════════════════════════════════════════════════════ --}} + + {{-- ── New File ────────────────────────────────────────────────────── --}} + +
+ New File + + Create a new file in + + {{ $currentDir ?: '/' }} + + + + + File name + + @error('newFileName') {{ $message }} @enderror + + +
+ + Create File + + + Cancel + +
+
+
+ + {{-- ── New Folder ──────────────────────────────────────────────────── --}} + +
+ New Folder + Create a folder inside + + {{ $currentDir ?: '/' }} + + + + + Folder name + + @error('newFolderName') {{ $message }} @enderror + + +
+ + Create Folder + + + Cancel + +
+
+
+ + {{-- ── Upload ──────────────────────────────────────────────────────── --}} + +
+ Upload Files + + Upload to {{ $currentDir ?: '/' }} + — max {{ config('filesystems.max_upload_mb', 50) }} MB per file + + + + +
+ + + + + Uploading… +
+ + @error('upload') + + {{ $message }} + + @enderror + +
+ + Done + +
+
+
+ + {{-- ── Delete Confirmation ─────────────────────────────────────────── --}} + +
+
+
+ +
+
+ + Delete {{ $deleteTargetType === 'dir' ? 'folder' : 'file' }}? + +

+ + {{ $deleteTargetName }} + + will be permanently deleted. + @if ($deleteTargetType === 'dir') + All files inside will be lost. + @endif +

+
+
+ +
+ + Delete + + + Cancel + +
+
+
+ + {{-- ── Page Settings ───────────────────────────────────────────────── --}} + +
+ Page Settings + + Configure your page's name, URL slug, and visibility. + + +
+ + Page Name + + @error('pageName') {{ $message }} @enderror + + + + Slug + + @error('pageSlug') {{ $message }} @enderror + + {{ config('app.base_domain') }}/{{ $pageSlug }} + + + + + + Publicly accessible + When off, only you can view this page. + +
+ + {{-- URLs --}} +
+

Access URLs

+ + +
+ +
+ + Save Settings + Saving… + + + Cancel + +
+ +
+ + + Delete this page + +
+
+
+ +
diff --git a/resources/views/livewire/partials/tree-item.blade.php b/resources/views/livewire/partials/tree-item.blade.php new file mode 100644 index 0000000..4c8bd83 --- /dev/null +++ b/resources/views/livewire/partials/tree-item.blade.php @@ -0,0 +1,136 @@ +{{-- + Recursive file-tree item. + Variables: $item (array), $depth (int, default 0) + Alpine context: fileManager() component on ancestor div. +--}} +@php +$depth ??= 0; +$pl = 8 + $depth * 16; + +// File-type icon + colour mapping (Flux/Heroicons) +$ext = strtolower(pathinfo($item['name'], PATHINFO_EXTENSION)); +$iconMap = [ + 'html' => ['name' => 'globe-alt', 'class' => 'text-orange-400'], + 'htm' => ['name' => 'globe-alt', 'class' => 'text-orange-400'], + 'css' => ['name' => 'swatch', 'class' => 'text-sky-400'], + 'js' => ['name' => 'bolt', 'class' => 'text-yellow-400'], + 'json' => ['name' => 'document-text', 'class' => 'text-amber-500'], + 'md' => ['name' => 'document-text', 'class' => 'text-zinc-400'], + 'txt' => ['name' => 'document-text', 'class' => 'text-zinc-400'], + 'xml' => ['name' => 'code-bracket', 'class' => 'text-green-400'], + 'svg' => ['name' => 'swatch', 'class' => 'text-pink-400'], + 'png' => ['name' => 'photo', 'class' => 'text-violet-400'], + 'jpg' => ['name' => 'photo', 'class' => 'text-violet-400'], + 'jpeg' => ['name' => 'photo', 'class' => 'text-violet-400'], + 'gif' => ['name' => 'photo', 'class' => 'text-violet-400'], + 'webp' => ['name' => 'photo', 'class' => 'text-violet-400'], + 'ico' => ['name' => 'photo', 'class' => 'text-violet-400'], + 'mp4' => ['name' => 'film', 'class' => 'text-red-400'], + 'webm' => ['name' => 'film', 'class' => 'text-red-400'], + 'mp3' => ['name' => 'musical-note', 'class' => 'text-emerald-400'], + 'wav' => ['name' => 'musical-note', 'class' => 'text-emerald-400'], + 'ogg' => ['name' => 'musical-note', 'class' => 'text-emerald-400'], + 'pdf' => ['name' => 'document', 'class' => 'text-red-500'], + 'woff' => ['name' => 'document', 'class' => 'text-zinc-500'], + 'woff2' => ['name' => 'document', 'class' => 'text-zinc-500'], +]; +$fileIcon = $item['isIndex'] ?? false + ? ['name' => 'home', 'class' => 'text-emerald-400'] + : ($iconMap[$ext] ?? ['name' => 'document', 'class' => 'text-zinc-500']); +@endphp + +@if ($item['type'] === 'dir') +{{-- ── DIRECTORY ──────────────────────────────────────────────────────── --}} +
+
+ {{-- Chevron --}} + + + + + {{-- Folder icon --}} + + + + {{-- Name --}} + {{ $item['name'] }} + + {{-- Hover actions --}} + +
+ + {{-- Children --}} +
+ @forelse ($item['children'] ?? [] as $child) + @include('livewire.partials.tree-item', ['item' => $child, 'depth' => $depth + 1]) + @empty +

+ Empty +

+ @endforelse +
+
+ +@else +{{-- ── FILE ────────────────────────────────────────────────────────────── --}} +
+ {{-- File icon --}} + + + + + {{-- Name --}} + + {{ $item['name'] }} + + + {{-- Hover actions --}} + +
+@endif diff --git a/resources/views/livewire/settings/appearance.blade.php b/resources/views/livewire/settings/appearance.blade.php deleted file mode 100644 index 86c7ec7..0000000 --- a/resources/views/livewire/settings/appearance.blade.php +++ /dev/null @@ -1,13 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Appearance settings') }} - - - - {{ __('Light') }} - {{ __('Dark') }} - {{ __('System') }} - - -
diff --git a/resources/views/livewire/settings/delete-user-form.blade.php b/resources/views/livewire/settings/delete-user-form.blade.php deleted file mode 100644 index a641407..0000000 --- a/resources/views/livewire/settings/delete-user-form.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -
-
- {{ __('Delete account') }} - {{ __('Delete your account and all of its resources') }} -
- - - - {{ __('Delete account') }} - - - - -
-
- {{ __('Are you sure you want to delete your account?') }} - - - {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} - -
- - - -
- - {{ __('Cancel') }} - - - {{ __('Delete account') }} -
- -
-
diff --git a/resources/views/livewire/settings/profile.blade.php b/resources/views/livewire/settings/profile.blade.php deleted file mode 100644 index 3b36a74..0000000 --- a/resources/views/livewire/settings/profile.blade.php +++ /dev/null @@ -1,47 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Profile settings') }} - - -
- - -
- - - @if ($this->hasUnverifiedEmail) -
- - {{ __('Your email address is unverified.') }} - - - {{ __('Click here to re-send the verification email.') }} - - - - @if (session('status') === 'verification-link-sent') - - {{ __('A new verification link has been sent to your email address.') }} - - @endif -
- @endif -
- -
-
- {{ __('Save') }} -
- - - {{ __('Saved.') }} - -
- - - @if ($this->showDeleteUser) - - @endif -
-
diff --git a/resources/views/livewire/settings/security.blade.php b/resources/views/livewire/settings/security.blade.php deleted file mode 100644 index 9e973d3..0000000 --- a/resources/views/livewire/settings/security.blade.php +++ /dev/null @@ -1,239 +0,0 @@ -
- @include('partials.settings-heading') - - {{ __('Security settings') }} - - -
- - - - -
-
- {{ __('Save') }} -
- - - {{ __('Saved.') }} - -
- - - @if ($canManageTwoFactor) -
- {{ __('Two-factor authentication') }} - {{ __('Manage your two-factor authentication settings') }} - -
- @if ($twoFactorEnabled) -
- - {{ __('You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} - - -
- - {{ __('Disable 2FA') }} - -
- - -
- @else -
- - {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - - - - {{ __('Enable 2FA') }} - -
- @endif -
-
- - -
-
-
-
-
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
- -
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
- - -
-
- -
- {{ $this->modalConfig['title'] }} - {{ $this->modalConfig['description'] }} -
-
- - @if ($showVerificationStep) -
-
- -
- -
- - {{ __('Back') }} - - - - {{ __('Confirm') }} - -
-
- @else - @error('setupData') - - @enderror - -
-
- @empty($qrCodeSvg) -
- -
- @else -
-
- {!! $qrCodeSvg !!} -
-
- @endempty -
-
- -
- - {{ $this->modalConfig['buttonText'] }} - -
- -
-
-
- - {{ __('or, enter the code manually') }} - -
- -
-
- @empty($manualSetupKey) -
- -
- @else - - - - @endempty -
-
-
- @endif -
-
- @endif -
-
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php deleted file mode 100644 index 9ed2674..0000000 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ /dev/null @@ -1,89 +0,0 @@ -
-
-
- - {{ __('2FA recovery codes') }} -
- - {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} - -
- -
-
- - - - {{ __('Hide recovery codes') }} - - - @if (filled($recoveryCodes)) - - {{ __('Regenerate codes') }} - - @endif -
- -
-
- @error('recoveryCodes') - - @enderror - - @if (filled($recoveryCodes)) -
- @foreach($recoveryCodes as $code) -
- {{ $code }} -
- @endforeach -
- - {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.') }} - - @endif -
-
-
-
diff --git a/resources/views/pages/create.blade.php b/resources/views/pages/create.blade.php new file mode 100644 index 0000000..5e33c83 --- /dev/null +++ b/resources/views/pages/create.blade.php @@ -0,0 +1,49 @@ + + +
+ + Create New Page + +
+ @csrf + + + Page Name + + @error('name') + {{ $message }} + @enderror + + + + + Slug + optional + +
+ + {{ config('app.base_domain') }}/ + + +
+ Leave blank to auto-generate from the page name. + @error('slug') + {{ $message }} + @enderror +
+ +
+ + Create Page + + + Cancel + +
+
+ +
+ +
diff --git a/resources/views/pages/dashboard.blade.php b/resources/views/pages/dashboard.blade.php new file mode 100644 index 0000000..118a1f0 --- /dev/null +++ b/resources/views/pages/dashboard.blade.php @@ -0,0 +1,71 @@ + + +
+ +
+ Your Pages + + New Page + +
+ + @if ($pages->isEmpty()) +
+

📄

+ No pages yet. + + Create your first page → + +
+ @else +
+ @foreach ($pages as $page) +
+ +
+
+ {{ $page->name }} + + {{ $page->slug }} + +
+ + {{ $page->is_public ? 'Public' : 'Private' }} + +
+ +

+ Updated {{ $page->updated_at->diffForHumans() }} +

+ +
+ + Edit + + + Preview + +
+ @csrf @method('DELETE') + + +
+
+ @endforeach +
+ @endif + +
+ +
diff --git a/resources/views/pages/manager.blade.php b/resources/views/pages/manager.blade.php new file mode 100644 index 0000000..99fbefc --- /dev/null +++ b/resources/views/pages/manager.blade.php @@ -0,0 +1,25 @@ + + + + + + + {{ $page->name }} — File Manager + @vite(['resources/css/app.css', 'resources/js/file-manager.js']) + @livewireStyles + + + + + + + @livewireScripts + + diff --git a/resources/views/pages/preview.blade.php b/resources/views/pages/preview.blade.php new file mode 100644 index 0000000..0afca78 --- /dev/null +++ b/resources/views/pages/preview.blade.php @@ -0,0 +1,136 @@ + + + + + + Preview — {{ $page->name }} + @vite(['resources/css/app.css']) + + + + + {{-- ── Toolbar ─────────────────────────────────────────────────────────── --}} +
+ + {{-- Back --}} + + + + + Editor + + +
+ + {{-- URL bar --}} +
+ + + + + +
+ + {{-- Device toggles --}} +
+ +
+ + {{-- Reload --}} + + + {{-- Open externally --}} + + + + + +
+ + {{-- ── Preview frame ───────────────────────────────────────────────────── --}} +
+
+ +
+
+ + + diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php deleted file mode 100644 index 52703a4..0000000 --- a/resources/views/partials/head.blade.php +++ /dev/null @@ -1,16 +0,0 @@ - - - - - {{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }} - - - - - - - - - -@vite(['resources/css/app.css', 'resources/js/app.js']) -@fluxAppearance diff --git a/resources/views/partials/settings-heading.blade.php b/resources/views/partials/settings-heading.blade.php deleted file mode 100644 index 925ace9..0000000 --- a/resources/views/partials/settings-heading.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -
- {{ __('Settings') }} - {{ __('Manage your profile and account settings') }} - -
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index c7b6eb4..d96bf6c 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -1,278 +1,49 @@ - - - - - - {{ __('Welcome') }} - {{ config('app.name', 'Laravel') }} - - - - - - - - - - - - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + {{ config('app.name') }} — Host your static pages instantly + @vite(['resources/css/app.css']) + + + +
+ +

+ {{ config('app.name') }} +

+ +

+ Create and host static HTML pages instantly.
+ Each page gets its own subdomain and path URL. +

+ +
+ + Get Started — Free + + + Log In + +
- {{-- Dark Mode 12 SVG --}} - -
-
-
+
+ @foreach ([ + ['⚡', 'Instant Deploy', 'Edit files in-browser — changes go live immediately.'], + ['🌐', 'Dual URLs', 'Subdomain + path-based access. Choose your style.'], + ['🗂', 'File Manager', 'Upload images, videos, code. Manage everything visually.'], + ] as [$icon, $title, $desc]) +
+
{{ $icon }}
+ {{ $title }} +

{{ $desc }}

+
+ @endforeach
- @if (Route::has('login')) - - @endif - +
+ + diff --git a/routes/auth.php b/routes/auth.php new file mode 100644 index 0000000..71a5eb6 --- /dev/null +++ b/routes/auth.php @@ -0,0 +1,70 @@ +group(function () { + + Route::get('register', [RegisteredUserController::class, 'create']) + ->name('register'); + + Route::post('register', [RegisteredUserController::class, 'store']); + + Route::get('login', [AuthenticatedSessionController::class, 'create']) + ->name('login'); + + Route::post('login', [AuthenticatedSessionController::class, 'store']); + + Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) + ->name('password.request'); + + Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) + ->name('password.email'); + + Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) + ->name('password.reset'); + + Route::post('reset-password', [NewPasswordController::class, 'store']) + ->name('password.store'); +}); + +// ── Authenticated routes ────────────────────────────────────────────────────── + +Route::middleware('auth')->group(function () { + + // Email verification — invokable controllers (no method specified) + Route::get('verify-email', EmailVerificationPromptController::class) + ->name('verification.notice'); + + Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + + Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware('throttle:6,1') + ->name('verification.send'); + + // Password confirmation + Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) + ->name('password.confirm'); + + Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); + + // Password update (from profile) + Route::put('password', [PasswordController::class, 'update']) + ->name('password.update'); + + // Logout + Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) + ->name('logout'); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..c787e5f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Scheduled task: clean up orphaned page storage directories once a day +Schedule::command('pages:cleanup-storage')->daily(); diff --git a/routes/web.php b/routes/web.php index 12e932e..c32b862 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,70 @@ name('home'); +/* +|-------------------------------------------------------------------------- +| Main Application Routes +|-------------------------------------------------------------------------- +*/ + +Route::get('/', function () { + return auth()->check() + ? redirect()->route('dashboard') + : view('welcome'); +})->name('home'); Route::middleware(['auth', 'verified'])->group(function () { - Route::view('dashboard', 'dashboard')->name('dashboard'); + + // Dashboard + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + + // Page management + Route::get('/pages/create', [PageController::class, 'create'])->name('pages.create'); + Route::post('/pages', [PageController::class, 'store'])->name('pages.store'); + Route::get('/pages/{slug}/manager', [FileManagerController::class, 'index'])->name('pages.manager'); + Route::get('/pages/{slug}/preview', [PagePreviewController::class, 'show'])->name('pages.preview'); + Route::delete('/pages/{slug}', [PageController::class, 'destroy'])->name('pages.destroy'); + Route::put('/pages/{slug}/settings', [PageController::class, 'updateSettings'])->name('pages.settings'); + + // File Manager JSON API + Route::prefix('/pages/{slug}/files')->name('pages.files.')->group(function () { + Route::get('/', [FileManagerController::class, 'list'])->name('list'); + Route::get('/read', [FileManagerController::class, 'read'])->name('read'); + Route::post('/', [FileManagerController::class, 'upload'])->name('upload'); + Route::post('/create', [FileManagerController::class, 'create'])->name('create'); + Route::put('/', [FileManagerController::class, 'save'])->name('save'); + Route::delete('/', [FileManagerController::class, 'delete'])->name('delete'); + Route::post('/folder', [FileManagerController::class, 'createFolder'])->name('folder'); + Route::put('/rename', [FileManagerController::class, 'rename'])->name('rename'); + }); }); -require __DIR__.'/settings.php'; +/* +|-------------------------------------------------------------------------- +| Subdomain Routes (must be defined before path catch-all) +|-------------------------------------------------------------------------- +*/ +Route::domain('{slug}.statichtmlsites.mtex.dev') + ->group(function () { + Route::get('/{path?}', [PageServeController::class, 'serve']) + ->where('path', '.*') + ->name('page.subdomain'); + }); + +/* +|-------------------------------------------------------------------------- +| Path-based Page Serving (catch-all – keep last) +|-------------------------------------------------------------------------- +*/ +Route::get('/{slug}/{path?}', [PageServeController::class, 'serve']) + ->where('slug', '[a-z0-9\-_]+') + ->where('path', '.*') + ->name('page.path'); + +require __DIR__ . '/auth.php'; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php deleted file mode 100644 index ded1b2b..0000000 --- a/tests/Feature/Auth/AuthenticationTest.php +++ /dev/null @@ -1,67 +0,0 @@ -get(route('login')); - - $response->assertOk(); -}); - -test('users can authenticate using the login screen', function () { - $user = User::factory()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); - -test('users can not authenticate with invalid password', function () { - $user = User::factory()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'wrong-password', - ]); - - $response->assertSessionHasErrorsIn('email'); - - $this->assertGuest(); -}); - -test('users with two factor enabled are redirected to two factor challenge', function () { - $this->skipUnlessFortifyFeature(Features::twoFactorAuthentication()); - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - - $user = User::factory()->withTwoFactor()->create(); - - $response = $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response->assertRedirect(route('two-factor.login')); - $this->assertGuest(); -}); - -test('users can logout', function () { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post(route('logout')); - - $response->assertRedirect(route('home')); - - $this->assertGuest(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php deleted file mode 100644 index 3203c45..0000000 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ /dev/null @@ -1,72 +0,0 @@ -skipUnlessFortifyFeature(Features::emailVerification()); -}); - -test('email verification screen can be rendered', function () { - $user = User::factory()->unverified()->create(); - - $response = $this->actingAs($user)->get(route('verification.notice')); - - $response->assertOk(); -}); - -test('email can be verified', function () { - $user = User::factory()->unverified()->create(); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)], - ); - - $response = $this->actingAs($user)->get($verificationUrl); - - Event::assertDispatched(Verified::class); - - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); -}); - -test('email is not verified with invalid hash', function () { - $user = User::factory()->unverified()->create(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')], - ); - - $this->actingAs($user)->get($verificationUrl); - - expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); -}); - -test('already verified user visiting verification link is redirected without firing event again', function () { - $user = User::factory()->create([ - 'email_verified_at' => now(), - ]); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)], - ); - - $this->actingAs($user)->get($verificationUrl) - ->assertRedirect(route('dashboard', absolute: false).'?verified=1'); - - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - Event::assertNotDispatched(Verified::class); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php deleted file mode 100644 index 514115a..0000000 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ /dev/null @@ -1,11 +0,0 @@ -create(); - - $response = $this->actingAs($user)->get(route('password.confirm')); - - $response->assertOk(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php deleted file mode 100644 index 901c989..0000000 --- a/tests/Feature/Auth/PasswordResetTest.php +++ /dev/null @@ -1,65 +0,0 @@ -skipUnlessFortifyFeature(Features::resetPasswords()); -}); - -test('reset password link screen can be rendered', function () { - $response = $this->get(route('password.request')); - - $response->assertOk(); -}); - -test('reset password link can be requested', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class); -}); - -test('reset password screen can be rendered', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get(route('password.reset', $notification->token)); - - $response->assertOk(); - - return true; - }); -}); - -test('password can be reset with valid token', function () { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post(route('password.request'), ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post(route('password.update'), [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('login', absolute: false)); - - return true; - }); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php deleted file mode 100644 index c2bd3b2..0000000 --- a/tests/Feature/Auth/RegistrationTest.php +++ /dev/null @@ -1,27 +0,0 @@ -skipUnlessFortifyFeature(Features::registration()); -}); - -test('registration screen can be rendered', function () { - $response = $this->get(route('register')); - - $response->assertOk(); -}); - -test('new users can register', function () { - $response = $this->post(route('register.store'), [ - 'name' => 'John Doe', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php deleted file mode 100644 index 244f510..0000000 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ /dev/null @@ -1,28 +0,0 @@ -skipUnlessFortifyFeature(Features::twoFactorAuthentication()); -}); - -test('two factor challenge redirects to login when not authenticated', function () { - $response = $this->get(route('two-factor.login')); - - $response->assertRedirect(route('login')); -}); - -test('two factor challenge can be rendered', function () { - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - - $user = User::factory()->withTwoFactor()->create(); - - $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'password', - ])->assertRedirect(route('two-factor.login')); -}); \ No newline at end of file diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php deleted file mode 100644 index d59d2d7..0000000 --- a/tests/Feature/DashboardTest.php +++ /dev/null @@ -1,16 +0,0 @@ -get(route('dashboard')); - $response->assertRedirect(route('login')); -}); - -test('authenticated users can visit the dashboard', function () { - $user = User::factory()->create(); - $this->actingAs($user); - - $response = $this->get(route('dashboard')); - $response->assertOk(); -}); \ No newline at end of file diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index a576279..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get(route('home')); - - $response->assertOk(); -}); \ No newline at end of file diff --git a/tests/Feature/FileManagerApiTest.php b/tests/Feature/FileManagerApiTest.php new file mode 100644 index 0000000..f4e1e86 --- /dev/null +++ b/tests/Feature/FileManagerApiTest.php @@ -0,0 +1,187 @@ +user = User::factory()->create(); + $this->page = Page::factory()->for($this->user)->create(['slug' => 'testsite']); + Storage::disk('pages')->put('testsite/index.html', ''); + } + + // ── File listing ────────────────────────────────────────────────────────── + + public function test_can_list_files(): void + { + $this->actingAs($this->user) + ->getJson(route('pages.files.list', 'testsite')) + ->assertOk() + ->assertJsonFragment(['name' => 'index.html']); + } + + public function test_list_requires_ownership(): void + { + $other = User::factory()->create(); + $this->actingAs($other) + ->getJson(route('pages.files.list', 'testsite')) + ->assertNotFound(); + } + + // ── Read ────────────────────────────────────────────────────────────────── + + public function test_can_read_file_content(): void + { + $this->actingAs($this->user) + ->getJson(route('pages.files.read', 'testsite') . '?path=index.html') + ->assertOk() + ->assertJsonPath('path', 'index.html') + ->assertJsonStructure(['content', 'path']); + } + + public function test_read_nonexistent_file_returns_error(): void + { + $this->actingAs($this->user) + ->getJson(route('pages.files.read', 'testsite') . '?path=ghost.html') + ->assertStatus(422); + } + + // ── Save ────────────────────────────────────────────────────────────────── + + public function test_can_save_file_content(): void + { + $this->actingAs($this->user) + ->putJson(route('pages.files.save', 'testsite'), [ + 'path' => 'index.html', + 'content' => 'Updated', + ]) + ->assertOk() + ->assertJsonPath('ok', true); + + $this->assertStringContainsString( + 'Updated', + Storage::disk('pages')->get('testsite/index.html') + ); + } + + public function test_save_rejects_path_traversal(): void + { + $this->actingAs($this->user) + ->putJson(route('pages.files.save', 'testsite'), [ + 'path' => '../other-site/index.html', + 'content' => 'evil', + ]) + ->assertStatus(422); + } + + // ── Create ──────────────────────────────────────────────────────────────── + + public function test_can_create_new_file(): void + { + $this->actingAs($this->user) + ->postJson(route('pages.files.create', 'testsite'), ['path' => 'about.html']) + ->assertOk() + ->assertJsonPath('ok', true); + + $this->assertTrue(Storage::disk('pages')->exists('testsite/about.html')); + } + + public function test_creating_duplicate_file_returns_error(): void + { + $this->actingAs($this->user) + ->postJson(route('pages.files.create', 'testsite'), ['path' => 'index.html']) + ->assertStatus(422); + } + + // ── Upload ──────────────────────────────────────────────────────────────── + + public function test_can_upload_image(): void + { + $file = UploadedFile::fake()->image('logo.png'); + + $this->actingAs($this->user) + ->post(route('pages.files.upload', 'testsite'), ['file' => $file]) + ->assertOk() + ->assertJsonPath('ok', true); + + $this->assertTrue(Storage::disk('pages')->exists('testsite/logo.png')); + } + + public function test_upload_rejects_disallowed_extension(): void + { + $file = UploadedFile::fake()->create('shell.php', 10, 'text/plain'); + + $this->actingAs($this->user) + ->post(route('pages.files.upload', 'testsite'), ['file' => $file]) + ->assertStatus(422); + } + + // ── Delete ──────────────────────────────────────────────────────────────── + + public function test_can_delete_file(): void + { + Storage::disk('pages')->put('testsite/old.css', 'p{}'); + + $this->actingAs($this->user) + ->deleteJson(route('pages.files.delete', 'testsite'), ['path' => 'old.css']) + ->assertOk(); + + $this->assertFalse(Storage::disk('pages')->exists('testsite/old.css')); + } + + public function test_can_delete_folder(): void + { + Storage::disk('pages')->makeDirectory('testsite/old-dir'); + Storage::disk('pages')->put('testsite/old-dir/file.txt', 'x'); + + $this->actingAs($this->user) + ->deleteJson(route('pages.files.delete', 'testsite'), ['path' => 'old-dir']) + ->assertOk(); + + $this->assertFalse(Storage::disk('pages')->directoryExists('testsite/old-dir')); + } + + // ── Folder ──────────────────────────────────────────────────────────────── + + public function test_can_create_folder(): void + { + $this->actingAs($this->user) + ->postJson(route('pages.files.folder', 'testsite'), ['path' => 'assets']) + ->assertOk(); + + $this->assertTrue(Storage::disk('pages')->directoryExists('testsite/assets')); + } + + // ── Rename ──────────────────────────────────────────────────────────────── + + public function test_can_rename_file(): void + { + Storage::disk('pages')->put('testsite/old.txt', 'content'); + + $this->actingAs($this->user) + ->putJson(route('pages.files.rename', 'testsite'), [ + 'from' => 'old.txt', + 'to' => 'new.txt', + ]) + ->assertOk(); + + $this->assertFalse(Storage::disk('pages')->exists('testsite/old.txt')); + $this->assertTrue(Storage::disk('pages')->exists('testsite/new.txt')); + } +} diff --git a/tests/Feature/PageServeTest.php b/tests/Feature/PageServeTest.php new file mode 100644 index 0000000..da168d9 --- /dev/null +++ b/tests/Feature/PageServeTest.php @@ -0,0 +1,132 @@ +create(['slug' => 'hello', 'is_public' => true]); + Storage::disk('pages')->put('hello/index.html', 'Hi'); + + $this->get('/hello') + ->assertOk() + ->assertSee('Hi'); + } + + public function test_base_tag_is_injected_into_html(): void + { + $page = Page::factory()->create(['slug' => 'demo', 'is_public' => true]); + Storage::disk('pages')->put('demo/index.html', 'Test'); + + $response = $this->get('/demo'); + $content = $response->content(); + + $this->assertStringContainsString(' 'test', 'is_public' => true]); + Storage::disk('pages')->put('test/index.html', 'X'); + + $html = $this->get('/test')->content(); + // must come right after + $this->assertMatchesRegularExpression('/]*>\s*create(['slug' => 'assets', 'is_public' => true]); + Storage::disk('pages')->put('assets/style.css', 'body { color: red; }'); + + $this->get('/assets/style.css') + ->assertOk() + ->assertHeader('Content-Type', 'text/css'); + } + + public function test_nested_file_is_served(): void + { + Page::factory()->create(['slug' => 'nested', 'is_public' => true]); + Storage::disk('pages')->put('nested/js/app.js', 'console.log("hi")'); + + $this->get('/nested/js/app.js') + ->assertOk() + ->assertSee('console.log'); + } + + public function test_directory_path_resolves_to_index_html(): void + { + Page::factory()->create(['slug' => 'sub', 'is_public' => true]); + Storage::disk('pages')->put('sub/about/index.html', 'About'); + + $this->get('/sub/about') + ->assertOk() + ->assertSee('About'); + } + + public function test_missing_file_returns_404(): void + { + Page::factory()->create(['slug' => 'exists', 'is_public' => true]); + + $this->get('/exists/nope.html')->assertNotFound(); + } + + // ── Private pages ───────────────────────────────────────────────────────── + + public function test_private_page_returns_403_for_guests(): void + { + $page = Page::factory()->create(['slug' => 'secret', 'is_public' => false]); + Storage::disk('pages')->put('secret/index.html', 'Secret'); + + $this->get('/secret')->assertForbidden(); + } + + public function test_private_page_is_accessible_to_authenticated_user(): void + { + $user = User::factory()->create(); + Page::factory()->for($user)->create(['slug' => 'private', 'is_public' => false]); + Storage::disk('pages')->put('private/index.html', 'Private'); + + $this->actingAs($user) + ->get('/private') + ->assertOk(); + } + + // ── Path traversal protection ───────────────────────────────────────────── + + public function test_path_traversal_is_blocked(): void + { + Page::factory()->create(['slug' => 'pwned', 'is_public' => true]); + + // Try to escape the page directory via encoded traversal + $this->get('/pwned/../../../etc/passwd')->assertNotFound(); + } + + // ── HTML-less documents ─────────────────────────────────────────────────── + + public function test_base_tag_prepended_when_no_head_tag(): void + { + Page::factory()->create(['slug' => 'nohead', 'is_public' => true]); + Storage::disk('pages')->put('nohead/index.html', '

Minimal

'); + + $html = $this->get('/nohead')->content(); + $this->assertStringStartsWith('get(route('dashboard'))->assertRedirect(route('login')); + } + + public function test_dashboard_shows_user_pages(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + + $mine = Page::factory()->for($user)->create(['name' => 'My Page']); + $theirs = Page::factory()->for($other)->create(['name' => 'Their Page']); + + $this->actingAs($user) + ->get(route('dashboard')) + ->assertOk() + ->assertSee('My Page') + ->assertDontSee('Their Page'); + } + + // ── Create ──────────────────────────────────────────────────────────────── + + public function test_create_page_form_renders(): void + { + $this->actingAs(User::factory()->create()) + ->get(route('pages.create')) + ->assertOk(); + } + + public function test_user_can_create_page_with_auto_slug(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('pages.store'), ['name' => 'My Awesome Site']) + ->assertRedirect(); + + $this->assertDatabaseHas('pages', [ + 'user_id' => $user->id, + 'name' => 'My Awesome Site', + 'slug' => 'my-awesome-site', + ]); + } + + public function test_user_can_create_page_with_custom_slug(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('pages.store'), ['name' => 'Test', 'slug' => 'custom-slug']) + ->assertRedirect(); + + $this->assertDatabaseHas('pages', ['slug' => 'custom-slug']); + } + + public function test_duplicate_slug_is_rejected(): void + { + $user = User::factory()->create(); + Page::factory()->for($user)->create(['slug' => 'taken']); + + $this->actingAs($user) + ->post(route('pages.store'), ['name' => 'Another', 'slug' => 'taken']) + ->assertSessionHasErrors('slug'); + } + + public function test_slug_with_invalid_chars_is_rejected(): void + { + $this->actingAs(User::factory()->create()) + ->post(route('pages.store'), ['name' => 'Test', 'slug' => 'invalid slug!']) + ->assertSessionHasErrors('slug'); + } + + // ── Update settings ─────────────────────────────────────────────────────── + + public function test_owner_can_update_page_settings(): void + { + $user = User::factory()->create(); + $page = Page::factory()->for($user)->create(['name' => 'Old', 'slug' => 'old-slug']); + + $this->actingAs($user) + ->put(route('pages.settings', 'old-slug'), [ + 'name' => 'New Name', + 'slug' => 'new-slug', + 'is_public' => true, + ]) + ->assertRedirect(route('pages.manager', 'new-slug')); + + $this->assertDatabaseHas('pages', ['name' => 'New Name', 'slug' => 'new-slug']); + } + + public function test_other_user_cannot_update_settings(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $page = Page::factory()->for($owner)->create(['slug' => 'my-page']); + + $this->actingAs($other) + ->put(route('pages.settings', 'my-page'), [ + 'name' => 'Hacked', 'slug' => 'my-page', 'is_public' => true, + ]) + ->assertNotFound(); + } + + // ── Delete ──────────────────────────────────────────────────────────────── + + public function test_owner_can_delete_page(): void + { + $user = User::factory()->create(); + $page = Page::factory()->for($user)->create(['slug' => 'to-delete']); + + $this->actingAs($user) + ->delete(route('pages.destroy', 'to-delete')) + ->assertRedirect(route('dashboard')); + + $this->assertDatabaseMissing('pages', ['slug' => 'to-delete']); + } + + public function test_other_user_cannot_delete_page(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + Page::factory()->for($owner)->create(['slug' => 'my-page']); + + $this->actingAs($other) + ->delete(route('pages.destroy', 'my-page')) + ->assertNotFound(); + + $this->assertDatabaseHas('pages', ['slug' => 'my-page']); + } +} diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php deleted file mode 100644 index 6d30fee..0000000 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ /dev/null @@ -1,76 +0,0 @@ -actingAs($user = User::factory()->create()); - - $this->get('/settings/profile')->assertOk(); -}); - -test('profile information can be updated', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(Profile::class) - ->set('name', 'Test User') - ->set('email', 'test@example.com') - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - $user->refresh(); - - expect($user->name)->toEqual('Test User'); - expect($user->email)->toEqual('test@example.com'); - expect($user->email_verified_at)->toBeNull(); -}); - -test('email verification status is unchanged when email address is unchanged', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(Profile::class) - ->set('name', 'Test User') - ->set('email', $user->email) - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - expect($user->refresh()->email_verified_at)->not->toBeNull(); -}); - -test('user can delete their account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test('settings.delete-user-form') - ->set('password', 'password') - ->call('deleteUser'); - - $response - ->assertHasNoErrors() - ->assertRedirect('/'); - - expect($user->fresh())->toBeNull(); - expect(auth()->check())->toBeFalse(); -}); - -test('correct password must be provided to delete account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test('settings.delete-user-form') - ->set('password', 'wrong-password') - ->call('deleteUser'); - - $response->assertHasErrors(['password']); - - expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php deleted file mode 100644 index a31dfbf..0000000 --- a/tests/Feature/Settings/SecurityTest.php +++ /dev/null @@ -1,105 +0,0 @@ -skipUnlessFortifyFeature(Features::twoFactorAuthentication()); - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); -}); - -test('security settings page can be rendered', function () { - $user = User::factory()->create(); - - $this->actingAs($user) - ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('security.edit')) - ->assertOk() - ->assertSee('Two-factor authentication') - ->assertSee('Enable 2FA'); -}); - -test('security settings page requires password confirmation when enabled', function () { - $user = User::factory()->create(); - - $response = $this->actingAs($user) - ->get(route('security.edit')); - - $response->assertRedirect(route('password.confirm')); -}); - -test('security settings page renders without two factor when feature is disabled', function () { - config(['fortify.features' => []]); - - $user = User::factory()->create(); - - $this->actingAs($user) - ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('security.edit')) - ->assertOk() - ->assertSee('Update password') - ->assertDontSee('Two-factor authentication'); -}); - -test('two factor authentication disabled when confirmation abandoned between requests', function () { - $user = User::factory()->create(); - - $user->forceFill([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), - 'two_factor_confirmed_at' => null, - ])->save(); - - $this->actingAs($user); - - $component = Livewire::test(Security::class); - - $component->assertSet('twoFactorEnabled', false); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'two_factor_secret' => null, - 'two_factor_recovery_codes' => null, - ]); -}); - -test('password can be updated', function () { - $user = User::factory()->create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Livewire::test(Security::class) - ->set('current_password', 'password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasNoErrors(); - - expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); -}); - -test('correct password must be provided to update password', function () { - $user = User::factory()->create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Livewire::test(Security::class) - ->set('current_password', 'wrong-password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasErrors(['current_password']); -}); \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 3399054..ee63ad0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,14 +3,5 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -use Laravel\Fortify\Features; -abstract class TestCase extends BaseTestCase -{ - protected function skipUnlessFortifyFeature(string $feature, ?string $message = null): void - { - if (! Features::enabled($feature)) { - $this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled."); - } - } -} +abstract class TestCase extends BaseTestCase {} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 27f3f87..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); \ No newline at end of file diff --git a/tests/Unit/FileManagerServiceTest.php b/tests/Unit/FileManagerServiceTest.php new file mode 100644 index 0000000..37e6b42 --- /dev/null +++ b/tests/Unit/FileManagerServiceTest.php @@ -0,0 +1,128 @@ +service = new FileManagerService(); + $this->page = Page::factory()->create(['slug' => 'mysite']); + Storage::disk('pages')->put('mysite/index.html', '

Hello

'); + } + + public function test_tree_returns_files(): void + { + $tree = $this->service->tree($this->page); + $names = array_column($tree, 'name'); + $this->assertContains('index.html', $names); + } + + public function test_tree_orders_dirs_before_files(): void + { + Storage::disk('pages')->makeDirectory('mysite/assets'); + Storage::disk('pages')->put('mysite/readme.txt', 'hi'); + + $tree = $this->service->tree($this->page); + $types = array_column($tree, 'type'); + + // Directories should precede files + $firstFile = array_search('file', $types); + $firstDir = array_search('directory', $types); + $this->assertLessThan($firstFile, $firstDir); + } + + public function test_can_read_file(): void + { + $content = $this->service->read($this->page, 'index.html'); + $this->assertStringContainsString('Hello', $content); + } + + public function test_read_missing_file_throws(): void + { + $this->expectException(RuntimeException::class); + $this->service->read($this->page, 'ghost.html'); + } + + public function test_can_save_file(): void + { + $this->service->save($this->page, 'index.html', '

Updated

'); + $this->assertStringContainsString('Updated', Storage::disk('pages')->get('mysite/index.html')); + } + + public function test_can_create_file(): void + { + $this->service->createFile($this->page, 'new.html'); + $this->assertTrue(Storage::disk('pages')->exists('mysite/new.html')); + } + + public function test_create_duplicate_file_throws(): void + { + $this->expectException(RuntimeException::class); + $this->service->createFile($this->page, 'index.html'); + } + + public function test_can_delete_file(): void + { + $this->service->delete($this->page, 'index.html'); + $this->assertFalse(Storage::disk('pages')->exists('mysite/index.html')); + } + + public function test_can_delete_directory(): void + { + Storage::disk('pages')->makeDirectory('mysite/subdir'); + Storage::disk('pages')->put('mysite/subdir/file.txt', 'x'); + + $this->service->delete($this->page, 'subdir'); + $this->assertFalse(Storage::disk('pages')->directoryExists('mysite/subdir')); + } + + public function test_can_create_folder(): void + { + $this->service->createFolder($this->page, 'media'); + $this->assertTrue(Storage::disk('pages')->directoryExists('mysite/media')); + } + + public function test_can_rename_file(): void + { + $this->service->rename($this->page, 'index.html', 'home.html'); + $this->assertFalse(Storage::disk('pages')->exists('mysite/index.html')); + $this->assertTrue(Storage::disk('pages')->exists('mysite/home.html')); + } + + public function test_path_traversal_is_blocked(): void + { + $this->expectException(RuntimeException::class); + $this->service->read($this->page, '../othersite/secret.txt'); + } + + public function test_upload_allowed_file(): void + { + $file = UploadedFile::fake()->image('photo.jpg'); + $this->service->upload($this->page, $file); + $this->assertTrue(Storage::disk('pages')->exists('mysite/photo.jpg')); + } + + public function test_upload_disallowed_extension_throws(): void + { + $this->expectException(RuntimeException::class); + $file = UploadedFile::fake()->create('evil.exe', 10); + $this->service->upload($this->page, $file); + } +} diff --git a/vite.config.js b/vite.config.js index f65249e..f28608b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,21 +1,20 @@ -import { - defineConfig -} from 'vite'; +import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; -import tailwindcss from "@tailwindcss/vite"; +import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ + tailwindcss(), laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, + input: [ + 'resources/css/app.css', + 'resources/js/app.js', + 'resources/js/file-manager.js', + ], + refresh: [ + 'resources/views/**', + 'routes/**', + ], }), - tailwindcss(), ], - server: { - cors: true, - watch: { - ignored: ['**/storage/framework/views/**'], - }, - }, });