52 - Replace React/Inertia with Blade + Livewire 4
This commit is contained in:
parent
c34269d0e9
commit
6ce470374f
44 changed files with 510 additions and 7966 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
resources/js/components/ui/*
|
|
||||||
resources/js/ziggy.js
|
|
||||||
resources/views/mail/*
|
|
||||||
19
.prettierrc
19
.prettierrc
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"singleAttributePerLine": false,
|
|
||||||
"htmlWhitespaceSensitivity": "css",
|
|
||||||
"printWidth": 150,
|
|
||||||
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
|
|
||||||
"tailwindFunctions": ["clsx", "cn"],
|
|
||||||
"tailwindStylesheet": "resources/css/app.css",
|
|
||||||
"tabWidth": 4,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "**/*.yml",
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,7 @@ # incr
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://laravel.com/)
|
[](https://laravel.com/)
|
||||||
[](https://reactjs.org/)
|
[](https://livewire.laravel.com/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -28,8 +28,8 @@ ## Features
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Laravel 13 (PHP 8.3+) with MySQL
|
- **Backend**: Laravel 13 (PHP 8.3+) with MySQL
|
||||||
- **Frontend**: React 19 + TypeScript with Inertia.js
|
- **Frontend**: Livewire 4 with Blade
|
||||||
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
- **Styling**: Tailwind CSS 4
|
||||||
- **Deployment**: Docker / Podman with multi-stage builds
|
- **Deployment**: Docker / Podman with multi-stage builds
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class CounterController extends Controller
|
|
||||||
{
|
|
||||||
public function increment(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = Tracker::current();
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json(['error' => 'No counter found.'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->increment('count');
|
|
||||||
$tracker->refresh();
|
|
||||||
|
|
||||||
return response()->json(['count' => $tracker->count]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'count' => 'required|integer|min:0|max:4294967295',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tracker = Tracker::current();
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json(['error' => 'No counter found.'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->update(['count' => $validated['count']]);
|
|
||||||
|
|
||||||
return response()->json(['count' => $tracker->count]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TrackerController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = Tracker::current();
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json(['exists' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['exists' => true, 'tracker' => $tracker]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'label' => 'sometimes|string|max:255',
|
|
||||||
'unit' => 'sometimes|string|max:50',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (Tracker::current()) {
|
|
||||||
return response()->json(['error' => 'Tracker already exists.'], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker = Tracker::create([
|
|
||||||
'label' => $validated['label'] ?? 'Counter',
|
|
||||||
'unit' => $validated['unit'] ?? 'units',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json($tracker, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'label' => 'sometimes|string|max:255',
|
|
||||||
'unit' => 'sometimes|string|max:50',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tracker = Tracker::current();
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json(['error' => 'No counter found.'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->update($validated);
|
|
||||||
|
|
||||||
return response()->json($tracker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\View;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class HandleAppearance
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*
|
|
||||||
* @param Closure(Request): (Response) $next
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Middleware;
|
|
||||||
use Tighten\Ziggy\Ziggy;
|
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The root template that's loaded on the first page visit.
|
|
||||||
*
|
|
||||||
* @see https://inertiajs.com/server-side-setup#root-template
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $rootView = 'app';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines the current asset version.
|
|
||||||
*
|
|
||||||
* @see https://inertiajs.com/asset-versioning
|
|
||||||
*/
|
|
||||||
public function version(Request $request): ?string
|
|
||||||
{
|
|
||||||
return parent::version($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define the props that are shared by default.
|
|
||||||
*
|
|
||||||
* @see https://inertiajs.com/shared-data
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function share(Request $request): array
|
|
||||||
{
|
|
||||||
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
|
|
||||||
|
|
||||||
return [
|
|
||||||
...parent::share($request),
|
|
||||||
'name' => config('app.name'),
|
|
||||||
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
|
||||||
'ziggy' => fn (): array => [
|
|
||||||
...(new Ziggy)->toArray(),
|
|
||||||
'location' => $request->url(),
|
|
||||||
],
|
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
app/Livewire/Counter.php
Normal file
101
app/Livewire/Counter.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Tracker;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Counter extends Component
|
||||||
|
{
|
||||||
|
// Ceiling of the unsigned int column backing trackers.count.
|
||||||
|
public const MAX_COUNT = 4294967295;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public int $count = 0;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public bool $needsOnboarding = false;
|
||||||
|
|
||||||
|
public bool $editing = false;
|
||||||
|
|
||||||
|
#[Validate('required|integer|min:0|max:'.self::MAX_COUNT)]
|
||||||
|
public ?int $value = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tracker = Tracker::current();
|
||||||
|
|
||||||
|
$this->needsOnboarding = $tracker === null;
|
||||||
|
$this->count = $tracker === null ? 0 : $tracker->count;
|
||||||
|
$this->value = $this->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initialise(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$tracker = Tracker::current() ?? Tracker::create([
|
||||||
|
'label' => 'Counter',
|
||||||
|
'unit' => 'units',
|
||||||
|
'count' => $this->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->count = $tracker->count;
|
||||||
|
$this->needsOnboarding = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function increment(): void
|
||||||
|
{
|
||||||
|
$tracker = Tracker::current();
|
||||||
|
|
||||||
|
if (! $tracker || $tracker->count >= self::MAX_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->increment('count');
|
||||||
|
$tracker->refresh();
|
||||||
|
|
||||||
|
$this->count = $tracker->count;
|
||||||
|
$this->value = $this->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(): void
|
||||||
|
{
|
||||||
|
$this->value = $this->count;
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$tracker = Tracker::current();
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->update(['count' => $this->value]);
|
||||||
|
|
||||||
|
$this->count = (int) $this->value;
|
||||||
|
$this->editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->value = $this->count;
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.counter')->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\HandleAppearance;
|
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -14,11 +12,7 @@
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
HandleAppearance::class,
|
|
||||||
HandleInertiaRequests::class,
|
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "resources/css/app.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://getcomposer.org/schema.json",
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
"name": "laravel/react-starter-kit",
|
"name": "lvl0/incr",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "A minimalist counter.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"laravel",
|
"laravel",
|
||||||
"framework"
|
"framework"
|
||||||
|
|
@ -10,10 +10,9 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"inertiajs/inertia-laravel": "^3.0",
|
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"tightenco/ziggy": "^2.4"
|
"livewire/livewire": "^4.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|
@ -58,11 +57,6 @@
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
|
||||||
"npm run build:ssr",
|
|
||||||
"Composer\\Config::disableProcessTimeout",
|
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
|
||||||
],
|
|
||||||
"test": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@php artisan test"
|
"@php artisan test"
|
||||||
|
|
|
||||||
221
composer.lock
generated
221
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "48478688a363bdda6c30a93ad1a52636",
|
"content-hash": "9b1957cddc5253df62939093e476f2f8",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|
@ -1053,79 +1053,6 @@
|
||||||
],
|
],
|
||||||
"time": "2025-08-22T14:27:06+00:00"
|
"time": "2025-08-22T14:27:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "inertiajs/inertia-laravel",
|
|
||||||
"version": "v3.0.6",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/inertiajs/inertia-laravel.git",
|
|
||||||
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c255b1ea050cf563b240542a76f7f756ccdb2d67",
|
|
||||||
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"laravel/framework": "^11.0|^12.0|^13.0",
|
|
||||||
"php": "^8.2.0",
|
|
||||||
"symfony/console": "^7.0|^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"laravel/boost": "<2.2.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
|
||||||
"larastan/larastan": "^3.0",
|
|
||||||
"laravel/pint": "^1.16",
|
|
||||||
"mockery/mockery": "^1.3.3",
|
|
||||||
"orchestra/testbench": "^9.2|^10.0|^11.0",
|
|
||||||
"phpunit/phpunit": "^11.5|^12.0",
|
|
||||||
"roave/security-advisories": "dev-master"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"Inertia\\ServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"./helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"Inertia\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jonathan Reinink",
|
|
||||||
"email": "jonathan@reinink.ca",
|
|
||||||
"homepage": "https://reinink.ca"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The Laravel adapter for Inertia.js.",
|
|
||||||
"keywords": [
|
|
||||||
"inertia",
|
|
||||||
"laravel"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
|
|
||||||
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.0.6"
|
|
||||||
},
|
|
||||||
"time": "2026-04-10T14:29:45+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.7.0",
|
"version": "v13.7.0",
|
||||||
|
|
@ -2098,6 +2025,82 @@
|
||||||
],
|
],
|
||||||
"time": "2026-03-08T20:05:35+00:00"
|
"time": "2026-03-08T20:05:35+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "livewire/livewire",
|
||||||
|
"version": "v4.4.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/livewire/livewire.git",
|
||||||
|
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/livewire/livewire/zipball/514b29d5a23594d4e4846494f580268b20c2f11e",
|
||||||
|
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/validation": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"laravel/prompts": "^0.1.24|^0.2|^0.3",
|
||||||
|
"league/mime-type-detection": "^1.9",
|
||||||
|
"php": "^8.1",
|
||||||
|
"symfony/console": "^6.0|^7.0|^8.0",
|
||||||
|
"symfony/http-kernel": "^6.2|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"calebporzio/sushi": "^2.1",
|
||||||
|
"laravel/framework": "^10.15.0|^11.0|^12.0|^13.0",
|
||||||
|
"mockery/mockery": "^1.3.1",
|
||||||
|
"orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0",
|
||||||
|
"orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0",
|
||||||
|
"phpunit/phpunit": "^10.4|^11.5|^12.5",
|
||||||
|
"psy/psysh": "^0.11.22|^0.12"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Livewire": "Livewire\\Livewire"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Livewire\\LivewireServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Livewire\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Caleb Porzio",
|
||||||
|
"email": "calebporzio@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A front-end framework for Laravel.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/livewire/livewire/issues",
|
||||||
|
"source": "https://github.com/livewire/livewire/tree/v4.4.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/livewire",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-08-10T15:24:22+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
|
|
@ -5954,76 +5957,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-03-30T13:44:50+00:00"
|
"time": "2026-03-30T13:44:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "tightenco/ziggy",
|
|
||||||
"version": "v2.6.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/tighten/ziggy.git",
|
|
||||||
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/tighten/ziggy/zipball/8a0b645921623f77dceaf543d61ecd51a391d96e",
|
|
||||||
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"laravel/framework": ">=9.0",
|
|
||||||
"php": ">=8.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"laravel/folio": "^1.1",
|
|
||||||
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0",
|
|
||||||
"pestphp/pest": "^2.0 || ^3.0 || ^4.0",
|
|
||||||
"pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"Tighten\\Ziggy\\ZiggyServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tighten\\Ziggy\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Daniel Coulbourne",
|
|
||||||
"email": "daniel@tighten.co"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jake Bathman",
|
|
||||||
"email": "jake@tighten.co"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jacob Baker-Kretzmar",
|
|
||||||
"email": "jacob@tighten.co"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Use your Laravel named routes in JavaScript.",
|
|
||||||
"homepage": "https://github.com/tighten/ziggy",
|
|
||||||
"keywords": [
|
|
||||||
"Ziggy",
|
|
||||||
"javascript",
|
|
||||||
"laravel",
|
|
||||||
"routes"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/tighten/ziggy/issues",
|
|
||||||
"source": "https://github.com/tighten/ziggy/tree/v2.6.2"
|
|
||||||
},
|
|
||||||
"time": "2026-03-05T14:41:19+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.4.0",
|
"version": "v2.4.0",
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Server Side Rendering
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| These options configures if and how Inertia uses Server Side Rendering
|
|
||||||
| to pre-render each initial request made to your application's pages
|
|
||||||
| so that server rendered HTML is delivered for the user's browser.
|
|
||||||
|
|
|
||||||
| See: https://inertiajs.com/server-side-rendering
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'ssr' => [
|
|
||||||
'enabled' => true,
|
|
||||||
'url' => 'http://127.0.0.1:13714',
|
|
||||||
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Testing
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| The values described here are used to locate Inertia components on the
|
|
||||||
| filesystem. For instance, when using `assertInertia`, the assertion
|
|
||||||
| attempts to locate the component as a file relative to the paths.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'testing' => [
|
|
||||||
|
|
||||||
'ensure_pages_exist' => true,
|
|
||||||
|
|
||||||
'page_paths' => [
|
|
||||||
resource_path('js/pages'),
|
|
||||||
],
|
|
||||||
|
|
||||||
'page_extensions' => [
|
|
||||||
'js',
|
|
||||||
'jsx',
|
|
||||||
'svelte',
|
|
||||||
'ts',
|
|
||||||
'tsx',
|
|
||||||
'vue',
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Multi-stage build for Laravel + React application
|
# Multi-stage build for Laravel + Livewire application
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -13,9 +13,6 @@ RUN npm ci
|
||||||
COPY resources/ resources/
|
COPY resources/ resources/
|
||||||
COPY public/ public/
|
COPY public/ public/
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY components.json ./
|
|
||||||
COPY eslint.config.js ./
|
|
||||||
|
|
||||||
# Build frontend assets
|
# Build frontend assets
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import js from '@eslint/js';
|
|
||||||
import prettier from 'eslint-config-prettier';
|
|
||||||
import react from 'eslint-plugin-react';
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
|
||||||
import globals from 'globals';
|
|
||||||
import typescript from 'typescript-eslint';
|
|
||||||
|
|
||||||
/** @type {import('eslint').Linter.Config[]} */
|
|
||||||
export default [
|
|
||||||
js.configs.recommended,
|
|
||||||
...typescript.configs.recommended,
|
|
||||||
{
|
|
||||||
...react.configs.flat.recommended,
|
|
||||||
...react.configs.flat['jsx-runtime'], // Required for React 17+
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
|
||||||
'react/prop-types': 'off',
|
|
||||||
'react/no-unescaped-entities': 'off',
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: 'detect',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
|
|
||||||
},
|
|
||||||
prettier, // Turn off all rules that might conflict with Prettier
|
|
||||||
];
|
|
||||||
6309
package-lock.json
generated
6309
package-lock.json
generated
File diff suppressed because it is too large
Load diff
47
package.json
47
package.json
|
|
@ -3,57 +3,12 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:ssr": "vite build && vite build --ssr",
|
"dev": "vite"
|
||||||
"dev": "vite",
|
|
||||||
"format": "prettier --write resources/",
|
|
||||||
"format:check": "prettier --check resources/",
|
|
||||||
"lint": "eslint . --fix",
|
|
||||||
"types": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
|
||||||
"@types/node": "^22.13.5",
|
|
||||||
"eslint": "^9.39.4",
|
|
||||||
"eslint-config-prettier": "^10.0.1",
|
|
||||||
"eslint-plugin-react": "^7.37.3",
|
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
|
||||||
"prettier": "^3.4.2",
|
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
|
||||||
"typescript-eslint": "^8.23.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@headlessui/react": "^2.2.0",
|
|
||||||
"@inertiajs/react": "^3.0.3",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@types/react": "^19.0.3",
|
|
||||||
"@types/react-dom": "^19.0.2",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"concurrently": "^9.0.1",
|
|
||||||
"globals": "^15.14.0",
|
|
||||||
"laravel-vite-plugin": "^3.1.0",
|
"laravel-vite-plugin": "^3.1.0",
|
||||||
"lucide-react": "^1.14.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"tailwind-merge": "^3.0.1",
|
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@plugin 'tailwindcss-animate';
|
|
||||||
|
|
||||||
@source '../views';
|
@source '../views';
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: '7Segment';
|
font-family: '7Segment';
|
||||||
|
|
@ -24,159 +21,3 @@ .glow-red {
|
||||||
.glow-red:hover {
|
.glow-red:hover {
|
||||||
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
|
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:is(.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';
|
|
||||||
|
|
||||||
--font-mono-display:
|
|
||||||
'Major Mono Display', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
|
||||||
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
|
||||||
so we've added these compatibility styles to make sure everything still
|
|
||||||
looks the same as it did with Tailwind CSS v3.
|
|
||||||
|
|
||||||
If we ever want to remove these styles, we need to add an explicit border
|
|
||||||
color utility to any element that depends on these defaults.
|
|
||||||
*/
|
|
||||||
@layer base {
|
|
||||||
*,
|
|
||||||
::after,
|
|
||||||
::before,
|
|
||||||
::backdrop,
|
|
||||||
::file-selector-button {
|
|
||||||
border-color: var(--color-gray-200, currentColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.87 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.87 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.145 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.145 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.985 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
||||||
--border: oklch(0.269 0 0);
|
|
||||||
--input: oklch(0.269 0 0);
|
|
||||||
--ring: oklch(0.439 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import '../css/app.css';
|
|
||||||
|
|
||||||
import { createInertiaApp } from '@inertiajs/react';
|
|
||||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { initializeTheme } from './hooks/use-appearance';
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
|
||||||
|
|
||||||
createInertiaApp({
|
|
||||||
title: (title) => title ? `${title} - ${appName}` : appName,
|
|
||||||
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
|
||||||
setup({ el, App, props }) {
|
|
||||||
const root = createRoot(el);
|
|
||||||
|
|
||||||
root.render(<App {...props} />);
|
|
||||||
},
|
|
||||||
progress: {
|
|
||||||
color: '#4B5563',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// This will set light / dark mode on load...
|
|
||||||
initializeTheme();
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { csrfToken } from '@/lib/utils';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useState } from 'react';
|
|
||||||
|
|
||||||
interface SetCountFormProps {
|
|
||||||
currentCount: number;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: (count: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SetCountForm({ currentCount, onClose, onSuccess }: SetCountFormProps) {
|
|
||||||
const [value, setValue] = useState(String(currentCount));
|
|
||||||
const [processing, setProcessing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const submit: FormEventHandler = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setProcessing(true);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/count', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ count: Number(value) }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setError('Enter a whole number of zero or more.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { count } = await response.json();
|
|
||||||
onSuccess(count);
|
|
||||||
onClose();
|
|
||||||
} catch {
|
|
||||||
setError('Something went wrong. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-black p-8">
|
|
||||||
<div className="w-full border-4 border-red-500 p-6 bg-black glow-red">
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<Label
|
|
||||||
htmlFor="count"
|
|
||||||
className="text-red-400 font-mono text-xs uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
> Set Value
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="count"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
autoFocus
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={error} />
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing || value === ''}
|
|
||||||
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[EXECUTE]
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface LedDisplayProps {
|
|
||||||
value: number;
|
|
||||||
className?: string;
|
|
||||||
animate?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LedDisplay({
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
onClick
|
|
||||||
}: LedDisplayProps) {
|
|
||||||
const [displayValue, setDisplayValue] = useState(0);
|
|
||||||
|
|
||||||
// Animate number changes
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayValue(value);
|
|
||||||
return;
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const formattedValue = Math.floor(displayValue).toString();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-full text-center select-none cursor-pointer",
|
|
||||||
"bg-black text-red-500",
|
|
||||||
"px-8 py-12 transition-all duration-300",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className="relative w-full flex items-center justify-center">
|
|
||||||
<div className={cn(
|
|
||||||
"relative z-10",
|
|
||||||
"text-[8rem] md:text-[12rem] lg:text-[16rem]",
|
|
||||||
"font-digital font-normal",
|
|
||||||
"text-red-500",
|
|
||||||
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
|
|
||||||
"filter brightness-110",
|
|
||||||
"leading-none",
|
|
||||||
"transition-all duration-300"
|
|
||||||
)}
|
|
||||||
style={{ letterSpacing: '0.15em' }}>
|
|
||||||
{formattedValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
export default function InputError({ message, className = '', ...props }: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
|
|
||||||
return message ? (
|
|
||||||
<p {...props} className={cn('text-sm text-red-600 dark:text-red-400', className)}>
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { csrfToken } from '@/lib/utils';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useState } from 'react';
|
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|
||||||
const [startingValue, setStartingValue] = useState('0');
|
|
||||||
const [processing, setProcessing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const submit: FormEventHandler = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setProcessing(true);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const trackerResponse = await fetch(route('tracker.store'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!trackerResponse.ok && trackerResponse.status !== 409) {
|
|
||||||
setError('Could not create the counter. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Number(startingValue);
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
const countResponse = await fetch('/count', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ count }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!countResponse.ok) {
|
|
||||||
setError('Could not save the starting value. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onComplete?.();
|
|
||||||
} catch {
|
|
||||||
setError('Something went wrong. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<Label
|
|
||||||
htmlFor="starting-value"
|
|
||||||
className="text-red-400 font-mono text-xs uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
> Starting Value
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="starting-value"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
autoFocus
|
|
||||||
value={startingValue}
|
|
||||||
onChange={(e) => setStartingValue(e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={error} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing || startingValue === ''}
|
|
||||||
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[INITIALIZE]
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { FC, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ComponentTitleProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ComponentTitle: FC<ComponentTitleProps> = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<h2 className="text-red-500 text-lg font-mono font-bold tracking-wider uppercase">
|
|
||||||
{ children }
|
|
||||||
</h2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ComponentTitle
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface TerminalSpinnerProps {
|
|
||||||
text?: string;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
fullScreen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TerminalSpinner({
|
|
||||||
text = 'LOADING',
|
|
||||||
size = 'md',
|
|
||||||
fullScreen = false
|
|
||||||
}: TerminalSpinnerProps) {
|
|
||||||
const [dots, setDots] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setDots(prev => (prev + 1) % 4);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getDots = () => {
|
|
||||||
switch (dots) {
|
|
||||||
case 0: return ' ';
|
|
||||||
case 1: return '. ';
|
|
||||||
case 2: return '.. ';
|
|
||||||
case 3: return '...';
|
|
||||||
default: return ' ';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'text-sm',
|
|
||||||
md: 'text-lg',
|
|
||||||
lg: 'text-xl'
|
|
||||||
};
|
|
||||||
|
|
||||||
const spinner = (
|
|
||||||
<div className="border border-red-500/30 bg-black p-6">
|
|
||||||
<span className={`text-red-500 font-mono ${sizeClasses[size]} uppercase tracking-wider`}>
|
|
||||||
[SYSTEM] {text}<span className="inline-block w-8">{getDots()}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fullScreen) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
|
||||||
{spinner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return spinner;
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
||||||
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
||||||
icon: "size-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
data-slot="input"
|
|
||||||
className={cn(
|
|
||||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Label({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
data-slot="label"
|
|
||||||
className={cn(
|
|
||||||
"text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export type Appearance = 'light' | 'dark' | 'system';
|
|
||||||
|
|
||||||
const prefersDark = () => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setCookie = (name: string, value: string, days = 365) => {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAge = days * 24 * 60 * 60;
|
|
||||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyTheme = (appearance: Appearance) => {
|
|
||||||
const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark());
|
|
||||||
|
|
||||||
document.documentElement.classList.toggle('dark', isDark);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mediaQuery = () => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSystemThemeChange = () => {
|
|
||||||
const currentAppearance = localStorage.getItem('appearance') as Appearance;
|
|
||||||
applyTheme(currentAppearance || 'system');
|
|
||||||
};
|
|
||||||
|
|
||||||
export function initializeTheme() {
|
|
||||||
const savedAppearance = (localStorage.getItem('appearance') as Appearance) || 'system';
|
|
||||||
|
|
||||||
applyTheme(savedAppearance);
|
|
||||||
|
|
||||||
// Add the event listener for system theme changes...
|
|
||||||
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAppearance() {
|
|
||||||
const [appearance, setAppearance] = useState<Appearance>('system');
|
|
||||||
|
|
||||||
const updateAppearance = useCallback((mode: Appearance) => {
|
|
||||||
setAppearance(mode);
|
|
||||||
|
|
||||||
// Store in localStorage for client-side persistence...
|
|
||||||
localStorage.setItem('appearance', mode);
|
|
||||||
|
|
||||||
// Store in cookie for SSR...
|
|
||||||
setCookie('appearance', mode);
|
|
||||||
|
|
||||||
applyTheme(mode);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedAppearance = localStorage.getItem('appearance') as Appearance | null;
|
|
||||||
updateAppearance(savedAppearance || 'system');
|
|
||||||
|
|
||||||
return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange);
|
|
||||||
}, [updateAppearance]);
|
|
||||||
|
|
||||||
return { appearance, updateAppearance } as const;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { type ClassValue, clsx } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const csrfToken = (): string =>
|
|
||||||
(document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '';
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import LedDisplay from '@/components/Display/LedDisplay';
|
|
||||||
import SetCountForm from '@/components/Counter/SetCountForm';
|
|
||||||
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
|
||||||
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
|
||||||
import { csrfToken } from '@/lib/utils';
|
|
||||||
import { Head } from '@inertiajs/react';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [incrementing, setIncrementing] = useState(false);
|
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
const response = await fetch('/tracker');
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setNeedsOnboarding(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { exists, tracker } = await response.json();
|
|
||||||
|
|
||||||
setCount(tracker?.count ?? 0);
|
|
||||||
setNeedsOnboarding(!exists);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
await loadData();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch data:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const increment = async () => {
|
|
||||||
if (incrementing) return;
|
|
||||||
|
|
||||||
const previous = count;
|
|
||||||
setIncrementing(true);
|
|
||||||
setCount(previous + 1);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/increment', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setCount(previous);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { count: updated } = await response.json();
|
|
||||||
setCount(updated);
|
|
||||||
} catch {
|
|
||||||
setCount(previous);
|
|
||||||
} finally {
|
|
||||||
setIncrementing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title="Dashboard" />
|
|
||||||
<TerminalSpinner fullScreen />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsOnboarding) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title="incr - Setup" />
|
|
||||||
<OnboardingFlow onComplete={loadData} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title="incr" />
|
|
||||||
|
|
||||||
<div className="min-h-screen bg-black">
|
|
||||||
<div className="w-full max-w-4xl mx-auto px-4">
|
|
||||||
<div className="pt-32">
|
|
||||||
<LedDisplay value={count} onClick={increment} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormOpen(true)}
|
|
||||||
className="text-red-400/60 hover:text-red-400 font-mono text-xs uppercase tracking-widest transition-colors"
|
|
||||||
>
|
|
||||||
[SET VALUE]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formOpen && (
|
|
||||||
<SetCountForm
|
|
||||||
currentCount={count}
|
|
||||||
onClose={() => setFormOpen(false)}
|
|
||||||
onSuccess={setCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { createInertiaApp } from '@inertiajs/react';
|
|
||||||
import createServer from '@inertiajs/react/server';
|
|
||||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
|
||||||
import ReactDOMServer from 'react-dom/server';
|
|
||||||
import { type RouteName, route } from 'ziggy-js';
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
|
||||||
|
|
||||||
createServer((page) =>
|
|
||||||
createInertiaApp({
|
|
||||||
page,
|
|
||||||
render: ReactDOMServer.renderToString,
|
|
||||||
title: (title) => title ? `${title} - ${appName}` : appName,
|
|
||||||
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
|
||||||
setup: ({ App, props }) => {
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-expect-error
|
|
||||||
global.route<RouteName> = (name, params, absolute) =>
|
|
||||||
route(name, params as any, absolute, {
|
|
||||||
// @ts-expect-error
|
|
||||||
...page.props.ziggy,
|
|
||||||
// @ts-expect-error
|
|
||||||
location: new URL(page.props.ziggy.location),
|
|
||||||
});
|
|
||||||
/* eslint-enable */
|
|
||||||
|
|
||||||
return <App {...props} />;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export interface Tracker {
|
|
||||||
id: number;
|
|
||||||
label: string;
|
|
||||||
unit: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
5
resources/js/types/global.d.ts
vendored
5
resources/js/types/global.d.ts
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
import type { route as routeFn } from 'ziggy-js';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
const route: typeof routeFn;
|
|
||||||
}
|
|
||||||
43
resources/js/types/index.d.ts
vendored
43
resources/js/types/index.d.ts
vendored
|
|
@ -1,43 +0,0 @@
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import type { Config } from 'ziggy-js';
|
|
||||||
|
|
||||||
export interface Auth {
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavGroup {
|
|
||||||
title: string;
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavItem {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
icon?: LucideIcon | null;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SharedData {
|
|
||||||
name: string;
|
|
||||||
quote: { message: string; author: string };
|
|
||||||
auth: Auth;
|
|
||||||
ziggy: Config & { location: string };
|
|
||||||
sidebarOpen: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar?: string;
|
|
||||||
email_verified_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
[key: string]: unknown; // This allows for additional properties...
|
|
||||||
}
|
|
||||||
1
resources/js/types/vite-env.d.ts
vendored
1
resources/js/types/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
const appearance = '{{ $appearance ?? "system" }}';
|
|
||||||
|
|
||||||
if (appearance === 'system') {
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
|
|
||||||
if (prefersDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
|
|
||||||
<style>
|
|
||||||
html {
|
|
||||||
background-color: oklch(1 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
background-color: oklch(0.145 0 0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|major-mono-display:400" rel="stylesheet" />
|
|
||||||
|
|
||||||
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
|
|
||||||
|
|
||||||
@routes
|
|
||||||
@viteReactRefresh
|
|
||||||
@vite(['resources/js/app.tsx'])
|
|
||||||
@inertiaHead
|
|
||||||
</head>
|
|
||||||
<body class="font-sans antialiased">
|
|
||||||
@inertia
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
24
resources/views/layouts/app.blade.php
Normal file
24
resources/views/layouts/app.blade.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<title>{{ config('app.name', 'incr') }}</title>
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
|
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
|
||||||
|
|
||||||
|
@vite(['resources/css/app.css'])
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-black antialiased">
|
||||||
|
{{ $slot }}
|
||||||
|
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
resources/views/livewire/counter.blade.php
Normal file
100
resources/views/livewire/counter.blade.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<div class="min-h-screen bg-black">
|
||||||
|
@if ($needsOnboarding)
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
||||||
|
<form wire:submit="initialise" class="space-y-4">
|
||||||
|
<label for="starting-value" class="text-red-400 font-mono text-xs uppercase tracking-wider">
|
||||||
|
> Starting Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="starting-value"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
autofocus
|
||||||
|
wire:model="value"
|
||||||
|
class="w-full bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red px-3 py-2"
|
||||||
|
>
|
||||||
|
@error('value')
|
||||||
|
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
|
||||||
|
>
|
||||||
|
[INITIALIZE]
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="w-full max-w-4xl mx-auto px-4">
|
||||||
|
<div class="pt-32">
|
||||||
|
<div
|
||||||
|
wire:click="increment"
|
||||||
|
class="w-full text-center select-none cursor-pointer bg-black text-red-500 px-8 py-12 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div class="relative w-full flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="relative z-10 text-[8rem] md:text-[12rem] lg:text-[16rem] font-digital font-normal text-red-500 drop-shadow-[0_0_10px_rgba(239,68,68,0.8)] filter brightness-110 leading-none transition-all duration-300"
|
||||||
|
style="letter-spacing: 0.15em"
|
||||||
|
>{{ $count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="edit"
|
||||||
|
class="text-red-400/60 hover:text-red-400 font-mono text-xs uppercase tracking-widest transition-colors"
|
||||||
|
>
|
||||||
|
[SET VALUE]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($editing)
|
||||||
|
<div class="bg-black p-8">
|
||||||
|
<div class="w-full border-4 border-red-500 p-6 bg-black glow-red">
|
||||||
|
<form wire:submit="save" class="space-y-4">
|
||||||
|
<label for="count" class="text-red-400 font-mono text-xs uppercase tracking-wider">
|
||||||
|
> Set Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="count"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
autofocus
|
||||||
|
wire:model="value"
|
||||||
|
class="w-full bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red px-3 py-2"
|
||||||
|
>
|
||||||
|
@error('value')
|
||||||
|
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
|
||||||
|
>
|
||||||
|
[EXECUTE]
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="cancel"
|
||||||
|
class="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
|
||||||
|
>
|
||||||
|
[ABORT]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -1,23 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\CounterController;
|
use App\Livewire\Counter;
|
||||||
use App\Http\Controllers\TrackerController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', Counter::class)->name('home');
|
||||||
return redirect('/dashboard');
|
|
||||||
})->name('home');
|
|
||||||
|
|
||||||
Route::get('dashboard', function () {
|
|
||||||
return Inertia::render('dashboard');
|
|
||||||
})->name('dashboard');
|
|
||||||
|
|
||||||
// Tracker routes
|
|
||||||
Route::get('/tracker', [TrackerController::class, 'show'])->name('tracker.show');
|
|
||||||
Route::post('/tracker', [TrackerController::class, 'store'])->name('tracker.store');
|
|
||||||
Route::patch('/tracker', [TrackerController::class, 'update'])->name('tracker.update');
|
|
||||||
|
|
||||||
// Counter routes
|
|
||||||
Route::post('/increment', [CounterController::class, 'increment'])->name('counter.increment');
|
|
||||||
Route::patch('/count', [CounterController::class, 'update'])->name('counter.update');
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Counter;
|
||||||
use App\Models\Tracker;
|
use App\Models\Tracker;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -18,13 +20,23 @@ private function tracker(int $count = 0): Tracker
|
||||||
return Tracker::factory()->create(['count' => $count]);
|
return Tracker::factory()->create(['count' => $count]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_the_page_renders_the_current_count(): void
|
||||||
|
{
|
||||||
|
$this->tracker(42);
|
||||||
|
|
||||||
|
$this->get('/')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('42');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_increment_adds_exactly_one(): void
|
public function test_increment_adds_exactly_one(): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker(5);
|
$tracker = $this->tracker(5);
|
||||||
|
|
||||||
$response = $this->postJson('/increment');
|
Livewire::test(Counter::class)
|
||||||
|
->call('increment')
|
||||||
|
->assertSet('count', 6);
|
||||||
|
|
||||||
$response->assertOk()->assertJson(['count' => 6]);
|
|
||||||
$this->assertSame(6, $tracker->refresh()->count);
|
$this->assertSame(6, $tracker->refresh()->count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,25 +44,26 @@ public function test_increments_accumulate(): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker();
|
$tracker = $this->tracker();
|
||||||
|
|
||||||
$this->postJson('/increment');
|
Livewire::test(Counter::class)
|
||||||
$this->postJson('/increment');
|
->call('increment')
|
||||||
$this->postJson('/increment');
|
->call('increment')
|
||||||
|
->call('increment')
|
||||||
|
->assertSet('count', 3);
|
||||||
|
|
||||||
$this->assertSame(3, $tracker->refresh()->count);
|
$this->assertSame(3, $tracker->refresh()->count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_increment_returns_404_without_a_tracker(): void
|
|
||||||
{
|
|
||||||
$this->postJson('/increment')->assertNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_count_can_be_set_to_an_absolute_value(): void
|
public function test_count_can_be_set_to_an_absolute_value(): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker(3);
|
$tracker = $this->tracker(3);
|
||||||
|
|
||||||
$response = $this->patchJson('/count', ['count' => 250]);
|
Livewire::test(Counter::class)
|
||||||
|
->call('edit')
|
||||||
|
->set('value', 250)
|
||||||
|
->call('save')
|
||||||
|
->assertSet('count', 250)
|
||||||
|
->assertSet('editing', false);
|
||||||
|
|
||||||
$response->assertOk()->assertJson(['count' => 250]);
|
|
||||||
$this->assertSame(250, $tracker->refresh()->count);
|
$this->assertSame(250, $tracker->refresh()->count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +71,10 @@ public function test_count_can_be_set_to_zero(): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker(42);
|
$tracker = $this->tracker(42);
|
||||||
|
|
||||||
$this->patchJson('/count', ['count' => 0])->assertOk();
|
Livewire::test(Counter::class)
|
||||||
|
->call('edit')
|
||||||
|
->set('value', 0)
|
||||||
|
->call('save');
|
||||||
|
|
||||||
$this->assertSame(0, $tracker->refresh()->count);
|
$this->assertSame(0, $tracker->refresh()->count);
|
||||||
}
|
}
|
||||||
|
|
@ -70,49 +86,47 @@ public static function invalidCounts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'negative' => [-1],
|
'negative' => [-1],
|
||||||
'non-numeric string' => ['abc'],
|
|
||||||
'fractional' => [1.5],
|
|
||||||
'above unsigned int ceiling' => [4294967296],
|
'above unsigned int ceiling' => [4294967296],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[DataProvider('invalidCounts')]
|
#[DataProvider('invalidCounts')]
|
||||||
public function test_count_rejects_invalid_values(mixed $value): void
|
public function test_save_rejects_invalid_values(mixed $value): void
|
||||||
{
|
{
|
||||||
$tracker = $this->tracker(7);
|
$tracker = $this->tracker(7);
|
||||||
|
|
||||||
$this->patchJson('/count', ['count' => $value])
|
Livewire::test(Counter::class)
|
||||||
->assertStatus(422)
|
->call('edit')
|
||||||
->assertJsonValidationErrors('count');
|
->set('value', $value)
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors('value');
|
||||||
|
|
||||||
$this->assertSame(7, $tracker->refresh()->count);
|
$this->assertSame(7, $tracker->refresh()->count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_count_requires_a_value(): void
|
public function test_cancel_discards_the_edit(): void
|
||||||
{
|
{
|
||||||
$this->tracker();
|
$tracker = $this->tracker(9);
|
||||||
|
|
||||||
$this->patchJson('/count', [])
|
Livewire::test(Counter::class)
|
||||||
->assertStatus(422)
|
->call('edit')
|
||||||
->assertJsonValidationErrors('count');
|
->set('value', 500)
|
||||||
|
->call('cancel')
|
||||||
|
->assertSet('count', 9)
|
||||||
|
->assertSet('editing', false);
|
||||||
|
|
||||||
|
$this->assertSame(9, $tracker->refresh()->count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_count_returns_404_without_a_tracker(): void
|
public function test_increment_stops_at_the_column_ceiling(): void
|
||||||
{
|
{
|
||||||
$this->patchJson('/count', ['count' => 5])->assertNotFound();
|
$tracker = $this->tracker(Counter::MAX_COUNT);
|
||||||
}
|
|
||||||
|
|
||||||
public function test_counter_endpoints_return_json_not_redirects(): void
|
Livewire::test(Counter::class)
|
||||||
{
|
->call('increment')
|
||||||
$this->tracker();
|
->assertSet('count', Counter::MAX_COUNT);
|
||||||
|
|
||||||
$this->postJson('/increment')
|
$this->assertSame(Counter::MAX_COUNT, $tracker->refresh()->count);
|
||||||
->assertOk()
|
|
||||||
->assertHeader('content-type', 'application/json');
|
|
||||||
|
|
||||||
$this->patchJson('/count', ['count' => 1])
|
|
||||||
->assertOk()
|
|
||||||
->assertHeader('content-type', 'application/json');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_count_is_cast_to_an_integer(): void
|
public function test_count_is_cast_to_an_integer(): void
|
||||||
|
|
|
||||||
|
|
@ -4,78 +4,85 @@
|
||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Counter;
|
||||||
use App\Models\Tracker;
|
use App\Models\Tracker;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class TrackerTest extends TestCase
|
class TrackerTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_show_reports_no_tracker_on_a_fresh_install(): void
|
public function test_a_fresh_install_asks_for_a_starting_value(): void
|
||||||
{
|
{
|
||||||
$this->getJson('/tracker')
|
Livewire::test(Counter::class)
|
||||||
->assertOk()
|
->assertSet('needsOnboarding', true)
|
||||||
->assertJson(['exists' => false]);
|
->assertSee('Starting Value');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_show_returns_the_tracker_once_created(): void
|
public function test_initialise_creates_the_counter(): void
|
||||||
{
|
{
|
||||||
Tracker::factory()->create(['count' => 9]);
|
Livewire::test(Counter::class)
|
||||||
|
->set('value', 47)
|
||||||
$this->getJson('/tracker')
|
->call('initialise')
|
||||||
->assertOk()
|
->assertSet('needsOnboarding', false)
|
||||||
->assertJson([
|
->assertSet('count', 47);
|
||||||
'exists' => true,
|
|
||||||
'tracker' => ['count' => 9],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_a_tracker_can_be_created_without_a_label_or_unit(): void
|
|
||||||
{
|
|
||||||
$this->postJson('/tracker', [])->assertCreated();
|
|
||||||
|
|
||||||
$tracker = Tracker::first();
|
$tracker = Tracker::first();
|
||||||
|
|
||||||
|
$this->assertNotNull($tracker);
|
||||||
|
$this->assertSame(47, $tracker->count);
|
||||||
$this->assertSame('Counter', $tracker->label);
|
$this->assertSame('Counter', $tracker->label);
|
||||||
$this->assertSame('units', $tracker->unit);
|
|
||||||
$this->assertSame(0, $tracker->count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_a_new_counter_starts_at_zero(): void
|
public function test_a_counter_can_start_at_zero(): void
|
||||||
{
|
{
|
||||||
$this->postJson('/tracker', []);
|
Livewire::test(Counter::class)
|
||||||
|
->set('value', 0)
|
||||||
|
->call('initialise')
|
||||||
|
->assertSet('needsOnboarding', false)
|
||||||
|
->assertSet('count', 0);
|
||||||
|
|
||||||
$this->getJson('/tracker')
|
$this->assertSame(0, Tracker::first()->count);
|
||||||
->assertOk()
|
|
||||||
->assertJson(['tracker' => ['count' => 0]]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_creating_a_second_tracker_conflicts(): void
|
public function test_a_zero_count_does_not_reopen_onboarding(): void
|
||||||
{
|
{
|
||||||
$this->postJson('/tracker', [])->assertCreated();
|
Tracker::factory()->create(['count' => 0]);
|
||||||
$this->postJson('/tracker', [])->assertStatus(409);
|
|
||||||
|
Livewire::test(Counter::class)
|
||||||
|
->assertSet('needsOnboarding', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_initialise_rejects_a_negative_starting_value(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Counter::class)
|
||||||
|
->set('value', -5)
|
||||||
|
->call('initialise')
|
||||||
|
->assertHasErrors('value')
|
||||||
|
->assertSet('needsOnboarding', true);
|
||||||
|
|
||||||
|
$this->assertSame(0, Tracker::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_existing_counter_skips_onboarding(): void
|
||||||
|
{
|
||||||
|
Tracker::factory()->create(['count' => 9]);
|
||||||
|
|
||||||
|
Livewire::test(Counter::class)
|
||||||
|
->assertSet('needsOnboarding', false)
|
||||||
|
->assertSet('count', 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_initialising_twice_does_not_create_a_second_counter(): void
|
||||||
|
{
|
||||||
|
$component = Livewire::test(Counter::class)->set('value', 5);
|
||||||
|
|
||||||
|
Tracker::factory()->create(['count' => 12]);
|
||||||
|
|
||||||
|
$component->call('initialise')->assertSet('count', 12);
|
||||||
|
|
||||||
$this->assertSame(1, Tracker::count());
|
$this->assertSame(1, Tracker::count());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_label_and_unit_can_be_updated(): void
|
|
||||||
{
|
|
||||||
$tracker = Tracker::factory()->create(['count' => 4]);
|
|
||||||
|
|
||||||
$this->patchJson('/tracker', ['label' => 'Books', 'unit' => 'books'])
|
|
||||||
->assertOk()
|
|
||||||
->assertJson(['label' => 'Books', 'unit' => 'books']);
|
|
||||||
|
|
||||||
$tracker->refresh();
|
|
||||||
|
|
||||||
$this->assertSame('Books', $tracker->label);
|
|
||||||
$this->assertSame('books', $tracker->unit);
|
|
||||||
$this->assertSame(4, $tracker->count, 'updating the label must not disturb the count');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_returns_404_without_a_tracker(): void
|
|
||||||
{
|
|
||||||
$this->patchJson('/tracker', ['label' => 'Books'])->assertNotFound();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
tsconfig.json
122
tsconfig.json
|
|
@ -1,122 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
|
||||||
|
|
||||||
/* Projects */
|
|
||||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
|
||||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
|
||||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
|
||||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
|
||||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
|
||||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
|
||||||
|
|
||||||
/* Language and Environment */
|
|
||||||
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
|
||||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
|
||||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
|
||||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
|
||||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
|
||||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
|
||||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
|
||||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
|
||||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
|
||||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
|
||||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
|
||||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
|
||||||
|
|
||||||
/* Modules */
|
|
||||||
"module": "ESNext" /* Specify what module code is generated. */,
|
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
|
||||||
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
|
||||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
|
||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
|
||||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
|
||||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
|
||||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
|
||||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
|
||||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
|
||||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
|
||||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
|
||||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
|
||||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
|
||||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
|
||||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
|
||||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
|
||||||
|
|
||||||
/* JavaScript Support */
|
|
||||||
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */,
|
|
||||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
|
||||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
|
||||||
|
|
||||||
/* Emit */
|
|
||||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
|
||||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
|
||||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
|
||||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
|
||||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
|
||||||
"noEmit": true /* Disable emitting files from a compilation. */,
|
|
||||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
|
||||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
|
||||||
// "removeComments": true, /* Disable emitting comments. */
|
|
||||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
|
||||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
|
||||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
|
||||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
|
||||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
|
||||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
|
||||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
|
||||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
|
||||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
|
||||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
|
||||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
|
||||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
|
||||||
|
|
||||||
/* Interop Constraints */
|
|
||||||
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
|
||||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
|
||||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
|
||||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
|
||||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
|
||||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
|
||||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
|
||||||
|
|
||||||
/* Type Checking */
|
|
||||||
"strict": true /* Enable all strict type-checking options. */,
|
|
||||||
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
|
|
||||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
|
||||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
|
||||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
|
||||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
|
||||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
|
||||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
|
||||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
|
||||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
|
||||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
|
||||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
|
||||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
|
||||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
|
||||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
|
||||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
|
||||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
|
||||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
|
||||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
|
||||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
|
||||||
|
|
||||||
/* Completeness */
|
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./resources/js/*"],
|
|
||||||
"ziggy-js": ["./vendor/tightenco/ziggy"]
|
|
||||||
},
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"resources/js/**/*.ts",
|
|
||||||
"resources/js/**/*.d.ts",
|
|
||||||
"resources/js/**/*.tsx",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +1,22 @@
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
laravel({
|
||||||
|
input: ['resources/css/app.css'],
|
||||||
|
refresh: true,
|
||||||
|
}),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
cors: true,
|
||||||
hmr: {
|
hmr: {
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
clientPort: 5173,
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
usePolling: true,
|
|
||||||
ignored: ['**/storage/framework/views/**'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
laravel({
|
|
||||||
input: ['resources/css/app.css', 'resources/js/app.tsx'],
|
|
||||||
ssr: 'resources/js/ssr.tsx',
|
|
||||||
refresh: true,
|
|
||||||
}),
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
esbuild: {
|
|
||||||
jsx: 'automatic',
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue