Moves locale preferences from calendar settings to account settings; beefs up config with the date and time formatting and region options; refactors button groups and focus handling; refactors shadow complexity in button groups

This commit is contained in:
Andrew Gioia 2026-01-29 15:56:15 -05:00
parent 0b82c88333
commit 5fd9628dc9
Signed by: andrew
GPG Key ID: FC09694A000800C8
22 changed files with 298 additions and 154 deletions

View File

@ -13,12 +13,14 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class AccountController extends Controller
{
/**
*
* landing page
*/
public function index(): RedirectResponse
@ -27,6 +29,7 @@ class AccountController extends Controller
}
/**
*
* info pane (name, email, timezone, etc.)
*/
public function infoForm(Request $request)
@ -40,9 +43,6 @@ class AccountController extends Controller
]);
}
/**
* save info pane
*/
public function infoStore(AccountUpdateRequest $request): RedirectResponse
{
$user = $request->user();
@ -59,6 +59,88 @@ class AccountController extends Controller
}
/**
*
* locale pane
*/
public function localeForm(Request $request)
{
$user = $request->user();
return $this->frame('account.settings.locale', [
'title' => __('account.settings.locale.title'),
'sub' => __('account.settings.locale.subtitle'),
'values' => [
// language comes from column
'language' => $user->locale ?? config('app.locale'),
// timezone comes from column (fallback to app timezone then UTC)
'timezone' => $user->timezone ?? config('app.timezone', 'UTC'),
// the other three live in user.settings json
'region' => $user->getSetting('app.region', 'US'),
'date_format' => $user->getSetting('app.date_format', 'mdy'),
'time_format' => $user->getSetting('app.time_format', '12'),
],
'options' => [
'languages' => config('kithkin.locales', []), // optgroups
'regions' => config('kithkin.regions', []), // optgroups
'date_formats' => config('kithkin.date_formats', []), // flat
'time_formats' => config('kithkin.time_formats', []), // flat
'timezones' => config('timezones', []), // optgroups
],
]);
}
public function localeStore(Request $request): RedirectResponse
{
$allowedLocales = collect(config('kithkin.locales', []))
->flatMap(fn ($group) => is_array($group) ? array_keys($group) : [])
->unique()
->values()
->all();
$allowedRegions = collect(config('kithkin.regions', []))
->flatMap(fn ($group) => is_array($group) ? array_keys($group) : [])
->unique()
->values()
->all();
$allowedTimezones = collect(config('timezones', []))
->flatMap(fn ($group) => is_array($group) ? array_keys($group) : [])
->unique()
->values()
->all();
$data = $request->validate([
'language' => ['required', Rule::in($allowedLocales)],
'region' => ['required', Rule::in($allowedRegions)],
'date_format' => ['required', 'in:mdy,dmy,ymd'],
'time_format' => ['required', 'in:12,24'],
'timezone' => ['required', 'string', Rule::in($allowedTimezones)],
]);
$user = $request->user();
// set language and timezone to their dedicated columns
$user->locale = $data['language'];
$user->timezone = $data['timezone'];
// everything else to json settings
$user->setSettings([
'app.region' => $data['region'],
'app.date_format' => $data['date_format'],
'app.time_format' => $data['time_format'],
]);
$user->save();
// apply locale immediately
app()->setLocale($user->locale);
return Redirect::route('account.locale')
->with('toast', __('Settings saved!'));
}
/**
*
* addresses pane (home + work)
*/
public function addressesForm(Request $request)

View File

