diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php new file mode 100644 index 0000000..b952ddc --- /dev/null +++ b/app/Http/Controllers/AccountController.php @@ -0,0 +1,317 @@ +user(); + + return $this->frame('account.settings.info', [ + 'title' => __('Account info'), + 'sub' => __('Update your personal information and sign-in email.'), + 'user' => $user, + ]); + } + + /** + * save info pane + */ + public function infoStore(AccountUpdateRequest $request): RedirectResponse + { + $user = $request->user(); + $user->fill($request->validated()); + + if ($user->isDirty('email')) { + $user->email_verified_at = null; + } + + $user->save(); + + return Redirect::route('account.info') + ->with('toast', __('Account updated!')); + } + + /** + * addresses pane (home + work) + */ + public function addressesForm(Request $request) + { + $user = $request->user(); + + // try primary of each kind; fall back to the first of that kind + $home = $user->addresses()->where('kind', 'home')->where('is_primary', 1)->first() + ?? $user->addresses()->where('kind', 'home')->first(); + + $work = $user->addresses()->where('kind', 'work')->where('is_primary', 1)->first() + ?? $user->addresses()->where('kind', 'work')->first(); + + // if neither is explicitly billing, fall back to "work if present, else home" + $billing = + ($home && $home->is_billing) ? 'home' : + (($work && $work->is_billing) ? 'work' : + ($work ? 'work' : 'home')); + + return $this->frame('account.settings.addresses', [ + 'title' => __('account.settings.addresses.title'), + 'sub' => __('account.settings.addresses.subtitle'), + 'user' => $user, + 'home' => $home, + 'work' => $work, + 'billing' => $billing, + ]); + } + + /** + * save addresses pane + */ + public function addressesStore(Request $request, Geocoder $geocoder): RedirectResponse + { + $user = $request->user(); + + $data = $request->validate([ + 'billing' => ['required', 'in:home,work'], + + 'home' => ['sometimes', 'array'], + 'home.label' => ['nullable', 'string', 'max:100'], + 'home.line1' => ['nullable', 'string', 'max:255'], + 'home.line2' => ['nullable', 'string', 'max:255'], + 'home.city' => ['nullable', 'string', 'max:120'], + 'home.state' => ['nullable', 'string', 'max:64'], + 'home.postal' => ['nullable', 'string', 'max:32'], + 'home.country' => ['nullable', 'string', 'max:64'], + + 'work' => ['sometimes', 'array'], + 'work.label' => ['nullable', 'string', 'max:100'], + 'work.line1' => ['nullable', 'string', 'max:255'], + 'work.line2' => ['nullable', 'string', 'max:255'], + 'work.city' => ['nullable', 'string', 'max:120'], + 'work.state' => ['nullable', 'string', 'max:64'], + 'work.postal' => ['nullable', 'string', 'max:32'], + 'work.country' => ['nullable', 'string', 'max:64'], + ]); + + DB::transaction(function () use ($user, $data, $geocoder) { + + $saved = []; // kind => UserAddress + + $save = function (string $kind, array $payload) use ($user, $geocoder, &$saved) { + // short-circuit if nothing filled + $filled = collect($payload) + ->only(['line1', 'line2', 'city', 'state', 'postal', 'country']) + ->filter(fn ($v) => filled($v)); + + if ($filled->isEmpty()) { + return; + } + + // upsert a single primary address of this kind + $addr = UserAddress::updateOrCreate( + ['user_id' => $user->id, 'kind' => $kind, 'is_primary' => 1], + [ + 'label' => $payload['label'] ?? ucfirst($kind), + 'line1' => $payload['line1'] ?? null, + 'line2' => $payload['line2'] ?? null, + 'city' => $payload['city'] ?? null, + 'state' => $payload['state'] ?? null, + 'postal' => $payload['postal'] ?? null, + 'country' => $payload['country'] ?? null, + ] + ); + + $saved[$kind] = $addr; + + // geocode + $singleLine = collect([ + $addr->line1, + $addr->line2, + $addr->city, + $addr->state, + $addr->postal, + $addr->country, + ])->filter()->implode(', '); + + if ($singleLine === '') { + return; + } + + $norm = $geocoder->forward($singleLine); + + if (!$norm) { + return; + } + + $loc = Location::findOrCreateNormalized($norm, $singleLine); + $addr->location_id = $loc->id; + $addr->save(); + }; + + if (isset($data['home'])) { + $save('home', $data['home']); + } + + if (isset($data['work'])) { + $save('work', $data['work']); + } + + // billing flag: clear all billing flags for this user + UserAddress::where('user_id', $user->id)->update(['is_billing' => 0]); + + // set billing on the chosen kind if we have that address + $kind = $data['billing']; + + // if the chosen kind wasn't saved in this submission (e.g., user didn't fill it), + // try to find an existing primary address of that kind. + $billingAddr = $saved[$kind] ?? UserAddress::where('user_id', $user->id) + ->where('kind', $kind) + ->where('is_primary', 1) + ->first(); + + if ($billingAddr) { + $billingAddr->is_billing = 1; + $billingAddr->save(); + } + }); + + return Redirect::route('account.addresses') + ->with('toast', __('Addresses updated!')); + } + + /** + * password pane + */ + public function passwordForm(Request $request) + { + return $this->frame('account.settings.password', [ + 'title' => __('Password'), + 'sub' => __('Change your password.'), + 'user' => $request->user(), + ]); + } + + /** + * save password pane + */ + public function passwordStore(Request $request): RedirectResponse + { + $data = $request->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', 'string', 'min:12', 'confirmed'], + ]); + + $user = $request->user(); + + $user->forceFill([ + 'password' => Hash::make($data['password']), + ])->save(); + + return Redirect::route('account.password') + ->with('toast', __('Password updated!')); + } + + /** + * delete pane + */ + public function deleteForm(Request $request) + { + return $this->frame('account.settings.delete', [ + 'title' => __('account.settings.delete.title'), + 'sub' => __('account.settings.delete.subtitle'), + 'user' => $request->user(), + ]); + } + + /** + * delete confirmation modal/page + */ + public function deleteConfirm(Request $request) + { + if ($request->header('HX-Request')) { + return view('account.partials.delete-modal'); + } + + // no-js full page + return $this->frame('account.settings.delete-confirm', [ + 'title' => __('account.settings.delete-confirm.title'), + 'sub' => __('account.settings.delete-confirm.subtitle'), + 'user' => $request->user(), + ]); + } + + /** + * delete the user's account + */ + public function destroy(Request $request) + { + // validate: on failure + // - full page: redirects to confirm page, flashes errors + input + toast + // - htmx: returns 422 modal html + HX-Trigger toast + $this->validateHtmx($request, [ + 'password' => ['required', 'current_password'], + ], [ + 'bag' => 'userDeletion', + 'redirect' => 'account.delete.confirm', // no-js failure lands back on confirm + 'htmx_view' => 'account.modals.delete', // modal content to re-render on 422 + 'toast_type' => 'error', // optional, default is already error + // optional: override toast message (default is first validation message) + // 'toast_message' => __('Please correct the password and try again.'), + // optional: disable toast on validation failures: + // 'toast' => false, + ]); + + // passed validation, delete account + $user = $request->user(); + + Auth::logout(); + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + // success: for no-js, redirect home with toast + // for htmx, send HX-Redirect + HX-Trigger (handled inside helper) + return $this->redirectWithToast( + $request, + 'dashboard', // or whatever route you want, could be a url('/') helper variant too + __('Your account has been deleted.'), + 'success' + ); + } + + /** + * content frame handler + */ + private function frame(?string $view = null, array $data = []) + { + return view('account.index', [ + 'view' => $view, + 'data' => $data, + ]); + } +} diff --git a/app/Http/Controllers/CalendarSettingsController.php b/app/Http/Controllers/CalendarSettingsController.php index e7f5b36..4fd75d4 100644 --- a/app/Http/Controllers/CalendarSettingsController.php +++ b/app/Http/Controllers/CalendarSettingsController.php @@ -8,12 +8,100 @@ use Illuminate\Support\Str; class CalendarSettingsController extends Controller { - /* landing page – list of settings choices */ + /* landing page shows the first settings pane (language/region) */ public function index() { - return $this->frame('calendar.settings.subscribe'); + 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!')); + } + + + /** + * Subscribe + **/ + /* show “Subscribe to a calendar” form */ public function subscribeForm() { @@ -50,6 +138,7 @@ class CalendarSettingsController extends Controller ->with('toast', __('Subscription added successfully!')); } + /** * content frame handler */ diff --git a/app/Http/Controllers/Concerns/FlashesToasts.php b/app/Http/Controllers/Concerns/FlashesToasts.php new file mode 100644 index 0000000..0916dd6 --- /dev/null +++ b/app/Http/Controllers/Concerns/FlashesToasts.php @@ -0,0 +1,40 @@ +header('HX-Request')) { + return response('', 204)->header('HX-Trigger', json_encode([ + 'toast' => ['message' => $message, 'type' => $type], + ])); + } + + // for normal requests, just flash to session + return Redirect::back()->with('toast', [ + 'message' => $message, + 'type' => $type ]); + } + + protected function redirectWithToast(Request $request, string $routeName, string $message, string $type = 'success') + { + if ($request->header('HX-Request')) { + // optionally: you can HX-Redirect and also trigger toast + return response('', 204) + ->header('HX-Redirect', route($routeName)) + ->header('HX-Trigger', json_encode([ + 'toast' => ['message' => $message, 'type' => $type], + ])); + } + + return Redirect::route($routeName)->with('toast', [ + 'message' => $message, + 'type' => $type ]); + } +} diff --git a/app/Http/Controllers/Concerns/ValidatesHtmx.php b/app/Http/Controllers/Concerns/ValidatesHtmx.php new file mode 100644 index 0000000..63ba40f --- /dev/null +++ b/app/Http/Controllers/Concerns/ValidatesHtmx.php @@ -0,0 +1,80 @@ +all(), + $rules, + $options['messages'] ?? [], + $options['attributes'] ?? [], + ); + + if ($validator->passes()) { + return $validator->validated(); + } + + // single-message toast by default + $toast = $options['toast_message'] + ?? $validator->errors()->first() + ?? __('Please correct any errors and try again.'); + $type = $options['toast_type'] ?? 'error'; + + // allow disabling toast + $toastEnabled = $options['toast'] ?? true; + + if ($request->header('HX-Request')) { + $request->session()->flashInput($request->input()); + + $errors = new ViewErrorBag(); + $errors->put($bag, $validator->errors()); + + $view = $options['htmx_view'] ?? null; + if (!$view) { + throw new HttpResponseException(response('', 422)); + } + + $data = array_merge($options['htmx_data'] ?? [], [ + 'errors' => $errors, + ]); + + $response = response()->view($view, $data, 422); + + if ($toastEnabled) { + $response->header('HX-Trigger', json_encode([ + 'toast' => [ + 'message' => $toast, + 'type' => $type, + ], + ])); + } + + throw new HttpResponseException($response); + } + + $redirect = $options['redirect'] ?? null; + $response = $redirect ? Redirect::route($redirect) : Redirect::back(); + + if ($toastEnabled) { + $response->with('toast', [ + 'message' => $toast, + 'type' => $type, + ]); + } + + throw new HttpResponseException( + $response->withErrors($validator, $bag)->withInput() + ); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 7c6c98b..a61f248 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Http\Controllers\Concerns\FlashesToasts; +use App\Http\Controllers\Concerns\ValidatesHtmx; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Routing\Controller as BaseController; @@ -9,5 +11,11 @@ use Illuminate\Routing\Controller as BaseController; abstract class Controller { // add authorization requests to the base controller - use AuthorizesRequests, ValidatesRequests; + use AuthorizesRequests; + use ValidatesRequests; + + // add error handling with HTMX and toast handling + // controllers now get $this->validateHtmx(), $this->toast(), $this->redirectWithToast() + use ValidatesHtmx; + use FlashesToasts; } diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php deleted file mode 100644 index 4358bb6..0000000 --- a/app/Http/Controllers/ProfileController.php +++ /dev/null @@ -1,172 +0,0 @@ -frame('profile.partials.addresses-form'); - } - - /** - * display user's profile forms - */ - public function edit(Request $request): View - { - $user = $request->user(); - - // try primary of each kind; fall back to the first of that kind - $home = $user->addresses()->where('kind', 'home')->where('is_primary', 1)->first() - ?? $user->addresses()->where('kind', 'home')->first(); - - $billing = $user->addresses()->where('kind', 'billing')->where('is_primary', 1)->first() - ?? $user->addresses()->where('kind', 'billing')->first(); - - return view('profile.edit', compact('user', 'home', 'billing')); - } - - /** - * Update the user's profile information. - */ - public function update(ProfileUpdateRequest $request): RedirectResponse - { - $request->user()->fill($request->validated()); - - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; - } - - $request->user()->save(); - - return Redirect::route('profile.edit')->with('status', 'profile-updated'); - } - - /** - * save or update the user's addresses (/profile/addresses) - */ - public function saveAddresses(Request $request, Geocoder $geocoder): RedirectResponse - { - $user = $request->user(); - - $data = $request->validate([ - 'home' => 'array', - 'home.label' => 'nullable|string|max:100', - 'home.line1' => 'nullable|string|max:255', - 'home.line2' => 'nullable|string|max:255', - 'home.city' => 'nullable|string|max:120', - 'home.state' => 'nullable|string|max:64', - 'home.postal' => 'nullable|string|max:32', - 'home.country' => 'nullable|string|max:64', - 'home.phone' => 'nullable|string|max:32', - - 'billing' => 'array', - 'billing.label' => 'nullable|string|max:100', - 'billing.line1' => 'nullable|string|max:255', - 'billing.line2' => 'nullable|string|max:255', - 'billing.city' => 'nullable|string|max:120', - 'billing.state' => 'nullable|string|max:64', - 'billing.postal' => 'nullable|string|max:32', - 'billing.country' => 'nullable|string|max:64', - 'billing.phone' => 'nullable|string|max:32', - ]); - - DB::transaction(function () use ($user, $data, $geocoder) { - - $save = function (string $kind, array $payload) use ($user, $geocoder) { - // short-circuit if nothing filled - $filled = collect($payload)->only(['line1','line2','city','state','postal','country','phone']) - ->filter(fn ($v) => filled($v)); - if ($filled->isEmpty()) { - return; - } - - // upsert a single primary address of this kind - $addr = UserAddress::updateOrCreate( - ['user_id' => $user->id, 'kind' => $kind, 'is_primary' => 1], - [ - 'label' => $payload['label'] ?? ucfirst($kind), - 'line1' => $payload['line1'] ?? null, - 'line2' => $payload['line2'] ?? null, - 'city' => $payload['city'] ?? null, - 'state' => $payload['state'] ?? null, - 'postal' => $payload['postal'] ?? null, - 'country' => $payload['country'] ?? null, - 'phone' => $payload['phone'] ?? null, - ] - ); - - // build a singleLine string to geocode - $singleLine = collect([ - $addr->line1, $addr->line2, $addr->city, $addr->state, $addr->postal, $addr->country - ])->filter()->implode(', '); - - if ($singleLine !== '') { - // geocode and link to locations - $norm = $geocoder->forward($singleLine); - if ($norm) { - $loc = Location::findOrCreateNormalized($norm, $singleLine); - $addr->location_id = $loc->id; - $addr->save(); - } - } - }; - - if (isset($data['home'])) { - $save('home', $data['home']); - } - if (isset($data['billing'])) { - $save('billing', $data['billing']); - } - }); - - return Redirect::route('profile.edit')->with('status', 'addresses-updated'); - } - - /** - * Delete the user's account. - */ - public function destroy(Request $request): RedirectResponse - { - $request->validateWithBag('userDeletion', [ - 'password' => ['required', 'current_password'], - ]); - - $user = $request->user(); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return Redirect::to('/'); - } - - /** - * content frame handler - */ - private function frame(?string $view = null, array $data = []) - { - return view('profile.index', [ - 'view' => $view, - 'data' => $data, - ]); - } - -} diff --git a/app/Http/Middleware/SetUserLocale.php b/app/Http/Middleware/SetUserLocale.php new file mode 100644 index 0000000..6c1e8b5 --- /dev/null +++ b/app/Http/Middleware/SetUserLocale.php @@ -0,0 +1,24 @@ +user(); + + if ($user) { + $locale = $user->getSetting('app.language'); + + if (is_string($locale) && $locale !== '') { + app()->setLocale($locale); + } + } + + return $next($request); + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/AccountUpdateRequest.php similarity index 64% rename from app/Http/Requests/ProfileUpdateRequest.php rename to app/Http/Requests/AccountUpdateRequest.php index 3622a8f..3461c06 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/AccountUpdateRequest.php @@ -6,7 +6,7 @@ use App\Models\User; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class ProfileUpdateRequest extends FormRequest +class AccountUpdateRequest extends FormRequest { /** * Get the validation rules that apply to the request. @@ -16,7 +16,9 @@ class ProfileUpdateRequest extends FormRequest public function rules(): array { return [ - 'name' => ['required', 'string', 'max:255'], + 'firstname' => ['nullable', 'string', 'max:255'], + 'lastname' => ['nullable', 'string', 'max:255'], + 'displayname' => ['nullable', 'string', 'max:255'], 'email' => [ 'required', 'string', @@ -25,6 +27,8 @@ class ProfileUpdateRequest extends FormRequest 'max:255', Rule::unique(User::class)->ignore($this->user()->id), ], + 'phone' => ['nullable', 'string', 'max:32'], + 'timezone' => ['nullable', 'string', 'max:64'], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 464bded..a9ed47b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,13 +3,14 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\UserSetting; +use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Laravel\Sanctum\HasApiTokens; -use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Support\Str; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { @@ -26,9 +27,12 @@ class User extends Authenticatable * @var list */ protected $fillable = [ - 'name', + 'firstname', + 'lastname', + 'displayname', 'email', - 'password', + 'timezone', + 'phone', ]; /** @@ -55,7 +59,7 @@ class User extends Authenticatable } /** - * A user can own many calendars. + * user can own many calendars */ public function calendars(): HasMany { @@ -88,4 +92,69 @@ class User extends Authenticatable ->where('is_primary', 1) ->first(); } + + /** + * user can have many settings + */ + public function userSettings(): HasMany + { + return $this->hasMany(UserSetting::class, 'user_id'); + } + + /** + * get a user setting by key + */ + public function getSetting(string $key, mixed $default = null): mixed + { + // avoid repeated queries if the relationship is already eager loaded + if ($this->relationLoaded('userSettings')) { + $match = $this->userSettings->firstWhere('key', $key); + return $match?->value ?? $default; + } + + $value = $this->userSettings() + ->where('key', $key) + ->value('value'); + + return $value ?? $default; + } + + /** + * set a user setting by key + */ + public function setSetting(string $key, mixed $value): void + { + // store everything as a string in the value column + $stringValue = is_null($value) ? null : (string) $value; + + $this->userSettings()->updateOrCreate( + ['key' => $key], + ['value' => $stringValue], + ); + + // keep in-memory relation in sync if it was loaded + if ($this->relationLoaded('userSettings')) { + $existing = $this->userSettings->firstWhere('key', $key); + + if ($existing) { + $existing->value = $stringValue; + } else { + $this->userSettings->push(new UserSetting([ + 'user_id' => $this->getKey(), + 'key' => $key, + 'value' => $stringValue, + ])); + } + } + } + + /** + * convenience: set many settings at once + */ + public function setSettings(array $pairs): void + { + foreach ($pairs as $key => $value) { + $this->setSetting($key, $value); + } + } } diff --git a/app/Models/UserAddress.php b/app/Models/UserAddress.php index db33215..640be08 100644 --- a/app/Models/UserAddress.php +++ b/app/Models/UserAddress.php @@ -30,6 +30,7 @@ class UserAddress extends Model // simple casts protected $casts = [ 'is_primary' => 'boolean', + 'is_billing' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Models/UserSetting.php b/app/Models/UserSetting.php new file mode 100644 index 0000000..3146ed6 --- /dev/null +++ b/app/Models/UserSetting.php @@ -0,0 +1,22 @@ +belongsTo(User::class); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 6e6d3b5..a521adb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - // + // set language/locale + $middleware->web(append: [ + SetUserLocale::class, + ]); }) ->withSchedule(function (Schedule $schedule) { diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index bf14f89..d52e600 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -14,6 +14,7 @@ return new class extends Migration $table->string('firstname')->nullable(); $table->string('lastname')->nullable(); $table->string('displayname')->nullable(); // formerly from sabre principals table + $table->string('phone', 32)->nullable(); // user-level phone number //$table->string('name')->nullable(); // custom name if necessary $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); diff --git a/database/migrations/2025_08_21_000000_user_address_improvements.php b/database/migrations/2025_08_21_000000_user_address_improvements.php index 63b1b60..89b4938 100644 --- a/database/migrations/2025_08_21_000000_user_address_improvements.php +++ b/database/migrations/2025_08_21_000000_user_address_improvements.php @@ -15,10 +15,10 @@ return new class extends Migration Schema::create('user_addresses', function (Blueprint $table) { $table->bigIncrements('id'); - // your users.id is char(26) + // users.id is char(26) $table->char('user_id', 26); $table->string('label', 100)->nullable(); // "Home", "Office" - $table->string('kind', 20)->nullable(); // "billing", "shipping", "home", "work" + $table->string('kind', 20)->nullable(); // "home", "work", etc // raw, user-entered fields $table->string('line1', 255)->nullable(); @@ -27,7 +27,6 @@ return new class extends Migration $table->string('state', 64)->nullable(); $table->string('postal', 32)->nullable(); $table->string('country', 64)->nullable(); - $table->string('phone', 32)->nullable(); // optional normalized location $table->unsignedBigInteger('location_id')->nullable(); @@ -35,11 +34,15 @@ return new class extends Migration // nullable so unique index allows many NULLs (only 1 with value 1) $table->boolean('is_primary')->nullable()->default(null); + // flag for whether this is the user's billing address + $table->boolean('is_billing')->default(false); + $table->timestamps(); // helpful indexes $table->index(['user_id', 'kind']); $table->index('postal'); + $table->index(['user_id', 'is_billing']); $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); $table->foreign('location_id')->references('id')->on('locations')->nullOnDelete(); @@ -55,7 +58,6 @@ return new class extends Migration $table->string('country', 64)->nullable()->after('postal'); $table->decimal('lat', 9, 6)->nullable()->after('country'); $table->decimal('lon', 9, 6)->nullable()->after('lat'); - $table->index('postal'); }); } @@ -76,4 +78,4 @@ return new class extends Migration Schema::dropIfExists('user_addresses'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_01_16_000000_create_user_settings_table.php b/database/migrations/2026_01_16_000000_create_user_settings_table.php new file mode 100644 index 0000000..e01576b --- /dev/null +++ b/database/migrations/2026_01_16_000000_create_user_settings_table.php @@ -0,0 +1,31 @@ +id(); + $table->char('user_id', 26); + $table->string('key', 100); + $table->text('value')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'key']); + $table->index(['user_id', 'key']); + + $table->foreign('user_id') + ->references('id')->on('users') + ->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_settings'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c597d89..bf2d3f7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -42,6 +42,32 @@ class DatabaseSeeder extends Seeder 'displayname' => $firstname.' '.$lastname, ]); + /** + * + * global calendar settings (user_settings) + */ + + $defaultCalendarSettings = [ + 'app.language' => 'en', + 'app.region' => 'US', + 'app.date_format' => 'mdy', + 'app.time_format' => '12', + ]; + + foreach ($defaultCalendarSettings as $key => $value) { + DB::table('user_settings')->updateOrInsert( + [ + 'user_id' => $user->id, + 'key' => $key, + ], + [ + 'value' => (string) $value, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + /** * * calendar and meta diff --git a/lang/en/account.php b/lang/en/account.php new file mode 100644 index 0000000..d2aa7a7 --- /dev/null +++ b/lang/en/account.php @@ -0,0 +1,64 @@ + [ + 'city' => 'City', + 'country' => 'Country', + 'home' => 'Home address', + 'label' => 'Address label', + 'line1' => 'Address line 1', + 'line2' => 'Address line 2', + 'state' => 'State', + 'work' => 'Work address', + 'zip' => 'Zip code', + ], + 'billing' => [ + 'home' => 'Use your home address for billing', + 'work' => 'Use your work address for billing', + ], + 'delete' => 'Delete account', + 'delete-your' => 'Delete your account', + 'delete-confirm' => 'Really delete my account!', + 'email' => 'Email', + 'email_address' => 'Email address', + 'first_name' => 'First name', + 'last_name' => 'Last name', + 'phone' => 'Phone number', + 'settings' => [ + 'addresses' => [ + 'title' => 'Addresses', + 'subtitle' => 'Manage your home and work addresses and choose which one is used for billing.', + ], + 'delete' => [ + 'title' => 'There be dragons here', + 'subtitle' => 'Delete your account and remove all information from our database. This can\'t be undone, so we recommend exporting your data first and migrating to a new provider.', + 'explanation' => 'Please note that this is not like other apps that "delete" your data—we\'re not setting is_deleted = 1, we\'re purging it from our database.', + ], + 'delete-confirm' => [ + 'title' => 'Confirm account deletion', + 'subtitle' => 'Please enter your password and confirm that you would like to permanently delete your account.', + ], + 'information' => [ + 'title' => 'Account information', + 'subtitle' => 'Your name, email address, and other primary account details.', + ], + 'password' => [ + 'title' => 'Password', + 'subtitle' => 'Ensure your account is using a long, random password to stay secure. We always recommend a password manager as well!', + ], + 'title' => 'Account settings', + ], + 'title' => 'Account', + +]; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 0000000..6598e2c --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,20 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/calendar.php b/lang/en/calendar.php new file mode 100644 index 0000000..e7ad524 --- /dev/null +++ b/lang/en/calendar.php @@ -0,0 +1,28 @@ + [ + 'language_region' => [ + 'title' => 'Language and region', + 'subtitle' => 'Choose your default language, region, and formatting preferences for calendars. These affect how dates and times are displayed throughout Kithkin.', + ], + 'subscribe' => [ + 'title' => 'Subscribe to a calendar', + 'subtitle' => 'Add an `.ics` calendar from another service', + ], + 'title' => 'Calendar settings', + ], + +]; diff --git a/lang/en/common.php b/lang/en/common.php new file mode 100644 index 0000000..442cfa6 --- /dev/null +++ b/lang/en/common.php @@ -0,0 +1,26 @@ + 'Address', + 'addresses' => 'Addresses', + 'calendar' => 'Calendar', + 'calendars' => 'Calendars', + 'cancel' => 'Cancel', + 'cancel_funny' => 'Get me out of here', + 'event' => 'Event', + 'events' => 'Events', + 'password' => 'Password', + 'save_changes' => 'Save changes', + 'settings' => 'Settings', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 0000000..d481411 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 0000000..fad3a7d --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..c57cf83 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,198 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'any_of' => 'The :attribute field is invalid.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', + 'current_password' => 'The password you entered is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'list' => 'The :attribute field must be a list.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.', + 'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_if_declined' => 'The :attribute field is required when :other is declined.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/lang/it/common.php b/lang/it/common.php new file mode 100644 index 0000000..b84840e --- /dev/null +++ b/lang/it/common.php @@ -0,0 +1,11 @@ + 'Calendario', + 'calendars' => 'Calendari', + 'event' => 'Evento', + 'events' => 'Eventi', + 'settings' => 'Impostazioni', + +]; diff --git a/resources/css/app.css b/resources/css/app.css index e2e8423..925ee4e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -13,6 +13,7 @@ @import './lib/input.css'; @import './lib/mini.css'; @import './lib/modal.css'; +@import './lib/toast.css'; /** plugins */ @plugin '@tailwindcss/forms'; diff --git a/resources/css/etc/layout.css b/resources/css/etc/layout.css index bd9edd0..18ab955 100644 --- a/resources/css/etc/layout.css +++ b/resources/css/etc/layout.css @@ -133,14 +133,14 @@ main { a { @apply flex flex-row gap-2 items-center justify-start px-3 h-9 rounded-md; - transition: background-color 100ms ease-in-out; + /*transition: background-color 100ms ease-in-out; */ &:hover { @apply bg-cyan-200; } &.is-active { - @apply bg-cyan-400; + @apply bg-cyan-400 font-semibold; &:hover { @apply bg-cyan-500; @@ -193,6 +193,16 @@ main { > .content { @apply col-span-4 2xl:col-span-3; + + /* page subtitle section */ + .description { + + } + + /* everything below the description (i.e., the content pane) */ + .pane { + @apply flex flex-col items-start gap-4 my-8; + } } } diff --git a/resources/css/etc/theme.css b/resources/css/etc/theme.css index e1c38af..17abed5 100644 --- a/resources/css/etc/theme.css +++ b/resources/css/etc/theme.css @@ -16,6 +16,8 @@ --color-gray-950: #282828; --color-primary: #151515; --color-primary-hover: #000000; + --color-secondary: #555; + --color-secondary-hover: #444; --color-cyan-50: oklch(98.97% 0.015 196.79); --color-cyan-100: oklch(97.92% 0.03 196.61); --color-cyan-200: oklch(95.79% 0.063 196.12); @@ -40,6 +42,17 @@ --color-magenta-800: oklch(47.69% 0.219 328.37); --color-magenta-900: oklch(40.42% 0.186 328.37); --color-magenta-950: oklch(36.79% 0.169 328.37); + --color-red-50: oklch(0.975 0.012 23.84); + --color-red-100: oklch(0.951 0.024 20.79); + --color-red-200: oklch(0.895 0.055 23.81); + --color-red-300: oklch(0.845 0.085 23.49); + --color-red-400: oklch(0.79 0.121 25.68); + --color-red-500: oklch(0.742 0.157 27.48); + --color-red-600: oklch(0.686 0.203 26.03); + --color-red-700: oklch(0.586 0.1663 26.19); + --color-red-800: oklch(0.386 0.136 34.85); + --color-red-900: oklch(0.269 0.095 34.78); + --color-red-950: oklch(0.205 0.073 34.33); --border-width-md: 1.5px; diff --git a/resources/css/etc/type.css b/resources/css/etc/type.css index 02ccb2f..dea7dec 100644 --- a/resources/css/etc/type.css +++ b/resources/css/etc/type.css @@ -34,12 +34,17 @@ h2 { @apply font-serif text-2xl font-extrabold leading-tight text-primary; } +/* section dividers */ +h3 { + @apply text-xl font-semibold leading-tight text-secondary; +} + /* links */ a { &.text { - @apply underline decoration-inherit underline-offset-2 text-magenta-600; + @apply underline decoration-inherit underline-offset-2 text-black font-semibold; text-decoration-thickness: 1.5px; - transition: color 125ms ease-in-out; + transition: color 100ms ease-in-out; &:hover { @apply text-magenta-700; @@ -58,4 +63,12 @@ p { .description { /* contains

and uses gap for spacing */ @apply space-y-3; + + p { + @apply text-lg; + } +} + +.error { + @apply text-base text-red-600 font-semibold; } diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css index d796fee..df6ec30 100644 --- a/resources/css/lib/button.css +++ b/resources/css/lib/button.css @@ -1,7 +1,7 @@ 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; + /*transition: background-color 125ms ease-in-out; */ --button-border: var(--color-primary); --button-accent: var(--color-primary-hover); @@ -22,6 +22,34 @@ button, } } + &.button--secondary { + @apply bg-white border-md border-solid; + border-color: var(--button-border); + --button-border: var(--color-gray-400); + --button-accent: var(--color-gray-100); + + &:hover { + @apply bg-gray-100; + } + } + + &.button--tertiary { + @apply px-0; + + &:hover { + @apply underline decoration-[1.5px] underline-offset-3; + } + } + + &.button--danger { + @apply bg-red-500 font-bold; + --button-accent: var(--color-red-900); + + &:hover { + @apply bg-red-600; + } + } + &.button--icon { @apply justify-center p-0 h-12 top-px rounded-blob; aspect-ratio: 1 / 1; @@ -45,7 +73,7 @@ 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: background-color 100ms ease-in-out; */ &:hover { @apply bg-cyan-200; diff --git a/resources/css/lib/input.css b/resources/css/lib/input.css index 8978abe..0946776 100644 --- a/resources/css/lib/input.css +++ b/resources/css/lib/input.css @@ -5,13 +5,52 @@ input[type="email"], input[type="password"], input[type="text"], input[type="url"], -input[type="search"] { +input[type="search"], +select { @apply border-md border-gray-800 bg-white rounded-md shadow-input; @apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600; transition: box-shadow 125ms ease-in-out, border-color 125ms ease-in-out; } +/** + * checkboxes + */ + + +/** + * radio buttons + */ + +input[type="radio"] { + @apply appearance-none p-0 align-middle select-none shrink-0 rounded-full h-6 w-6; + @apply text-black border-md border-gray-800 bg-none; + @apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600; + print-color-adjust: exact; + --radio-bg: var(--color-white); + + &:checked { + @apply border-black; + box-shadow: 0 0 0 3px var(--radio-bg) inset, 0 0 0 3px var(--radio-bg) inset; + animation: radio-check 200ms ease-out; + } +} +@keyframes radio-check { + 0% { + box-shadow: 0 0 0 12px var(--radio-bg) inset, 0 0 0 12px var(--radio-bg) inset; + } + 50% { + box-shadow: 0 0 0 2px var(--radio-bg) inset, 0 0 0 2px var(--radio-bg) inset; + } + 100% { + box-shadow: 0 0 0 3px var(--radio-bg) inset, 0 0 0 3px var(--radio-bg) inset; + } +} +.radio-label { + @apply flex flex-row gap-2 items-center; +} + + /** * specific minor types */ @@ -30,18 +69,32 @@ input[type="color"] { label.text-label { @apply flex flex-col gap-2; - > .label { - @apply font-semibold text-md; - } - > .description { @apply text-gray-800; } } +.label { + @apply font-semibold text-md; +} /** * form layouts */ +form { + &.settings { + @apply mt-8; + @apply 2xl:max-w-3xl; + } +} +article.settings { + /* scroll detection for use later */ + animation: settings-scroll; + animation-timeline: scroll(self); + --can-scroll: 0; +} +@keyframes settings-scroll { + from, to { --can-scroll: 1; } +} .form-grid-1 { @apply grid grid-cols-3 gap-6; @@ -53,5 +106,63 @@ label.text-label { @apply col-span-3 flex flex-row justify-start gap-4 pt-4; } } +.input-row { + @apply grid gap-4; + @apply md:gap-6; + /* format is a string of slash-separated cols, so `1-2` is 3 cols, 1st spans 1 and 2nd spans 2 */ + &.input-row--1 { + @apply grid-cols-1; + } + &.input-row--1-1 { + @apply grid-cols-1; + @apply md:grid-cols-2; + } + &.input-row--1-1-1-1 { + @apply grid-cols-1; + @apply md:grid-cols-2; + @apply xl:grid-cols-4; + } + &.input-row--2-1-1 { + @apply grid-cols-1; + @apply md:grid-cols-4; + + .input-cell:first-child { + @apply col-span-2; + } + } + + /* bottom actions row */ + &.input-row--actions { + @apply pt-6 pb-8 flex flex-row gap-6 justify-between items-center; + @apply sticky bottom-0 bg-white border-t-md border-transparent; + transition: border-color 100ms ease-in-out; + box-shadow: 0 -1rem 1rem white; + + &.input-row--start { + @apply justify-start; + } + } + + /* generic cell and row handling */ + .input-cell { + @apply flex flex-col gap-2; + } + + .input-row { + @apply mt-6; + } +} +h3 + .input-row { + @apply mt-6; +} +@container style(--can-scroll: 1) { + .input-row--actions { + @apply !border-black; + } +} +@container style(--can-scroll: 0) { + .input-row--actions { + @apply !border-transparent; + } +} diff --git a/resources/css/lib/modal.css b/resources/css/lib/modal.css index 7254dc3..9c79f62 100644 --- a/resources/css/lib/modal.css +++ b/resources/css/lib/modal.css @@ -1,3 +1,7 @@ +.close-modal { + @apply hidden; +} + dialog { @apply grid fixed top-0 right-0 bottom-0 left-0 m-0 p-0 pointer-events-none; @apply justify-items-center items-start bg-transparent opacity-0 invisible; @@ -20,16 +24,35 @@ dialog { transition: all 150ms cubic-bezier(0,0,.2,1); box-shadow: #00000040 0 1.5rem 4rem -0.5rem; - > form { - @apply absolute top-4 right-4; + > .close-modal { + @apply block absolute top-4 right-4; } > .content { @apply w-full; /* modal header */ - h2 { - @apply pr-12; + header { + @apply px-6 py-6; + + h2 { + @apply pr-12; + } + } + + /* main content pane */ + section { + @apply flex flex-col px-6 pb-8; + } + + /* standard form with 1rem gap between rows */ + form { + @apply flex flex-col gap-4; + } + + /* footer */ + footer { + @apply px-6 py-4 border-t-md border-gray-400 flex justify-between; } } } diff --git a/resources/css/lib/toast.css b/resources/css/lib/toast.css new file mode 100644 index 0000000..a3bbfe4 --- /dev/null +++ b/resources/css/lib/toast.css @@ -0,0 +1,52 @@ +dl.toasts { + @apply fixed top-4 right-4 z-100; + --toast-delay: 7s; + + /* semantic info in a hidden title */ + dt { + @apply h-0 invisible overflow-hidden; + + &.success + dd { + @apply bg-green-500 text-white; + } + + &.error + dd { + @apply bg-red-500 text-primary; + } + + &.info + dd { + @apply bg-cyan-500 text-primary; + } + } + + /* toast itself */ + dd { + @apply max-w-96 rounded-md px-4 py-3 translate-y-2 leading-tight font-medium; + animation: + toast-in 250ms ease-out forwards, + toast-out 300ms ease-in forwards; + animation-delay: + 0ms, + var(--toast-delay); + } +} + +.toast__dismiss{ + color: rgba(255,255,255,.85); + text-decoration: underline; + font-size: .875rem; + margin-left: auto; +} + +@keyframes toast-in { + to { + opacity: 1; + translate: 0; + } +} +@keyframes toast-out { + to { + opacity: 0; + translate: 0 -0.5rem; + } +} diff --git a/resources/js/app.js b/resources/js/app.js index ba48ada..4295696 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -15,7 +15,7 @@ document.addEventListener('htmx:configRequest', (evt) => { }) // calendar toggle -// > progressive enhancement on html form with no js +// * progressive enhancement on html form with no js document.addEventListener('change', event => { const checkbox = event.target; diff --git a/resources/svg/icons/bomb.svg b/resources/svg/icons/bomb.svg new file mode 100644 index 0000000..4a880a4 --- /dev/null +++ b/resources/svg/icons/bomb.svg @@ -0,0 +1 @@ + diff --git a/resources/svg/icons/info-circle.svg b/resources/svg/icons/info-circle.svg new file mode 100644 index 0000000..3953b4d --- /dev/null +++ b/resources/svg/icons/info-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/key.svg b/resources/svg/icons/key.svg new file mode 100644 index 0000000..58e2389 --- /dev/null +++ b/resources/svg/icons/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/pin.svg b/resources/svg/icons/pin.svg new file mode 100644 index 0000000..8d2fc94 --- /dev/null +++ b/resources/svg/icons/pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/solid/bomb.svg b/resources/svg/icons/solid/bomb.svg new file mode 100644 index 0000000..630f69c --- /dev/null +++ b/resources/svg/icons/solid/bomb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/solid/calendar-sync.svg b/resources/svg/icons/solid/calendar-sync.svg new file mode 100644 index 0000000..272cadb --- /dev/null +++ b/resources/svg/icons/solid/calendar-sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/solid/calendar.svg b/resources/svg/icons/solid/calendar.svg new file mode 100644 index 0000000..187c517 --- /dev/null +++ b/resources/svg/icons/solid/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/solid/globe.svg b/resources/svg/icons/solid/globe.svg new file mode 100644 index 0000000..baf1c45 --- /dev/null +++ b/resources/svg/icons/solid/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/solid/info-circle.svg b/resources/svg/icons/solid/info-circle.svg new file mode 100644 index 0000000..a34df2d --- /dev/null +++ b/resources/svg/icons/solid/info-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/solid/key.svg b/resources/svg/icons/solid/key.svg new file mode 100644 index 0000000..f01c013 --- /dev/null +++ b/resources/svg/icons/solid/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/solid/pin.svg b/resources/svg/icons/solid/pin.svg new file mode 100644 index 0000000..776a55a --- /dev/null +++ b/resources/svg/icons/solid/pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/profile/edit.blade.php b/resources/views/account/edit.blade.php similarity index 80% rename from resources/views/profile/edit.blade.php rename to resources/views/account/edit.blade.php index 134e9aa..1a1cc24 100644 --- a/resources/views/profile/edit.blade.php +++ b/resources/views/account/edit.blade.php @@ -9,13 +9,13 @@

- @include('profile.partials.update-profile-information-form') + @include('account.partials.update-profile-information-form')
- @include('profile.partials.addresses-form', [ + @include('account.partials.addresses-form', [ 'home' => $home ?? null, 'billing' => $billing ?? null, ]) @@ -24,13 +24,13 @@
- @include('profile.partials.update-password-form') + @include('account.partials.update-password-form')
- @include('profile.partials.delete-user-form') + @include('account.partials.delete-user-form')
diff --git a/resources/views/profile/index.blade.php b/resources/views/account/index.blade.php similarity index 76% rename from resources/views/profile/index.blade.php rename to resources/views/account/index.blade.php index 7930312..98c1f65 100644 --- a/resources/views/profile/index.blade.php +++ b/resources/views/account/index.blade.php @@ -1,10 +1,10 @@ - +

- {{ __('Profile') }} + {{ __('account.title') }}

- +
@@ -12,7 +12,7 @@ @isset($data['title']) {{ $data['title'] }} @else - {{ __('Settings') }} + {{ __('common.settings') }} @endisset diff --git a/resources/views/account/partials/delete-modal.blade.php b/resources/views/account/partials/delete-modal.blade.php new file mode 100644 index 0000000..49f521f --- /dev/null +++ b/resources/views/account/partials/delete-modal.blade.php @@ -0,0 +1,26 @@ + + + {{ __('account.settings.delete-confirm.title') }} + + +
+ @csrf + @method('delete') +

+ {{ __('account.settings.delete-confirm.subtitle') }} +

+
+ + +
+
+
+ + + {{ __('common.cancel') }} + + + {{ __('account.delete-confirm') }} + + +
diff --git a/resources/views/account/settings/addresses.blade.php b/resources/views/account/settings/addresses.blade.php new file mode 100644 index 0000000..011d9ba --- /dev/null +++ b/resources/views/account/settings/addresses.blade.php @@ -0,0 +1,124 @@ +
+

+ {{ $sub }} +

+
+ +
+ @csrf + @method('patch') + + {{-- home address --}} +

{{ __('account.address.home') }}

+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+ + {{-- work address --}} +

{{ __('account.address.work') }}

+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+ + {{-- save --}} +
+ {{ __('common.save_changes') }} + {{ __('common.cancel') }} + + @if (session('status') === 'addresses-updated') +

{{ __('Saved.') }}

+ @endif +
+
diff --git a/resources/views/account/settings/delete-confirm.blade.php b/resources/views/account/settings/delete-confirm.blade.php new file mode 100644 index 0000000..7c43f56 --- /dev/null +++ b/resources/views/account/settings/delete-confirm.blade.php @@ -0,0 +1,21 @@ +
+ @isset($sub) +

{{ $sub }}

+ @endisset +
+
+ @csrf + @method('delete') + +
+
+ + +
+
+ +
+ {{ __('account.delete-confirm') }} + {{ __('common.cancel_funny') }} +
+
diff --git a/resources/views/account/settings/delete.blade.php b/resources/views/account/settings/delete.blade.php new file mode 100644 index 0000000..8e642b4 --- /dev/null +++ b/resources/views/account/settings/delete.blade.php @@ -0,0 +1,31 @@ +
+

+ {{ $sub }} +

+
+
+ +

+ {{ __('account.delete-your') }} +

+ +

+ {!! __('account.settings.delete.explanation') !!} +

+ + + {{ __('Delete my account...') }} + + + @error('password', 'userDeletion') +
{{ $message }}
+ @enderror +
diff --git a/resources/views/account/settings/info.blade.php b/resources/views/account/settings/info.blade.php new file mode 100644 index 0000000..2609d09 --- /dev/null +++ b/resources/views/account/settings/info.blade.php @@ -0,0 +1,70 @@ +
+

+ {{ __("account.settings.information.subtitle") }} +

+
+ +
+ @csrf +
+ +
+ @csrf + @method('patch') + +
+
+ + +
+
+ + +
+
+ +
+
+ + + + @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) +
+

+ {{ __('Your email address is unverified.') }} + + +

+ + @if (session('status') === 'verification-link-sent') +

+ {{ __('A new verification link has been sent to your email address.') }} +

+ @endif +
+ @endif +
+
+ + + +
+
+ +
+ {{ __('common.save_changes') }} + {{ __('common.cancel') }} + + @if (session('status') === 'account-updated') +

{{ __('Saved.') }}

+ @endif +
+
diff --git a/resources/views/account/settings/password.blade.php b/resources/views/account/settings/password.blade.php new file mode 100644 index 0000000..af05e0a --- /dev/null +++ b/resources/views/account/settings/password.blade.php @@ -0,0 +1,46 @@ +
+

+ {{ __('account.settings.password.subtitle') }} +

+
+ +
+ @csrf + @method('put') + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ {{ __('common.save_changes') }} + {{ __('common.cancel') }} + + @if (session('status') === 'password-updated') +

{{ __('Saved.') }}

+ @endif +
+
diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 3d38186..85a9fdf 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -15,7 +15,7 @@ name="password" required autocomplete="current-password" /> - +
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index cb32e08..5d98b94 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -13,7 +13,7 @@
- +
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 19e3270..ad4c5f9 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -7,21 +7,21 @@
- + - +
- + - +
@@ -34,7 +34,7 @@
@if (Route::has('password.request')) - + {{ __('Forgot your password?') }} @endif diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index c690bfb..2c982f4 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -6,14 +6,14 @@
- +
- +
@@ -25,7 +25,7 @@ name="password" required autocomplete="new-password" /> - +
@@ -36,7 +36,7 @@ type="password" name="password_confirmation" required autocomplete="new-password" /> - +
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index a6494cc..4612075 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -9,14 +9,14 @@
- +
- +
@@ -27,7 +27,7 @@ type="password" name="password_confirmation" required autocomplete="new-password" /> - +
diff --git a/resources/views/calendar/_form.blade.php b/resources/views/calendar/_form.blade.php index 45a4b77..d3808bc 100644 --- a/resources/views/calendar/_form.blade.php +++ b/resources/views/calendar/_form.blade.php @@ -11,7 +11,7 @@ - +
{{-- Description --}} @@ -19,7 +19,7 @@ - +
{{-- Timezone --}} @@ -34,7 +34,7 @@ @endforeach - +
{{-- COLOR --}} diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php index 8f84f09..fcd3531 100644 --- a/resources/views/calendar/index.blade.php +++ b/resources/views/calendar/index.blade.php @@ -2,7 +2,7 @@

- {{ __('Calendar') }} + {{ __('common.calendar') }}

- {{ __('Calendar') }} + {{ __('common.calendar') }}

- + @@ -12,7 +12,7 @@ @isset($data['title']) {{ $data['title'] }} @else - {{ __('Settings') }} + {{ __('common.settings') }} @endisset diff --git a/resources/views/calendar/settings/language.blade.php b/resources/views/calendar/settings/language.blade.php new file mode 100644 index 0000000..37b0c3e --- /dev/null +++ b/resources/views/calendar/settings/language.blade.php @@ -0,0 +1,78 @@ +@php + $values = $data['values'] ?? []; + $options = $data['options'] ?? []; +@endphp + +
+

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

+
+ + + @csrf + +
+ + + @error('language') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('region') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('date_format') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('time_format') +
{{ $message }}
+ @enderror +
+ +
+ {{ __('Save') }} + {{ __('Cancel and go back') }} +
+ diff --git a/resources/views/components/app/nav-button.blade.php b/resources/views/components/app/nav-button.blade.php index d2bc56d..2056550 100644 --- a/resources/views/components/app/nav-button.blade.php +++ b/resources/views/components/app/nav-button.blade.php @@ -1,13 +1,26 @@ -@props(['active']) +@props([ + 'active' => false, + 'label' => null, + 'icon' => null, +]) @php -$classes = ($active ?? false) - ? 'is-active' - : ''; + $isActive = (bool) $active; + + $classes = $isActive ? 'is-active' : null; + + $iconComponent = null; + if ($icon) { + $iconComponent = $isActive + ? 'icon-solid.'.$icon + : 'icon-'.$icon; + } @endphp
  • - merge(['class' => $classes]) }}> - {{ $slot }} + merge(['class' => $classes]) }} aria-label="{{ $label }}"> + @if ($iconComponent) + + @endif
  • diff --git a/resources/views/components/app/pagelink.blade.php b/resources/views/components/app/pagelink.blade.php index 344e981..4f8b33d 100644 --- a/resources/views/components/app/pagelink.blade.php +++ b/resources/views/components/app/pagelink.blade.php @@ -1,11 +1,37 @@ -@props(['active']) +@props([ + 'active' => false, + + // label text (string) + 'label' => null, + + // icon name only + 'icon' => null, +]) @php -$classes = ($active ?? false) - ? 'is-active' - : ''; + $isActive = (bool) $active; + + $classes = trim(collect([ + 'pagelink', + $isActive ? 'is-active' : null, + ])->filter()->implode(' ')); + + $iconComponent = null; + if ($icon) { + $iconComponent = $isActive + ? 'icon-solid.'.$icon + : 'icon-'.$icon; + } @endphp merge(['class' => $classes]) }}> - {{ $slot }} + @if ($iconComponent) + + @endif + + @if (!is_null($label)) + {{ $label }} + @else + {{ $slot }} + @endif diff --git a/resources/views/components/button/icon.blade.php b/resources/views/components/button/icon.blade.php index 0b1c0da..dfdc8db 100644 --- a/resources/views/components/button/icon.blade.php +++ b/resources/views/components/button/icon.blade.php @@ -21,8 +21,8 @@ $sizeClass = match ($size) { $element = match ($type) { 'anchor' => 'a', - 'button' => 'button', - 'submit' => 'button', + 'button' => 'button type="button"', + 'submit' => 'button type="submit"', default => 'button', }; diff --git a/resources/views/components/button/index.blade.php b/resources/views/components/button/index.blade.php index 1bb4779..0fd4b4d 100644 --- a/resources/views/components/button/index.blade.php +++ b/resources/views/components/button/index.blade.php @@ -1,39 +1,51 @@ @props([ - 'variant' => '', - 'size' => 'default', - 'type' => 'button', - 'class' => '', - 'label' => '', - 'href' => null ]) + 'variant' => '', // e.g. "primary danger" + 'size' => 'default', // sm | default | lg + 'type' => 'button', // anchor | button | submit + 'class' => '', + 'label' => '', + 'href' => null, +]) @php -$variantClass = match ($variant) { - 'primary' => 'button--primary', - 'secondary' => 'button--secondary', - default => '', -}; + // allow "primary danger" (space-delimited), or even "primary,danger" + $variantTokens = preg_split('/[\s,]+/', trim($variant)) ?: []; -$sizeClass = match ($size) { - 'sm' => 'button--sm', - 'lg' => 'button--lg', - default => '', -}; + $variantMap = [ + 'primary' => 'button--primary', + 'secondary' => 'button--secondary', + 'tertiary' => 'button--tertiary', + 'danger' => 'button--danger', + ]; -$element = match ($type) { - 'anchor' => 'a', - 'button' => 'button type="button"', - 'submit' => 'button type="submit"', - default => 'button', -}; + $variantClass = collect($variantTokens) + ->map(fn ($v) => $variantMap[$v] ?? null) + ->filter() + ->implode(' '); -$type = match ($type) { - 'anchor' => '', - 'button' => 'type="button"', - 'submit' => 'type="submit"', - default => '', -} + $sizeClass = match ($size) { + 'sm' => 'button--sm', + 'lg' => 'button--lg', + default => '', + }; + + $isAnchor = $type === 'anchor'; + $tag = $isAnchor ? 'a' : 'button'; + $buttonType = $isAnchor ? null : ($type === 'submit' ? 'submit' : 'button'); + + $classes = trim("button {$variantClass} {$sizeClass} {$class}"); @endphp -<{{ $element }} {{ $type }} class="button button--icon {{ $variantClass }} {{ $sizeClass }} {{ $class }}" aria-label="{{ $label }}" href="{{ $href }}"> - {{ $slot }} - +@if($isAnchor) + merge(['class' => $classes]) }}> + {{ $slot }} + +@else + +@endif diff --git a/resources/views/components/calendar/settings-menu.blade.php b/resources/views/components/calendar/settings-menu.blade.php deleted file mode 100644 index db21a16..0000000 --- a/resources/views/components/calendar/settings-menu.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -
    -
    - {{ __('General settings') }} - -
  • - - - Language and region - -
  • -
    -
    -
    - {{ __('Add a calendar') }} - -
  • - - - Subscribe to a calendar - -
  • -
    -
    -
    diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input/error.blade.php similarity index 51% rename from resources/views/components/input-error.blade.php rename to resources/views/components/input/error.blade.php index 9e6da21..43907e3 100644 --- a/resources/views/components/input-error.blade.php +++ b/resources/views/components/input/error.blade.php @@ -1,9 +1,9 @@ @props(['messages']) @if ($messages) -
      merge(['class' => 'text-sm text-red-600 space-y-1']) }}> +
        merge(['class' => 'input-errors']) }}> @foreach ((array) $messages as $message) -
      • {{ $message }}
      • +
      • {{ $message }}
      • @endforeach
      @endif diff --git a/resources/views/components/input/label.blade.php b/resources/views/components/input/label.blade.php new file mode 100644 index 0000000..532c47c --- /dev/null +++ b/resources/views/components/input/label.blade.php @@ -0,0 +1,5 @@ +@props(['value']) + + diff --git a/resources/views/components/input/radio-label.blade.php b/resources/views/components/input/radio-label.blade.php new file mode 100644 index 0000000..e6846dc --- /dev/null +++ b/resources/views/components/input/radio-label.blade.php @@ -0,0 +1,22 @@ +@props([ + 'id' => null, // input id + 'label' => '', // label text + 'labelclass' => '', // extra CSS classes for the label + 'checkclass' => '', // radio classes + 'name' => '', // radio name + 'value' => '', // radio value + 'style' => '', // raw style string for the radio + 'checked' => false // true/false or truthy value +]) + + diff --git a/resources/views/components/input/radio.blade.php b/resources/views/components/input/radio.blade.php new file mode 100644 index 0000000..8880fe2 --- /dev/null +++ b/resources/views/components/input/radio.blade.php @@ -0,0 +1,16 @@ +@props([ + 'id' => null, // input id + 'class' => '', // extra CSS classes + 'name' => '', // input name + 'value' => '', // input value + 'style' => '', // raw style string + 'checked' => false // true/false or truthy value +]) + +class($class) }} + @if($style !== '') style="{{ $style }}" @endif + @checked($checked) /> diff --git a/resources/views/components/input/select-state.blade.php b/resources/views/components/input/select-state.blade.php new file mode 100644 index 0000000..a1b5234 --- /dev/null +++ b/resources/views/components/input/select-state.blade.php @@ -0,0 +1,74 @@ +@props([ + 'id' => 'select-state', + 'name' => 'state', + 'selected' => null, + 'placeholder' => 'Select a state', + 'required' => false, + 'autocomplete' => 'address-level1', +]) + +@php + $states = [ + 'AL' => 'Alabama', + 'AK' => 'Alaska', + 'AZ' => 'Arizona', + 'AR' => 'Arkansas', + 'CA' => 'California', + 'CO' => 'Colorado', + 'CT' => 'Connecticut', + 'DE' => 'Delaware', + 'DC' => 'District of Columbia', + 'FL' => 'Florida', + 'GA' => 'Georgia', + 'HI' => 'Hawaii', + 'ID' => 'Idaho', + 'IL' => 'Illinois', + 'IN' => 'Indiana', + 'IA' => 'Iowa', + 'KS' => 'Kansas', + 'KY' => 'Kentucky', + 'LA' => 'Louisiana', + 'ME' => 'Maine', + 'MD' => 'Maryland', + 'MA' => 'Massachusetts', + 'MI' => 'Michigan', + 'MN' => 'Minnesota', + 'MS' => 'Mississippi', + 'MO' => 'Missouri', + 'MT' => 'Montana', + 'NE' => 'Nebraska', + 'NV' => 'Nevada', + 'NH' => 'New Hampshire', + 'NJ' => 'New Jersey', + 'NM' => 'New Mexico', + 'NY' => 'New York', + 'NC' => 'North Carolina', + 'ND' => 'North Dakota', + 'OH' => 'Ohio', + 'OK' => 'Oklahoma', + 'OR' => 'Oregon', + 'PA' => 'Pennsylvania', + 'RI' => 'Rhode Island', + 'SC' => 'South Carolina', + 'SD' => 'South Dakota', + 'TN' => 'Tennessee', + 'TX' => 'Texas', + 'UT' => 'Utah', + 'VT' => 'Vermont', + 'VA' => 'Virginia', + 'WA' => 'Washington', + 'WV' => 'West Virginia', + 'WI' => 'Wisconsin', + 'WY' => 'Wyoming', + ]; +@endphp + + diff --git a/resources/views/components/input/select.blade.php b/resources/views/components/input/select.blade.php new file mode 100644 index 0000000..27e4704 --- /dev/null +++ b/resources/views/components/input/select.blade.php @@ -0,0 +1,63 @@ +@props([ + 'id' => '', // unique ID + 'class' => '', // extra CSS classes + 'name' => '', // select name + 'options' => [], // array of options, optgroups, or rich option defs + 'selected' => null, // selected value (falls back to old()) + 'placeholder' => '', // optional placeholder option (disabled) + 'style' => '', // raw style string + 'required' => false, // true/false or truthy value + 'autocomplete' => '', +]) + +@php + // prefer old() value if present, otherwise selected prop + $current = old($name, $selected); + + $isSelected = function ($value) use ($current) { + // cast to string so '1' and 1 match + return (string) $value === (string) $current; + }; + + $renderOption = function ($value, $label, $attrs = []) use ($isSelected) { + $attrString = collect($attrs)->map(function ($v, $k) { + if (is_bool($v)) return $v ? $k : ''; + return $k.'="'.e($v).'"'; + })->filter()->implode(' '); + + $selectedAttr = $isSelected($value) ? ' selected' : ''; + + return ''; + }; +@endphp + + diff --git a/resources/views/components/input/text.blade.php b/resources/views/components/input/text.blade.php index a7b81cd..f8f70ff 100644 --- a/resources/views/components/input/text.blade.php +++ b/resources/views/components/input/text.blade.php @@ -12,6 +12,6 @@ name="{{ $name }}" value="{{ $value }}" placeholder="{{ $placeholder }}" - {{ $attributes->class($class) }} + {{ $attributes->merge(['class' => 'text']) }} @if($style !== '') style="{{ $style }}" @endif @required($required) /> diff --git a/resources/views/components/menu/account-settings.blade.php b/resources/views/components/menu/account-settings.blade.php new file mode 100644 index 0000000..2151354 --- /dev/null +++ b/resources/views/components/menu/account-settings.blade.php @@ -0,0 +1,39 @@ +
      +
      + {{ __('General settings') }} + +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
      +
      +
      diff --git a/resources/views/components/profile/settings-menu.blade.php b/resources/views/components/menu/calendar-settings.blade.php similarity index 51% rename from resources/views/components/profile/settings-menu.blade.php rename to resources/views/components/menu/calendar-settings.blade.php index db21a16..74bbf0e 100644 --- a/resources/views/components/profile/settings-menu.blade.php +++ b/resources/views/components/menu/calendar-settings.blade.php @@ -4,11 +4,11 @@
    • - - Language and region - + href="{{ route('calendar.settings.language') }}" + :active="request()->routeIs('calendar.settings.language')" + :label="__('calendar.settings.language_region.title')" + icon="globe" + />
    • @@ -18,10 +18,10 @@
    • - - Subscribe to a calendar - + :active="request()->routeIs('calendar.settings.subscribe')" + :label="__('calendar.settings.subscribe.title')" + icon="calendar-sync" + />
    • diff --git a/resources/views/components/modal/content.blade.php b/resources/views/components/modal/content.blade.php index 27e1bd4..74e1e62 100644 --- a/resources/views/components/modal/content.blade.php +++ b/resources/views/components/modal/content.blade.php @@ -1,4 +1,4 @@ -
      + diff --git a/resources/views/components/modal/footer.blade.php b/resources/views/components/modal/footer.blade.php new file mode 100644 index 0000000..6504f7c --- /dev/null +++ b/resources/views/components/modal/footer.blade.php @@ -0,0 +1,3 @@ +
      + {{ $slot }} +
      diff --git a/resources/views/components/modal/index.blade.php b/resources/views/components/modal/index.blade.php index d7d97ee..93b3352 100644 --- a/resources/views/components/modal/index.blade.php +++ b/resources/views/components/modal/index.blade.php @@ -1,7 +1,10 @@ - + diff --git a/resources/views/components/modal/title.blade.php b/resources/views/components/modal/title.blade.php index 8314024..dbf1f32 100644 --- a/resources/views/components/modal/title.blade.php +++ b/resources/views/components/modal/title.blade.php @@ -2,8 +2,8 @@ 'border' => false, ]) -
      $border])> -

      +
      $border])> +

      {{ $slot }}

      diff --git a/resources/views/event/form.blade.php b/resources/views/event/form.blade.php index cb3e8f2..1688045 100644 --- a/resources/views/event/form.blade.php +++ b/resources/views/event/form.blade.php @@ -31,7 +31,7 @@ - +

    {{-- Description --}} @@ -39,7 +39,7 @@ - + {{-- Location --}} @@ -70,7 +70,7 @@ - + {{-- Start / End --}} @@ -82,7 +82,7 @@ class="mt-1 block w-full" :value="old('start_at', $start)" required /> - +
    @@ -91,7 +91,7 @@ class="mt-1 block w-full" :value="old('end_at', $end)" required /> - +
    diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 8059cac..79be90d 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -35,20 +35,18 @@ - -
    - @if (session('toast')) -
    - {{ session('toast') }} -
    + +
    + @if (session()->has('toast')) + @php + $toast = session('toast'); + $message = is_array($toast) ? ($toast['message'] ?? '') : $toast; + $type = is_array($toast) ? ($toast['type'] ?? 'success') : 'success'; + @endphp +
    {{ $type }}
    +
    {{ $message }}
    @endif -
    + diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 6a95cb2..61c09b8 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -8,15 +8,21 @@ - - - - - - - - - + + + @@ -25,7 +31,7 @@ - + diff --git a/resources/views/profile/partials/addresses-form.blade.php b/resources/views/profile/partials/addresses-form.blade.php deleted file mode 100644 index 8220895..0000000 --- a/resources/views/profile/partials/addresses-form.blade.php +++ /dev/null @@ -1,153 +0,0 @@ -
    -
    -

    - {{ __('Addresses') }} -

    -

    - {{ __('Manage your Home and Billing addresses.') }} -

    -
    - - - @csrf - @method('patch') - - {{-- home address --}} -
    -

    {{ __('Home Address') }}

    - -
    - - - -
    - -
    - - - -
    - -
    - - - -
    - -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    - -
    -
    - - - -
    -
    - - - -
    -
    -
    - - {{-- billing address --}} -
    -

    {{ __('Billing Address') }}

    - -
    - - - -
    - -
    - - - -
    - -
    - - - -
    - -
    -
    - - - -
    -
    - - - -
    -
    - - - -
    -
    - -
    -
    - - - -
    -
    - - - -
    -
    -
    - -
    - {{ __('Save Addresses') }} - - @if (session('status') === 'addresses-updated') -

    {{ __('Saved.') }}

    - @endif -
    - -
    diff --git a/resources/views/profile/partials/delete-user-form.blade.php b/resources/views/profile/partials/delete-user-form.blade.php deleted file mode 100644 index edeeb4a..0000000 --- a/resources/views/profile/partials/delete-user-form.blade.php +++ /dev/null @@ -1,55 +0,0 @@ -
    -
    -

    - {{ __('Delete Account') }} -

    - -

    - {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }} -

    -
    - - {{ __('Delete Account') }} - - -
    - @csrf - @method('delete') - -

    - {{ __('Are you sure you want to delete your account?') }} -

    - -

    - {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} -

    - -
    - - - - - -
    - -
    - - {{ __('Cancel') }} - - - - {{ __('Delete Account') }} - -
    -
    -
    -
    diff --git a/resources/views/profile/partials/update-password-form.blade.php b/resources/views/profile/partials/update-password-form.blade.php deleted file mode 100644 index eaca1ac..0000000 --- a/resources/views/profile/partials/update-password-form.blade.php +++ /dev/null @@ -1,48 +0,0 @@ -
    -
    -

    - {{ __('Update Password') }} -

    - -

    - {{ __('Ensure your account is using a long, random password to stay secure.') }} -

    -
    - -
    - @csrf - @method('put') - -
    - - - -
    - -
    - - - -
    - -
    - - - -
    - -
    - {{ __('Save') }} - - @if (session('status') === 'password-updated') -

    {{ __('Saved.') }}

    - @endif -
    -
    -
    diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php deleted file mode 100644 index 6bf01e2..0000000 --- a/resources/views/profile/partials/update-profile-information-form.blade.php +++ /dev/null @@ -1,64 +0,0 @@ -
    -
    -

    - {{ __('Profile Information') }} -

    - -

    - {{ __("Update your account's profile information and email address.") }} -

    -
    - -
    - @csrf -
    - -
    - @csrf - @method('patch') - -
    - - - -
    - -
    - - - - - @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) -
    -

    - {{ __('Your email address is unverified.') }} - - -

    - - @if (session('status') === 'verification-link-sent') -

    - {{ __('A new verification link has been sent to your email address.') }} -

    - @endif -
    - @endif -
    - -
    - {{ __('Save') }} - - @if (session('status') === 'profile-updated') -

    {{ __('Saved.') }}

    - @endif -
    -
    -
    diff --git a/routes/web.php b/routes/web.php index 76cea56..dbcb7a8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,7 @@ group(function () { - /* user profile */ - Route::get('/profile/view', [ProfileController::class, 'index'])->name('profile.index'); - Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); - Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); - Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); - Route::patch('/profile/addresses', [ProfileController::class, 'saveAddresses'])->name('profile.addresses.save'); + /* user account */ + Route::middleware(['web', 'auth']) + ->prefix('account') + ->name('account.') + ->group(function () { + + // landing: send them to the first pane + Route::get('/', [AccountController::class, 'index'])->name('index'); + + // panes + Route::get('info', [AccountController::class, 'infoForm'])->name('info'); + Route::patch('info', [AccountController::class, 'infoStore'])->name('info.store'); + + Route::get('addresses', [AccountController::class, 'addressesForm'])->name('addresses'); + Route::patch('addresses', [AccountController::class, 'addressesStore'])->name('addresses.store'); + + Route::get('password', [AccountController::class, 'passwordForm'])->name('password'); + Route::patch('password', [AccountController::class, 'passwordStore'])->name('password.store'); + + Route::get('delete', [AccountController::class, 'deleteForm'])->name('delete'); + Route::get('delete/confirm', [AccountController::class, 'deleteConfirm'])->name('delete.confirm'); + Route::delete('/', [AccountController::class, 'destroy'])->name('destroy'); + }); /* calendar core */ Route::middleware('auth')->group(function () { @@ -64,6 +81,10 @@ 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');