Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/Http/Middleware/HandleAppearance.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?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(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');

return $next($request);
}
}
5 changes: 5 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;

class HandleInertiaRequests extends Middleware
{
Expand Down Expand Up @@ -45,6 +46,10 @@ public function share(Request $request): array
'auth' => [
'user' => $request->user(),
],
'ziggy' => fn (): array => [
...(new Ziggy)->toArray(),
'location' => $request->url(),
]
];
}
}
4 changes: 4 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
Expand All @@ -13,7 +14,10 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->encryptCookies(except: ['appearance']);

$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
Expand Down
5 changes: 5 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
"dev": [
"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"
],
"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"
]
},
"extra": {
Expand Down
724 changes: 371 additions & 353 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.13.5",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-react": "^7.37.3",
Expand Down
5 changes: 0 additions & 5 deletions resources/js/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@ import '../css/app.css';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { route as routeFn } from 'ziggy-js';
import { initializeTheme } from './hooks/use-appearance';

declare global {
const route: typeof routeFn;
}

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

createInertiaApp({
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
<SheetHeader className="flex justify-start text-left">
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
</SheetHeader>
<div className="p-4 flex h-full flex-1 flex-col space-y-4">
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
<div className="flex h-full flex-col justify-between text-sm">
<div className="flex flex-col space-y-4">
{mainNavItems.map((item) => (
Expand Down
35 changes: 31 additions & 4 deletions resources/js/hooks/use-appearance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,36 @@ import { useCallback, useEffect, useState } from 'react';

export type Appearance = 'light' | 'dark' | 'system';

const prefersDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
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 = window.matchMedia('(prefers-color-scheme: dark)');
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}

return window.matchMedia('(prefers-color-scheme: dark)');
};

const handleSystemThemeChange = () => {
const currentAppearance = localStorage.getItem('appearance') as Appearance;
Expand All @@ -23,23 +44,29 @@ export function initializeTheme() {
applyTheme(savedAppearance);

// Add the event listener for system theme changes...
mediaQuery.addEventListener('change', handleSystemThemeChange);
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);
return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange);
}, [updateAppearance]);

return { appearance, updateAppearance } as const;
Expand Down
5 changes: 5 additions & 0 deletions resources/js/layouts/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ const sidebarNavItems: NavItem[] = [
];

export default function SettingsLayout({ children }: PropsWithChildren) {
// When server-side rendering, we only render the layout on the client...
if (typeof window === 'undefined') {
return null;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is url in usePage hooks, why not just use that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We certainly could, both will accomplish the same thing I'm pretty sure. This is just a preference of being explicit about rendering on the server.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess in that context I prefer that way too, thanks for the answer.


const currentPath = window.location.pathname;

return (
Expand Down
8 changes: 7 additions & 1 deletion resources/js/pages/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,13 @@ export default function Login({ status, canResetPassword }: LoginProps) {
</div>

<div className="flex items-center space-x-3">
<Checkbox id="remember" name="remember" checked={data.remember} onClick={() => setData('remember', !data.remember)} tabIndex={3} />
<Checkbox
id="remember"
name="remember"
checked={data.remember}
onClick={() => setData('remember', !data.remember)}
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
</div>

Expand Down
21 changes: 0 additions & 21 deletions resources/js/ssr.jsx

This file was deleted.

30 changes: 30 additions & 0 deletions resources/js/ssr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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} - ${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} />;
},
}),
);
5 changes: 5 additions & 0 deletions resources/js/types/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { route as routeFn } from 'ziggy-js';

declare global {
const route: typeof routeFn;
}
2 changes: 2 additions & 0 deletions resources/js/types/index.ts → resources/js/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LucideIcon } from 'lucide-react';
import type { Config } from 'ziggy-js';

export interface Auth {
user: User;
Expand All @@ -25,6 +26,7 @@ export interface SharedData {
name: string;
quote: { message: string; author: string };
auth: Auth;
ziggy: Config & { location: string };
[key: string]: unknown;
}

Expand Down
28 changes: 27 additions & 1 deletion resources/views/app.blade.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<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>

<link rel="preconnect" href="https://fonts.bunny.net">
Expand Down
6 changes: 5 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,9 @@
},
"jsx": "react-jsx"
},
"include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"]
"include": [
"resources/js/**/*.ts",
"resources/js/**/*.d.ts",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would "resources/js/**/*.d.ts" not already be targeted with "resources/js/**/*.ts"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is necessary, but I feel like it's not a bad thing to include because it helps with clarity. Some devs perfer to explicitly include the .d.ts 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RV7PR, correct. Technically not necessary, but I like to include them to be very explicit that the project include type declaration files. Similar to how the original Breeze templates worked as well.

"resources/js/**/*.tsx",
]
}
16 changes: 10 additions & 6 deletions vite.config.js → vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import laravel from 'laravel-vite-plugin';
import {
defineConfig
} from 'vite';
import tailwindcss from "@tailwindcss/vite";
import { resolve } from 'node:path';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'],
ssr: 'resources/js/ssr.jsx',
ssr: 'resources/js/ssr.tsx',
refresh: true,
}),
react(),
Expand All @@ -18,4 +17,9 @@ export default defineConfig({
esbuild: {
jsx: 'automatic',
},
});
resolve: {
alias: {
'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'),
},
},
});
Loading