@ -14,93 +14,10 @@ use Illuminate\Support\Facades\Redirect;
class CalendarSettingsController extends Controller
{
/* landing page shows the first settings pane (language/region) */
/* landing page shows the first settings pane */
public function index()
{
return redirect()->route('calendar.settings.language');
}
/**
* Language and region
**/
/* language and region form */
public function languageForm(Request $request)
{
$user = $request->user();
$settings = (array) ($user->settings ?? []);
return $this->frame('calendar.settings.language', [
'title' => __('calendar.settings.language_region.title'),
'values' => [
'language' => $user->getSetting('app.language', app()->getLocale()),
'region' => $user->getSetting('app.region', 'US'),
'date_format' => $user->getSetting('app.date_format', 'mdy'),
'time_format' => $user->getSetting('app.time_format', '12'),
],
'options' => [
'languages' => [
'en' => 'English',
'es' => 'Spanish',
'fr' => 'French',
'de' => 'German',
'it' => 'Italian',
'pt' => 'Portuguese',
'nl' => 'Dutch',
],
'regions' => [
'US' => 'United States',
'CA' => 'Canada',
'GB' => 'United Kingdom',
'AU' => 'Australia',
'NZ' => 'New Zealand',
'IE' => 'Ireland',
'DE' => 'Germany',
'FR' => 'France',
'ES' => 'Spain',
'IT' => 'Italy',
'NL' => 'Netherlands',
],
'date_formats' => [
'mdy' => 'MM/DD/YYYY (01/15/2026)',
'dmy' => 'DD/MM/YYYY (15/01/2026)',
'ymd' => 'YYYY-MM-DD (2026-01-15)',
],
'time_formats' => [
'12' => '12-hour (1:30 PM)',
'24' => '24-hour (13:30)',
],
],
]);
}
/* handle POST from language/region pane */
public function languageStore(Request $request)
{
$data = $request->validate([
'language' => ['required', 'string', 'max:10', 'regex:/^[a-z]{2}([-_][A-Z]{2})?$/'],
'region' => ['required', 'string', 'size:2', 'regex:/^[A-Z]{2}$/'],
'date_format' => ['required', 'in:mdy,dmy,ymd'],
'time_format' => ['required', 'in:12,24'],
]);
$user = $request->user();
$user->setSettings([
'app.language' => $data['language'],
'app.region' => $data['region'],
'app.date_format' => $data['date_format'],
'app.time_format' => $data['time_format'],
]);
// apply immediately for the current request cycle going forward
app()->setLocale($data['language']);
return redirect()
->route('calendar.settings.language')
->with('toast', __('Settings saved!'));
return redirect()->route('calendar.settings.create');
}
/**
@ -124,6 +41,7 @@ class CalendarSettingsController extends Controller
];
if ($request->header('HX-Request')) {
$data['redirect'] = route('calendar.index');
return view('calendar.partials.create-modal', $data);
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
class SetUserLocale
{
@ -12,7 +13,13 @@ class SetUserLocale
$user = $request->user();
if ($user) {
// prefer dedicated column
$locale = $user->locale;
// fallback for legacy
if (!is_string($locale) || $locale === '') {
$locale = $user->getSetting('app.language');
}
if (is_string($locale) && $locale !== '') {
app()->setLocale($locale);

View File

@ -33,6 +33,7 @@ class User extends Authenticatable
'email',
'timezone',
'phone',
'locale',
];
/**

View File

@ -16,4 +16,53 @@ return [
// options: 'first' | 'random'
'default_color_strategy' => 'random',
],
'locales' => [
'English' => [
'en' => 'English',
'en_US' => 'English (United States)',
'en_GB' => 'English (United Kingdom)',
],
'Deutsch' => [
'de' => 'Deutsch',
'de_DE' => 'Deutsch (Deutschland)',
'de_CH' => 'Deutsch (Schweiz)',
],
'Español' => [
'es' => 'Español',
'es_MX' => 'Español (México)',
],
'Italiano' => [
'it' => 'Italiano',
],
],
'regions' => [
'North America' => [
'US' => 'United States',
'CA' => 'Canada',
'MX' => 'Mexico',
],
'Europe' => [
'GB' => 'United Kingdom',
'DE' => 'Germany',
'FR' => 'France',
'IE' => 'Ireland',
'IT' => 'Italy',
'NL' => 'Netherlands',
'ES' => 'Spain',
'CH' => 'Switzerland',
],
'Oceania' => [
'AU' => 'Australia',
'NZ' => 'New Zealand',
],
],
'date_formats' => [
'mdy' => 'MM/DD/YYYY (12/31/2026)',
'dmy' => 'DD/MM/YYYY (31/12/2026)',
'ymd' => 'YYYY-MM-DD (2026-12-31)',
],
'time_formats' => [
'12' => '12-hour (1:30 PM)',
'24' => '24-hour (13:30)',
],
];

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// store a laravel locale string, e.g. "en", "es", "pt_BR"
$table->string('locale', 12)->nullable()->after('timezone');
$table->index('locale');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex(['locale']);
$table->dropColumn('locale');
});
}
};

View File

@ -50,9 +50,13 @@ return [
'subtitle' => 'Please enter your password and confirm that you would like to permanently delete your account.',
],
'information' => [
'title' => 'Account information',
'title' => 'Personal information',
'subtitle' => 'Your name, email address, and other primary account details.',
],
'locale' => [
'title' => 'Locale preferences',
'subtitle' => 'Location, timezone, and other regional preferences for calendars and events.'
],
'password' => [
'title' => 'Password',
'subtitle' => 'Ensure your account is using a long, random password to stay secure. We always recommend a password manager as well!',

View File

@ -36,6 +36,7 @@ return [
'time_format' => 'Time format',
'time_format_select' => 'Select a time format',
'timezone' => 'Time zone',
'timezone_default' => 'Default time zone',
'timezone_select' => 'Select a time zone',
];

View File

@ -10,6 +10,7 @@
@import './lib/calendar.css';
@import './lib/checkbox.css';
@import './lib/color.css';
@import './lib/icon.css';
@import './lib/indicator.css';
@import './lib/input.css';
@import './lib/mini.css';

View File

@ -87,6 +87,9 @@
--shadow-drop: 2.5px 2.5px 0 0 var(--color-primary);
--shadow-input: inset 0 0.25rem 0 0 var(--color-gray-100);
--shadow-input-hover: inset 0 0.25rem 0 0 var(--color-teal-200);
--shadow-checked: inset 2.5px 0 0 var(--color-primary), inset 0 0.25rem 0 0 var(--color-cyan-500);
--shadow-active: inset 0 0.25rem 0 0 var(--color-cyan-500);
--shadow-sibling: inset 1.5px 0 0 0 var(--color-primary);
--spacing-md: 1.5px;
--spacing-2px: 2px;

View File

@ -1,9 +1,9 @@
button,
.button {
@apply relative inline-flex items-center cursor-pointer gap-2 rounded-md h-11 px-4 text-lg font-medium;
/*transition: background-color 125ms ease-in-out; */
@apply focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600 focus:outline-none;
transition: border-color 125ms ease-in-out;
@apply outline-0 outline-transparent outline-offset-0;
@apply focus:outline-2 focus:outline-offset-2 focus:outline-cyan-600;
transition: border-color 125ms ease-in-out, outline 125ms ease-in-out;
--button-border: var(--color-primary);
--button-accent: var(--color-primary-hover);
@ -18,15 +18,7 @@ button,
border-color: var(--button-accent);
}
&:focus {
box-shadow:
2.5px 2.5px 0 0 var(--button-border),
0 0 0 2px var(--color-white),
0 0 0 4px var(--color-cyan-600);
}
&:active {
@apply shadow-none outline-none;
left: 2.5px;
top: 2.5px;
}
@ -65,7 +57,7 @@ button,
aspect-ratio: 1 / 1;
&:hover {
background-color: rgba(0,0,0,0.075);
background-color: rgba(0, 0, 0, 0.075);
}
}
@ -83,7 +75,9 @@ button,
> button {
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer;
@apply border-md border-primary border-l-0 font-medium rounded-none;
/*transition: background-color 100ms ease-in-out; */
transition: outline 125ms ease-in-out;
box-shadow: var(--shadows);
--shadows: none;
&:hover {
@apply bg-cyan-200;
@ -92,27 +86,36 @@ button,
&:has(input:checked),
&:active {
@apply bg-cyan-400 border-r-transparent;
box-shadow:
inset 2.5px 0 0 0 var(--color-primary),
inset 0 0.25rem 0 0 var(--color-cyan-500);
top: 2.5px;
+ label,
+ button {
box-shadow: inset 1.5px 0 0 0 var(--color-primary);
}
--shadows: var(--shadow-checked);
&:hover {
@apply bg-cyan-500;
}
+ label,
+ button {
--shadows: var(--shadow-sibling);
}
}
&:has(input:checked) {}
&:first-child {
@apply rounded-l-md border-l-md;
&:has(input:checked),
&:active {
box-shadow: inset 0 0.25rem 0 0 var(--color-cyan-500);
--shadows: var(--shadow-active);
}
&:focus,
&:active {
box-shadow:
0 0 0 2px var(--color-white),
0 0 0 4px var(--color-cyan-600),
var(--focus-ring);
--focus-ring: ;
}
}
@ -126,6 +129,10 @@ button,
}
}
button:active + button {
}
> label {
> input[type="radio"] {
@apply hidden absolute top-0 left-0 w-0 h-0 max-w-0 max-h-0;

View File

@ -0,0 +1,10 @@
/**
* icons
*/
/* sizes */
.icon-12 { @apply w-3 h-3; }
.icon-16 { @apply w-4 h-4; }
.icon-20 { @apply w-5 h-5; }
.icon-24 { @apply w-6 h-6; }
.icon-32 { @apply w-8 h-8; }

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/><path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"/><path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/><circle cx="12" cy="12" r="10"/></svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M22.116,7.676c0.569,1.328 0.884,2.789 0.884,4.324c0,6.071 -4.929,11 -11,11c-6.071,0 -11,-4.929 -11,-11c0,-6.071 4.929,-11 11,-11c4.479,0 8.336,2.682 10.051,6.527c0.024,0.049 0.046,0.098 0.065,0.149Zm-16.106,-2.391c-1.371,1.224 -2.365,2.861 -2.787,4.715l1.777,-0c1.646,0 3,1.354 3,3l0,1c0,0.549 0.451,1 1,1c1.646,0 3,1.354 3,3l0,3c0.687,0 1.357,-0.077 2,-0.223l-0,-3.777c0,-1.646 1.354,-3 3,-3l3.777,0c0.146,-0.643 0.223,-1.313 0.223,-2c0,-1.052 -0.181,-2.061 -0.513,-3l-2.487,0c-0.55,0 -1,0.45 -1,1c0,1.646 -1.354,3 -3,3c-1.65,0 -3,-1.35 -3,-3c0,-0.549 -0.451,-1 -1,-1c-2.099,0 -3.842,-1.652 -3.99,-3.715Z"/></svg>

After

Width:  |  Height:  |  Size: 740 B

View File

@ -5,12 +5,13 @@
<div class="description">
<p>
{{ __('calendar.settings.language_region.subtitle') }}
{{ __('account.settings.locale.subtitle') }}
</p>
</div>
<form method="post" action="{{ route('calendar.settings.language.store') }}" class="settings">
<form method="post" action="{{ route('account.locale.store') }}" class="settings">
@csrf
@method('patch')
<div class="input-row input-row--1-1">
<div class="input-cell">
@ -66,10 +67,26 @@
</div>
</div>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.select-label
:label="__('common.timezone_default')"
id="timezone"
name="timezone"
placeholder="{{ __('common.timezone_select') }}"
:value="$values['timezone']"
:options="$options['timezones']"
:selected="old('timezone', $values['timezone'])"
description="This can be overridden for each calendar."
/>
<x-input.error :messages="$errors->get('date_format')" />
</div>
</div>
<div class="input-row input-row--actions input-row--start sticky-bottom">
<x-button variant="primary" type="submit">{{ __('common.save_changes') }}</x-button>
<x-button type="anchor"
variant="tertiary"
href="{{ route('calendar.settings.language') }}">{{ __('common.cancel') }}</x-button>
href="{{ route('account.locale') }}">{{ __('common.cancel') }}</x-button>
</div>
</form>

View File

@ -1,21 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight">
{{ __('Create Calendar') }}
</h2>
</x-slot>
<div class="py-6">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('calendar.store') }}">
@csrf
{{-- just render the form component --}}
<x-calendar-form />
</form>
</div>
</div>
</div>
</x-app-layout>

