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.
+
+
+
+
+
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
+
+
+
+
+ ← 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
+
+
+
+
+ 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
+
+
+
+
+ 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
+
+
+
+
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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+ @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'])
+
+
+
+
+
+
+
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') }}
-
-
-
-
-
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
->
-
-
-
-
-
-
- {{ $heading }}
-
-
-
-
-
-
-
-class('block space-y-[2px]') }}>
-
-
-
- {{ $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') }}
-
-
-
-
-
-
-
-
-
-
- {{ $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 @@
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
-
-
-
-
- {{ __('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 @@
-
-
-
-
-
-
-
-
-
- @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 @@
-
-
-
-
-
-
-
-
-
-
- {{ __('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 @@
-
-
-
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 @@
-
-
-
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
-
-
-
-
-
-
-
-
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
+ ═══════════════════════════════════════════════════════════════════════ --}}
+
+
+
+ {{-- ═══════════════════════════════════════════════════════════════════════
+ MAIN SPLIT PANE
+ ═══════════════════════════════════════════════════════════════════════ --}}
+
+
+ {{-- ── LEFT SIDEBAR ──────────────────────────────────────────────── --}}
+
+
+ {{-- Sidebar toolbar --}}
+
+
+ Explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{-- Current directory breadcrumb --}}
+ @if ($currentDir)
+
+
+
+
+
+ / {{ $currentDir }}
+
+
+ @endif
+
+ {{-- File tree --}}
+
+
+ {{-- Rename inline form (shown when renameTarget is set) --}}
+ @if ($renameTarget !== null)
+
+
+ Rename
+
+
+
+
+ Rename
+
+
+ Cancel
+
+
+
+ @endif
+
+
+
+
+ {{-- ── CENTER EDITOR PANE ──────────────────────────────────────── --}}
+
+
+ {{-- Editor toolbar --}}
+
+ @if ($activeFile)
+
+
+ {{ $activeFile }}
+
+ @else
+
No file open
+ @endif
+
+
+ @if ($isDirty)
+
+
+ Save
+ ⌘S
+
+ @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
+
+
+ Create a new file
+
+
+ @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)
+ ═══════════════════════════════════════════════════════════════════════ --}}
+
+
+
+
+
+
+ New File Here
+
+
+
+ New Folder Here
+
+
+
+
+
+
+
+ Rename
+
+
+
+
+ Copy Path
+
+
+
+
+
+
+ Delete
+
+
+
+
+
+ {{-- ═══════════════════════════════════════════════════════════════════════
+ 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
+
+
+
+
+
+ Drop files here, or click to browse
+
+
+
+
+
+
+ @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 --}}
+
+
+
+
+ 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') }}
-
-
-
-
-
-
-
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->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') }}
-
-
-
-
- @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.') }}
-
-
-
-
-
-
- {{ __('View recovery codes') }}
-
-
-
- {{ __('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
+
+
+
+
+
+
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
+
+
+
+
+ @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 --}}
+
+
+
+ {{-- Mobile icon --}}
+
+
+
+
+
+ {{-- Tablet icon --}}
+
+
+
+
+
+ {{-- Desktop icon --}}
+
+
+
+
+
+
+
+
+
+
+ {{-- 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'))
-
- @auth
-
- Dashboard
-
- @else
-
- Log in
-
-
- @if (Route::has('register'))
-
- Register
-
- @endif
- @endauth
-
- @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/**'],
- },
- },
});