diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index b952ddc..1f76c05 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -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) diff --git a/app/Http/Controllers/CalendarSettingsController.php b/app/Http/Controllers/CalendarSettingsController.php index 4d55219..326557e 100644 --- a/app/Http/Controllers/CalendarSettingsController.php +++ b/app/Http/Controllers/CalendarSettingsController.php @@ -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); } diff --git a/app/Http/Middleware/SetUserLocale.php b/app/Http/Middleware/SetUserLocale.php index 6c1e8b5..58f4a55 100644 --- a/app/Http/Middleware/SetUserLocale.php +++ b/app/Http/Middleware/SetUserLocale.php @@ -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) { - $locale = $user->getSetting('app.language'); + // 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); diff --git a/app/Models/User.php b/app/Models/User.php index a9ed47b..5f66828 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -33,6 +33,7 @@ class User extends Authenticatable 'email', 'timezone', 'phone', + 'locale', ]; /** diff --git a/config/kithkin.php b/config/kithkin.php index 94980fa..9f75cea 100644 --- a/config/kithkin.php +++ b/config/kithkin.php @@ -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)', + ], ]; diff --git a/database/migrations/2026_01_29_000000_create_user_locale_field.php b/database/migrations/2026_01_29_000000_create_user_locale_field.php new file mode 100644 index 0000000..4bdb95e --- /dev/null +++ b/database/migrations/2026_01_29_000000_create_user_locale_field.php @@ -0,0 +1,25 @@ +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'); + }); + } +}; diff --git a/lang/en/account.php b/lang/en/account.php index d2aa7a7..9db150f 100644 --- a/lang/en/account.php +++ b/lang/en/account.php @@ -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!', diff --git a/lang/en/common.php b/lang/en/common.php index 0b0d632..7995c53 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -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', ]; diff --git a/resources/css/app.css b/resources/css/app.css index 4856a38..a28e74e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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'; diff --git a/resources/css/etc/theme.css b/resources/css/etc/theme.css index 8be6b5d..8923699 100644 --- a/resources/css/etc/theme.css +++ b/resources/css/etc/theme.css @@ -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; diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css index e72e32c..f39d66c 100644 --- a/resources/css/lib/button.css +++ b/resources/css/lib/button.css @@ -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; diff --git a/resources/css/lib/icon.css b/resources/css/lib/icon.css new file mode 100644 index 0000000..9a0c415 --- /dev/null +++ b/resources/css/lib/icon.css @@ -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; } diff --git a/resources/svg/icons/earth.svg b/resources/svg/icons/earth.svg new file mode 100644 index 0000000..1d05cc0 --- /dev/null +++ b/resources/svg/icons/earth.svg @@ -0,0 +1 @@ + diff --git a/resources/svg/icons/solid/earth.svg b/resources/svg/icons/solid/earth.svg new file mode 100644 index 0000000..ca8ff58 --- /dev/null +++ b/resources/svg/icons/solid/earth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/calendar/settings/language.blade.php b/resources/views/account/settings/locale.blade.php similarity index 74% rename from resources/views/calendar/settings/language.blade.php rename to resources/views/account/settings/locale.blade.php index 88796d9..faf1701 100644 --- a/resources/views/calendar/settings/language.blade.php +++ b/resources/views/account/settings/locale.blade.php @@ -5,12 +5,13 @@

- {{ __('calendar.settings.language_region.subtitle') }} + {{ __('account.settings.locale.subtitle') }}

-
+ @csrf + @method('patch')
@@ -66,10 +67,26 @@
+
+
+ + +
+
+
{{ __('common.save_changes') }} {{ __('common.cancel') }} + href="{{ route('account.locale') }}">{{ __('common.cancel') }}
diff --git a/resources/views/calendar/create.blade.php b/resources/views/calendar/create.blade.php deleted file mode 100644 index c70e3c4..0000000 --- a/resources/views/calendar/create.blade.php +++ /dev/null @@ -1,21 +0,0 @@ - - -

- {{ __('Create Calendar') }} -

-
- -
-
-
-
- @csrf - - {{-- just render the form component --}} - - - -
-
-
-
diff --git a/resources/views/calendar/partials/create-modal.blade.php b/resources/views/calendar/partials/create-modal.blade.php index cf1f129..feec0f4 100644 --- a/resources/views/calendar/partials/create-modal.blade.php +++ b/resources/views/calendar/partials/create-modal.blade.php @@ -27,7 +27,11 @@
- +
diff --git a/resources/views/components/app/pagelink.blade.php b/resources/views/components/app/pagelink.blade.php index cc6406e..599bc01 100644 --- a/resources/views/components/app/pagelink.blade.php +++ b/resources/views/components/app/pagelink.blade.php @@ -23,7 +23,7 @@ merge(['class' => $classes]) }}> @if ($iconComponent) - + @endif @if (!is_null($label)) diff --git a/resources/views/components/input/select-label.blade.php b/resources/views/components/input/select-label.blade.php new file mode 100644 index 0000000..37e36f4 --- /dev/null +++ b/resources/views/components/input/select-label.blade.php @@ -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 +]) + + diff --git a/resources/views/components/menu/account-settings.blade.php b/resources/views/components/menu/account-settings.blade.php index 2151354..7314122 100644 --- a/resources/views/components/menu/account-settings.blade.php +++ b/resources/views/components/menu/account-settings.blade.php @@ -12,10 +12,10 @@
  • @@ -26,6 +26,19 @@ icon="pin" />
  • + + +
    + {{ __('Account settings') }} + +
  • + +
  • {{ __('General settings') }} -
  • - -
  • diff --git a/routes/web.php b/routes/web.php index b45b371..ec7cb8a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');