View File

@ -27,7 +27,11 @@
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="timezone" :value="__('Timezone')" />
<x-input.select id="timezone" name="timezone" :options="config('timezones')" :selected="old('timezone', $defaults['timezone'] ?? 'UTC')" />
<x-input.select
id="timezone"
name="timezone"
:options="config('timezones')"
:selected="old('timezone', $defaults['timezone'] ?? 'UTC')" />
<x-input.error :messages="$errors->get('timezone')" />
</div>

View File

@ -23,7 +23,7 @@
<a {{ $attributes->merge(['class' => $classes]) }}>
@if ($iconComponent)
<x-dynamic-component :component="$iconComponent" width="20" :color="$color" />
<x-dynamic-component :component="$iconComponent" class="icon-20" :color="$color" />
@endif
@if (!is_null($label))

View File

@ -0,0 +1,30 @@
@props([
'label' => '', // label text
'labelclass' => '', // extra CSS classes for the label
'inputclass' => '', // input classes
'id' => '',
'name' => '', // input name
'disabled' => false, // disabled flag
'options' => [],
'selected' => '', // input value
'placeholder' => '', // placeholder text
'style' => '', // raw style string for the input
'required' => false, // true/false or truthy value
'autocomplete' => false,
'description' => '', // optional descriptive text below the input
])
<label {{ $attributes->class("text-label $labelclass") }}>
<span class="label">{{ $label }}</span>
<x-input.select
:id="$id"
:name="$name"
:class="$inputclass"
:options="$options"
:selected="$selected"
:placeholder="$placeholder"
:required="$required"
:autocomplete="$autocomplete"
{{ $attributes }} />
@if($description !== '')<span class="description">{{ $description}}</span>@endif
</label>

