release/0.4.0 #60

Merged
myrmidex merged 30 commits from release/0.4.0 into main 2026-08-16 11:58:52 +02:00
44 changed files with 510 additions and 7966 deletions
Showing only changes of commit 6ce470374f - Show all commits

View file

@ -1,3 +0,0 @@
resources/js/components/ui/*
resources/js/ziggy.js
resources/views/mail/*

View file

@ -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
}
}
]
}

View file

@ -8,7 +8,7 @@ # incr
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Laravel](https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white)](https://laravel.com/) [![Laravel](https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white)](https://laravel.com/)
[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://reactjs.org/) [![Livewire](https://img.shields.io/badge/Livewire-4-FB70A9?logo=livewire&logoColor=white)](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

View file

@ -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]);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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
View 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');
}
}

View file

@ -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,
]); ]);
}) })

View file

@ -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"
}

View file

@ -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
View file

@ -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",

View file

@ -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',
],
],
];

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -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;
}
}

View file

@ -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();

View file

@ -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"
>
&gt; 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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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"
>
&gt; 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>
);
}

View file

@ -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

View file

@ -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;
}

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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;
}

View file

@ -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 ?? '';

View file

@ -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>
</>
);
}

View file

@ -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} />;
},
}),
);

View file

@ -1,6 +0,0 @@
export interface Tracker {
id: number;
label: string;
unit: string;
count: number;
}

View file

@ -1,5 +0,0 @@
import type { route as routeFn } from 'ziggy-js';
declare global {
const route: typeof routeFn;
}

View file

@ -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...
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -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>

View 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>

View 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">
&gt; 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">
&gt; 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>

View file

@ -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');

View file

@ -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

View file

@ -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();
}
} }

View file

@ -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",
]
}

View file

@ -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'),
}, },
}, },
}); });