View File

@ -12,10 +12,10 @@
</li>
<li>
<x-app.pagelink
href="{{ route('account.password') }}"
:active="request()->routeIs('account.password')"
:label="__('common.password')"
icon="key"
href="{{ route('account.locale') }}"
:active="request()->routeIs('account.locale', 'account.delete.*')"
:label="__('account.settings.locale.title')"
icon="earth"
/>
</li>
<li>
@ -26,6 +26,19 @@
icon="pin"
/>
</li>
</menu>
</details>
<details open>
<summary>{{ __('Account settings') }}</summary>
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('account.password') }}"
:active="request()->routeIs('account.password')"
:label="__('common.password')"
icon="key"
/>
</li>
<li>
<x-app.pagelink
href="{{ route('account.delete') }}"

View File

@ -2,14 +2,6 @@
<details open>
<summary>{{ __('General settings') }}</summary>
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('calendar.settings.language') }}"
:active="request()->routeIs('calendar.settings.language')"
:label="__('calendar.settings.language_region.title')"
icon="globe"
/>
</li>
</menu>
</details>
<details open>

View File

@ -54,6 +54,9 @@ Route::middleware('auth')->group(function ()
Route::get('info', [AccountController::class, 'infoForm'])->name('info');
Route::patch('info', [AccountController::class, 'infoStore'])->name('info.store');
Route::get('locale', [AccountController::class, 'localeForm'])->name('locale');
Route::patch('locale', [AccountController::class, 'localeStore'])->name('locale.store');
Route::get('addresses', [AccountController::class, 'addressesForm'])->name('addresses');
Route::patch('addresses', [AccountController::class, 'addressesStore'])->name('addresses.store');
@ -81,10 +84,6 @@ Route::middleware('auth')->group(function ()
// settings landing
Route::get('settings', [CalendarSettingsController::class, 'index'])->name('settings');
// language/region settings
Route::get('settings/language', [CalendarSettingsController::class, 'languageForm'])->name('settings.language');
Route::post('settings/language', [CalendarSettingsController::class, 'languageStore'])->name('settings.language.store');
// settings / subscribe to a calendar
Route::get('settings/subscribe', [CalendarSettingsController::class, 'subscribeForm'])->name('settings.subscribe');
Route::post('settings/subscribe', [CalendarSettingsController::class, 'subscribeStore'])->name('settings.subscribe.store');