Refactors account and calendar settings pages; installs language localization files; new traits for HTMX and toast handling; solid icon variants added; modals improved

This commit is contained in:
Andrew Gioia 2026-01-21 15:59:17 -05:00
parent b7282865c8
commit 859d03ae30
Signed by: andrew
GPG Key ID: FC09694A000800C8
87 changed files with 2224 additions and 679 deletions

View File

@ -0,0 +1,317 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\AccountUpdateRequest;
use App\Models\Location;
use App\Models\UserAddress;
use App\Services\Location\Geocoder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class AccountController extends Controller
{
/**
* landing page
*/
public function index(): RedirectResponse
{
return Redirect::route('account.info');
}
/**
* info pane (name, email, timezone, etc.)
*/
public function infoForm(Request $request)
{
$user = $request->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,
]);
}
}

View File

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

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Redirect;
trait FlashesToasts
{
protected function toast(Request $request, string $message, string $type = 'success')
{
if ($request->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 ]);
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ViewErrorBag;
trait ValidatesHtmx
{
protected function validateHtmx(Request $request, array $rules, array $options = []): array
{
$bag = $options['bag'] ?? 'default';
$validator = Validator::make(
$request->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()
);
}
}

View File

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

View File

@ -1,172 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use App\Models\UserAddress;
use App\Models\Location;
use App\Services\Location\Geocoder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* profile index test
*/
public function index()
{
return $this->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,
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetUserLocale
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if ($user) {
$locale = $user->getSetting('app.language');
if (is_string($locale) && $locale !== '') {
app()->setLocale($locale);
}
}
return $next($request);
}
}

View File

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

View File

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

View File

@ -30,6 +30,7 @@ class UserAddress extends Model
// simple casts
protected $casts = [
'is_primary' => 'boolean',
'is_billing' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserSetting extends Model
{
protected $table = 'user_settings';
protected $fillable = [
'user_id',
'key',
'value',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\SetUserLocale;
use App\Jobs\GeocodeEventLocations;
use App\Jobs\SyncSubscriptionsDispatcher;
use Illuminate\Console\Scheduling\Schedule;
@ -16,7 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
//
// set language/locale
$middleware->web(append: [
SetUserLocale::class,
]);
})
->withSchedule(function (Schedule $schedule) {

View File

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

View File

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

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_settings', function (Blueprint $table) {
$table->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');
}
};

View File

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

64
lang/en/account.php Normal file
View File

@ -0,0 +1,64 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Account Language Lines
|--------------------------------------------------------------------------
|
| Account, profile, and user settings language lines.
|
*/
// addresses
'address' => [
'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&mdash;we\'re not setting <code>is_deleted = 1</code>, 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',
];

20
lang/en/auth.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'These credentials do not match our records.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
];

28
lang/en/calendar.php Normal file
View File

@ -0,0 +1,28 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Calendar Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used throughout the calendar app,
| including calendar settings and events.
|
*/
// settings
'settings' => [
'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',
],
];

26
lang/en/common.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Common words and phrases
|--------------------------------------------------------------------------
|
| Generic words used throughout the app in more than one location.
|
*/
'address' => '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',
];

19
lang/en/pagination.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Previous',
'next' => 'Next &raquo;',
];

22
lang/en/passwords.php Normal file
View File

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => '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.",
];

198
lang/en/validation.php Normal file
View File

@ -0,0 +1,198 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => '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' => [],
];

11
lang/it/common.php Normal file
View File

@ -0,0 +1,11 @@
<?php
return [
'calendar' => 'Calendario',
'calendars' => 'Calendari',
'event' => 'Evento',
'events' => 'Eventi',
'settings' => 'Impostazioni',
];

View File

@ -13,6 +13,7 @@
@import './lib/input.css';
@import './lib/mini.css';
@import './lib/modal.css';
@import './lib/toast.css';
/** plugins */
@plugin '@tailwindcss/forms';

View File

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

View File

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

View File

@ -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 <p> and uses gap for spacing */
@apply space-y-3;
p {
@apply text-lg;
}
}
.error {
@apply text-base text-red-600 font-semibold;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="13" r="9"/><path d="M14.35 4.65 16.3 2.7a2.41 2.41 0 0 1 3.4 0l1.6 1.6a2.4 2.4 0 0 1 0 3.4l-1.95 1.95"/><path d="m22 2-1.5 1.5"/></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round" viewBox="0 0 24 24"><circle cx="11" cy="13" r="9" style="stroke:#000;stroke-width:2px"/><path d="M14.35 4.65 16.3 2.7a2.422 2.422 0 0 1 3.4 0l1.6 1.6a2.399 2.399 0 0 1 0 3.4l-1.95 1.95M22 2l-1.5 1.5" style="fill:none;fill-rule:nonzero;stroke:#000;stroke-width:2px"/></svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M7,5l-2,0c-0.549,0 -1,0.451 -1,1l0,3l3.005,0c0.549,0 0.995,0.446 0.995,0.995l0,6.505c0,1.844 0.667,3.533 1.772,4.839c0.169,0.176 0.272,0.415 0.272,0.679c0,0.542 -0.44,0.982 -0.982,0.982l-4.062,-0c-1.646,0 -3,-1.354 -3,-3l0,-14c0,-1.646 1.354,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l2,0c1.646,0 3,1.354 3,3l0,2.549c0,0.549 -0.446,0.995 -0.995,0.995c-0.545,0 -0.989,-0.44 -0.996,-0.983l-0,-0.012l-0.009,-2.549c0,-0.549 -0.451,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l-0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm3,9l-0,-4c0,-0.552 0.448,-1 1,-1c0.552,0 1,0.448 1,1l-0,1.528c1.098,-0.982 2.522,-1.528 4,-1.528c2.332,0 4.461,1.359 5.442,3.474c0.232,0.501 0.015,1.096 -0.486,1.328c-0.501,0.232 -1.096,0.015 -1.328,-0.486c-0.654,-1.41 -2.074,-2.316 -3.628,-2.316c-0.976,0 -1.917,0.357 -2.646,1l1.646,-0c0.552,-0 1,0.448 1,1c0,0.552 -0.448,1 -1,1l-4,-0c-0.258,0 -0.505,-0.099 -0.691,-0.277c-0.198,-0.189 -0.309,-0.449 -0.309,-0.723Zm10,6.472c-1.098,0.982 -2.522,1.528 -4,1.528c-2.332,0 -4.461,-1.359 -5.442,-3.474c-0.232,-0.501 -0.015,-1.096 0.486,-1.328c0.501,-0.232 1.096,-0.015 1.328,0.486c0.654,1.41 2.074,2.316 3.628,2.316c0.976,0 1.917,-0.357 2.646,-1l-1.646,0c-0.552,0 -1,-0.448 -1,-1c-0,-0.552 0.448,-1 1,-1l4,0c0.258,-0 0.505,0.099 0.691,0.277c0.198,0.189 0.309,0.449 0.309,0.723l0,4c0,0.552 -0.448,1 -1,1c-0.552,0 -1,-0.448 -1,-1l0,-1.528Z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M17,3l2,0c1.656,0 3,1.344 3,3l0,14c0,1.656 -1.344,3 -3,3l-14,0c-1.656,0 -3,-1.344 -3,-3l0,-14c0,-1.656 1.344,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1Zm-10,2l-2,0c-0.552,0 -1,0.448 -1,1l0,3l16,0l0,-3c0,-0.552 -0.448,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm9.01,11.981c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-3.99,0c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm3.99,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-8,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M11.546,22.991c-5.527,-0.225 -10.008,-4.536 -10.501,-9.991l6.012,0c0.221,3.467 1.59,6.872 4.108,9.572l0.38,0.418Zm1.144,-0.267l0.035,-0.034c2.589,-2.718 3.995,-6.172 4.219,-9.69l6.012,0c-0.493,5.457 -4.978,9.77 -10.509,9.991l0.243,-0.267Zm-0.27,-21.716c5.543,0.208 10.042,4.526 10.535,9.992l-6.012,-0c-0.224,-3.518 -1.63,-6.971 -4.219,-9.69l-0.034,-0.034l-0.026,-0.024l-0.244,-0.244Zm-1.144,0.302c-2.589,2.718 -3.995,6.172 -4.219,9.69l-6.012,-0c0.493,-5.456 4.976,-9.768 10.505,-9.991l-0.274,0.301Zm0.724,2.205c1.766,2.187 2.746,4.812 2.942,7.485l-5.883,-0c0.195,-2.673 1.176,-5.298 2.942,-7.485Zm0,16.97c-1.766,-2.187 -2.746,-4.812 -2.942,-7.485l5.883,-0c-0.195,2.673 -1.176,5.298 -2.942,7.485Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M12,1c6.071,0 11,4.929 11,11c0,6.071 -4.929,11 -11,11c-6.071,0 -11,-4.929 -11,-11c0,-6.071 4.929,-11 11,-11Zm1.25,15l0,-4c-0,-0.69 -0.56,-1.25 -1.25,-1.25c-0.69,0 -1.25,0.56 -1.25,1.25l0,4c-0,0.69 0.56,1.25 1.25,1.25c0.69,0 1.25,-0.56 1.25,-1.25Zm-1.252,-9.252c-0.691,0 -1.252,0.561 -1.252,1.252c0,0.691 0.561,1.252 1.252,1.252c0.691,0 1.252,-0.561 1.252,-1.252c0,-0.691 -0.561,-1.252 -1.252,-1.252Z"/></svg>

After

Width:  |  Height:  |  Size: 857 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M11.172,17l-0.172,0l0,1c0,1.097 -0.903,2 -2,2l-1,0l0,1c0,1.097 -0.903,2 -2,2l-3,0c-1.097,0 -2,-0.903 -2,-2l0,-2.172c0,-0.795 0.317,-1.559 0.879,-2.121l6.392,-6.392c-0.154,-0.605 -0.233,-1.228 -0.233,-1.853c0,-4.114 3.386,-7.5 7.5,-7.5c4.114,0 7.5,3.386 7.5,7.5c0,4.114 -3.386,7.5 -7.5,7.5c-0.626,0 -1.249,-0.078 -1.853,-0.233l-0.392,0.392c-0.562,0.562 -1.326,0.879 -2.121,0.879Zm5.311,-11.526c-1.109,0 -2.009,0.9 -2.009,2.009c0,1.109 0.9,2.009 2.009,2.009c1.109,0 2.009,-0.9 2.009,-2.009c0,-1.109 -0.9,-2.009 -2.009,-2.009Z"/></svg>

After

Width:  |  Height:  |  Size: 981 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;"><path d="M20,10c0,4.993 -5.539,10.193 -7.399,11.799c-0.355,0.267 -0.847,0.267 -1.202,0c-1.86,-1.606 -7.399,-6.806 -7.399,-11.799c-0,-4.389 3.611,-8 8,-8c4.389,-0 8,3.611 8,8Zm-8,-3c-1.656,0 -3,1.344 -3,3c0,1.656 1.344,3 3,3c1.656,0 3,-1.344 3,-3c0,-1.656 -1.344,-3 -3,-3Z" style="stroke:#000;stroke-width:2px;"/></svg>

After

Width:  |  Height:  |  Size: 759 B

View File

@ -9,13 +9,13 @@
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-profile-information-form')
@include('account.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.addresses-form', [
@include('account.partials.addresses-form', [
'home' => $home ?? null,
'billing' => $billing ?? null,
])
@ -24,13 +24,13 @@
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-password-form')
@include('account.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.delete-user-form')
@include('account.partials.delete-user-form')
</div>
</div>
</div>

View File

@ -1,10 +1,10 @@
<x-app-layout id="profile" class="readable">
<x-app-layout id="account" class="readable settings">
<x-slot name="aside">
<h1>
{{ __('Profile') }}
{{ __('account.title') }}
</h1>
<x-profile.settings-menu />
<x-menu.account-settings />
</x-slot>
<x-slot name="header">
@ -12,7 +12,7 @@
@isset($data['title'])
{{ $data['title'] }}
@else
{{ __('Settings') }}
{{ __('common.settings') }}
@endisset
</h2>
</x-slot>

View File

@ -0,0 +1,26 @@
<x-modal.content>
<x-modal.title>
{{ __('account.settings.delete-confirm.title') }}
</x-modal.title>
<x-modal.body>
<form id="delete-account-form" method="post" action="{{ route('account.destroy') }}">
@csrf
@method('delete')
<p>
{{ __('account.settings.delete-confirm.subtitle') }}
</p>
<div>
<x-input.text-label label="{{ __('Password') }}" id="password" name="password" type="password" placeholder="{{ __('Password') }}" />
<x-input.error :messages="$errors->userDeletion->get('password')" />
</div>
</form>
</x-modal.body>
<x-modal.footer>
<x-button variant="secondary" onclick="this.closest('dialog')?.close()">
{{ __('common.cancel') }}
</x-button>
<x-button variant="primary danger" type="submit" form="delete-account-form">
{{ __('account.delete-confirm') }}
</x-button>
</x-modal.footer>
</x-modal.content>

View File

@ -0,0 +1,124 @@
<div class="description">
<p>
{{ $sub }}
</p>
</div>
<form method="post" action="{{ route('account.addresses.store') }}" class="settings">
@csrf
@method('patch')
{{-- home address --}}
<h3>{{ __('account.address.home') }}</h3>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="home_label" :value="__('account.address.label')" />
<x-input.text id="home_label" name="home[label]" type="text" placeholder="Home" :value="old('home.label', $home->label ?? '')" />
<x-input.error :messages="$errors->get('home.label')" />
</div>
<div class="input-cell">
<x-input.label for="home_country" :value="__('account.address.country')" />
<x-input.text id="home_country" name="home[country]" type="text" :value="old('home.country', $home->country ?? '')" />
<x-input.error :messages="$errors->get('home.country')" />
</div>
</div>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="home_line1" :value="__('account.address.line1')" />
<x-input.text id="home_line1" name="home[line1]" type="text" :value="old('home.line1', $home->line1 ?? '')" />
<x-input.error :messages="$errors->get('home.line2')" />
</div>
<div class="input-cell">
<x-input.label for="home_line2" :value="__('account.address.line2')" />
<x-input.text id="home_line2" name="home[line2]" type="text" :value="old('home.line2', $home->line2 ?? '')" />
<x-input.error :messages="$errors->get('home.line2')" />
</div>
</div>
<div class="input-row input-row--2-1-1">
<div class="input-cell">
<x-input.label for="home_city" :value="__('account.address.city')" />
<x-input.text id="home_city" name="home[city]" type="text" :value="old('home.city', $home->city ?? '')" />
<x-input.error :messages="$errors->get('home.city')" />
</div>
<div class="input-cell">
<x-input.label for="home_state" :value="__('account.address.state')" />
<x-input.select-state id="home_state" name="home[state]" :selected="$home->state ?? ''" autocomplete="address-level1" />
<x-input.error :messages="$errors->get('home.state')" />
</div>
<div class="input-cell">
<x-input.label for="home_postal" :value="__('account.address.zip')" />
<x-input.text id="home_postal" name="home[postal]" type="text" :value="old('home.postal', $home->postal ?? '')" />
<x-input.error :messages="$errors->get('home.postal')" />
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.radio-label :label="__('account.billing.home')" id="billing_home" value="home" name="billing" :checked="old('billing', $billing) === 'home'" />
</div>
</div>
{{-- work address --}}
<h3 class="mt-12">{{ __('account.address.work') }}</h3>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="work_label" :value="__('account.address.label')" />
<x-input.text id="work_label" name="work[label]" type="text" placeholder="Work" :value="old('work.label', $work->label ?? '')" />
<x-input.error :messages="$errors->get('work.label')" />
</div>
<div class="input-cell">
<x-input.label for="work_country" :value="__('account.address.country')" />
<x-input.text id="work_country" name="work[country]" type="text" :value="old('work.country', $work->country ?? '')" />
<x-input.error :messages="$errors->get('work.country')" />
</div>
</div>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="work_line1" :value="__('account.address.line1')" />
<x-input.text id="work_line1" name="work[line1]" type="text" :value="old('work.line1', $work->line1 ?? '')" />
<x-input.error :messages="$errors->get('work.line2')" />
</div>
<div class="input-cell">
<x-input.label for="work_line2" :value="__('account.address.line2')" />
<x-input.text id="work_line2" name="work[line2]" type="text" :value="old('work.line2', $work->line2 ?? '')" />
<x-input.error :messages="$errors->get('work.line2')" />
</div>
</div>
<div class="input-row input-row--2-1-1">
<div class="input-cell">
<x-input.label for="work_city" :value="__('account.address.city')" />
<x-input.text id="work_city" name="work[city]" type="text" :value="old('work.city', $work->city ?? '')" />
<x-input.error :messages="$errors->get('work.city')" />
</div>
<div class="input-cell">
<x-input.label for="work_state" :value="__('account.address.state')" />
<x-input.select-state id="work_state" name="work[state]" :selected="$work->state ?? ''" autocomplete="address-level1" />
<x-input.error :messages="$errors->get('work.state')" />
</div>
<div class="input-cell">
<x-input.label for="work_postal" :value="__('account.address.zip')" />
<x-input.text id="work_postal" name="work[postal]" type="text" :value="old('work.postal', $work->postal ?? '')" />
<x-input.error :messages="$errors->get('work.postal')" />
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.radio-label :label="__('account.billing.work')" id="billing_work" value="work" name="billing" :checked="old('billing', $billing) === 'work'" />
</div>
</div>
{{-- save --}}
<div class="input-row input-row--actions input-row--start sticky-bottom">
<x-button type="submit" variant="primary">{{ __('common.save_changes') }}</x-button>
<x-button type="anchor" variant="secondary" href="{{ route('account.addresses') }}">{{ __('common.cancel') }}</x-button>
@if (session('status') === 'addresses-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>

View File

@ -0,0 +1,21 @@
<div class="description">
@isset($sub)
<p>{{ $sub }}</p>
@endisset
</div>
<form method="post" action="{{ route('account.destroy') }}" class="settings">
@csrf
@method('delete')
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.text-label label="{{ __('Password') }}" id="password" name="password" type="password" placeholder="{{ __('Password') }}" />
<x-input.error :messages="$errors->userDeletion->get('password')" />
</div>
</div>
<div class="input-row input-row--actions input-row--start">
<x-button type="submit" variant="primary danger">{{ __('account.delete-confirm') }}</x-button>
<x-button type="anchor" variant="tertiary" href="{{ route('account.info') }}">{{ __('common.cancel_funny') }}</x-button>
</div>
</form>

View File

@ -0,0 +1,31 @@
<div class="description">
<p>
{{ $sub }}
</p>
</div>
<div class="pane">
<h3>
{{ __('account.delete-your') }}
</h3>
<p>
{!! __('account.settings.delete.explanation') !!}
</p>
<x-button
type="anchor"
variant="primary danger"
class="mt-4"
href="{{ route('account.delete.confirm') }}"
hx-get="{{ route('account.delete.confirm') }}"
hx-target="#modal"
hx-swap="innerHTML"
hx-push-url="false">
{{ __('Delete my account...') }}
</x-button>
@error('password', 'userDeletion')
<div class="error">{{ $message }}</div>
@enderror
</div>

View File

@ -0,0 +1,70 @@
<div class="description">
<p>
{{ __("account.settings.information.subtitle") }}
</p>
</div>
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
@csrf
</form>
<form method="post" action="{{ route('account.info.store') }}" class="settings">
@csrf
@method('patch')
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.text-label :label="__('account.first_name')" id="firstname" name="firstname" type="text" :value="old('firstname', $user->firstname)" required autofocus autocomplete="name" />
<x-input.error :messages="$errors->get('firstname')" />
</div>
<div class="input-cell">
<x-input.text-label :label="__('account.last_name')" id="lastname" name="lastname" type="text" :value="old('lastname', $user->lastname)" required autofocus autocomplete="name" />
<x-input.error :messages="$errors->get('lastname')" />
</div>
</div>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.text-label :label="__('account.email_address')" id="email" name="email" type="text" :value="old('email', $user->email)" required autofocus autocomplete="username" />
<x-input.error class="mt-2" :messages="$errors->get('email')" />
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
<div>
<p class="text-sm mt-2 text-gray-800">
{{ __('Your email address is unverified.') }}
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@endif
</div>
<div class="input-cell">
<x-input.label for="phone" :value="__('account.phone')" />
<x-input.text id="phone" name="phone" type="text" :value="old('phone', $user->phone ?? '')" />
<x-input.error :messages="$errors->get('phone')" />
</div>
</div>
<div class="input-row input-row--actions input-row--start sticky-bottom">
<x-button variant="primary" type="submit">{{ __('common.save_changes') }}</x-button>
<x-button type="anchor" variant="tertiary" href="{{ route('account.info') }}">{{ __('common.cancel') }}</x-button>
@if (session('status') === 'account-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>

View File

@ -0,0 +1,46 @@
<div class="description">
<p>
{{ __('account.settings.password.subtitle') }}
</p>
</div>
<form method="post" action="{{ route('password.update') }}" class="settings">
@csrf
@method('put')
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text-label :label="__('Current password')" id="current_password" name="current_password" type="password" autocomplete="current-password" />
<x-input.error :messages="$errors->updatePassword->get('current_password')" />
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text-label :label="__('New password')" id="new_password" name="password" type="password" autocomplete="new-password" />
<x-input.error :messages="$errors->updatePassword->get('password')" />
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text-label :label="__('Confirm password')" id="password_confirmation" name="password_confirmation" type="password" autocomplete="new-password" />
<x-input.error :messages="$errors->updatePassword->get('password_confirmation')" />
</div>
</div>
<div class="input-row input-row--actions input-row--start">
<x-button variant="primary">{{ __('common.save_changes') }}</x-button>
<x-button type="anchor" variant="tertiary" href="{{ route('account.info') }}">{{ __('common.cancel') }}</x-button>
@if (session('status') === 'password-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>

View File

@ -15,7 +15,7 @@
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">

View File

@ -13,7 +13,7 @@
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">

View File

@ -7,21 +7,21 @@
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-input.label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-input.label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Remember Me -->
@ -34,7 +34,7 @@
<div class="flex items-center justify-between mt-4 gap-4">
@if (Route::has('password.request'))
<a href="{{ route('password.request') }}" href="text">
<a href="{{ route('password.request') }}" class="text">
{{ __('Forgot your password?') }}
</a>
@endif

View File

@ -6,14 +6,14 @@
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
<x-input.error :messages="$errors->get('name')" class="mt-2" />
</div>
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
@ -25,7 +25,7 @@
name="password"
required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
@ -36,7 +36,7 @@
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
<x-input.error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">

View File

@ -9,14 +9,14 @@
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
@ -27,7 +27,7 @@
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
<x-input.error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">

View File

@ -11,7 +11,7 @@
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
:value="old('name', $instance?->displayname ?? '')" required autofocus />
<x-input-error class="mt-2" :messages="$errors->get('name')" />
<x-input.error class="mt-2" :messages="$errors->get('name')" />
</div>
{{-- Description --}}
@ -19,7 +19,7 @@
<x-input-label for="description" :value="__('Description')" />
<textarea id="description" name="description" rows="3"
class="mt-1 block w-full rounded-md shadow-xs border-gray-300 focus:border-indigo-300 focus:ring-3">{{ old('description', $instance?->description ?? '') }}</textarea>
<x-input-error class="mt-2" :messages="$errors->get('description')" />
<x-input.error class="mt-2" :messages="$errors->get('description')" />
</div>
{{-- Timezone --}}
@ -34,7 +34,7 @@
</option>
@endforeach
</select>
<x-input-error class="mt-2" :messages="$errors->get('timezone')" />
<x-input.error class="mt-2" :messages="$errors->get('timezone')" />
</div>
{{-- COLOR --}}

View File

@ -2,7 +2,7 @@
<x-slot name="aside">
<h1>
{{ __('Calendar') }}
{{ __('common.calendar') }}
</h1>
<div class="content aside-inset">
<form id="calendar-toggles"

View File

@ -2,9 +2,9 @@
<x-slot name="aside">
<h1>
{{ __('Calendar') }}
{{ __('common.calendar') }}
</h1>
<x-calendar.settings-menu />
<x-menu.calendar-settings />
</x-slot>
<x-slot name="header">
@ -12,7 +12,7 @@
@isset($data['title'])
{{ $data['title'] }}
@else
{{ __('Settings') }}
{{ __('common.settings') }}
@endisset
</h2>
</x-slot>

View File

@ -0,0 +1,78 @@
@php
$values = $data['values'] ?? [];
$options = $data['options'] ?? [];
@endphp
<div class="description">
<p>
{{ __('calendar.settings.language_region.subtitle') }}
</p>
</div>
<form method="post"
action="{{ route('calendar.settings.language.store') }}"
class="form-grid-1 mt-8">
@csrf
<div>
<label for="language">{{ __('Language') }}</label>
<select id="language" name="language">
@foreach(($options['languages'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected(old('language', $values['language'] ?? '') === $value)>
{{ $label }}
</option>
@endforeach
</select>
@error('language')
<div class="text-danger">{{ $message }}</div>
@enderror
</div>
<div>
<label for="region">{{ __('Region') }}</label>
<select id="region" name="region">
@foreach(($options['regions'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected(old('region', $values['region'] ?? '') === $value)>
{{ $label }}
</option>
@endforeach
</select>
@error('region')
<div class="text-danger">{{ $message }}</div>
@enderror
</div>
<div>
<label for="date_format">{{ __('Date format') }}</label>
<select id="date_format" name="date_format">
@foreach(($options['date_formats'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected(old('date_format', $values['date_format'] ?? '') === $value)>
{{ $label }}
</option>
@endforeach
</select>
@error('date_format')
<div class="text-danger">{{ $message }}</div>
@enderror
</div>
<div>
<label for="time_format">{{ __('Time format') }}</label>
<select id="time_format" name="time_format">
@foreach(($options['time_formats'] ?? []) as $value => $label)
<option value="{{ $value }}" @selected(old('time_format', $values['time_format'] ?? '') === $value)>
{{ $label }}
</option>
@endforeach
</select>
@error('time_format')
<div class="text-danger">{{ $message }}</div>
@enderror
</div>
<div class="flex gap-4">
<x-button variant="primary" type="submit">{{ __('Save') }}</x-button>
<a href="{{ route('calendar.index') }}"
class="button button--secondary">{{ __('Cancel and go back') }}</a>
</div>
</form>

View File

@ -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
<li class="app-button">
<a {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
<a {{ $attributes->merge(['class' => $classes]) }} aria-label="{{ $label }}">
@if ($iconComponent)
<x-dynamic-component :component="$iconComponent" class="w-7 h-7" />
@endif
</a>
</li>

View File

@ -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
<a {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
@if ($iconComponent)
<x-dynamic-component :component="$iconComponent" width="20" />
@endif
@if (!is_null($label))
<span>{{ $label }}</span>
@else
{{ $slot }}
@endif
</a>

View File

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

View File

@ -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 }}
</{{ $element }}>
@if($isAnchor)
<a href="{{ $href }}"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</a>
@else
<button type="{{ $buttonType }}"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</button>
@endif

View File

@ -1,28 +0,0 @@
<div class="drawers aside-inset">
<details open>
<summary>{{ __('General settings') }}</summary>
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('calendar.settings.subscribe') }}"
:active="request()->routeIs('calendar.settings')">
<x-icon-globe width="20" />
<span>Language and region</span>
</x-app.pagelink>
</li>
</menu>
</details>
<details open>
<summary>{{ __('Add a calendar') }}</summary>
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('calendar.settings.subscribe') }}"
:active="request()->routeIs('calendar.settings.subscribe')">
<x-icon-calendar-sync width="20" />
<span>Subscribe to a calendar</span>
</x-app.pagelink>
</li>
</menu>
</details>
</div>

View File

@ -1,9 +1,9 @@
@props(['messages'])
@if ($messages)
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 space-y-1']) }}>
<ul {{ $attributes->merge(['class' => 'input-errors']) }}>
@foreach ((array) $messages as $message)
<li>{{ $message }}</li>
<li class="error">{{ $message }}</li>
@endforeach
</ul>
@endif

View File

@ -0,0 +1,5 @@
@props(['value'])
<label {{ $attributes->merge(['class' => 'label']) }}>
{{ $value ?? $slot }}
</label>

View File

@ -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
])
<label class="radio-label {{ $labelclass }}">
<x-input.radio
:id="$id"
:name="$name"
:value="$value"
:class="$checkclass"
:style="$style"
:checked="$checked"
{{ $attributes->except(['id', 'class']) }} />
<span>{{ $label }}</span>
</label>

View File

@ -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
])
<input type="radio"
@if($id) id="{{ $id }}" @endif
name="{{ $name }}"
value="{{ $value }}"
{{ $attributes->class($class) }}
@if($style !== '') style="{{ $style }}" @endif
@checked($checked) />

View File

@ -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
<x-input.select
:id="$id"
:name="$name"
:options="$states"
:selected="$selected"
:placeholder="$placeholder"
:required="$required"
:autocomplete="$autocomplete"
{{ $attributes }} />

View File

@ -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 '<option value="'.e($value).'"'.$selectedAttr.($attrString ? ' '.$attrString : '').'>'.e($label).'</option>';
};
@endphp
<select id="{{ $id }}" name="{{ $name }}" autocomplete="{{ $autocomplete }}"
{{ $attributes->merge(['class' => 'select '.$class]) }}
@if($style !== '') style="{{ $style }}" @endif
@required($required)>
@if($placeholder !== '')
<option value="" disabled @selected((string) $current === '')>
{{ $placeholder }}
</option>
@endif
@foreach($options as $key => $opt)
{{-- optgroup: 'Group label' => [ ...options... ] --}}
@if(is_array($opt) && !array_key_exists('value', $opt) && !array_key_exists('label', $opt))
<optgroup label="{{ $key }}">
@foreach($opt as $value => $label)
{!! $renderOption($value, $label) !!}
@endforeach
</optgroup>
{{-- rich option: [ 'value' => 'en', 'label' => 'English', 'attrs' => ['data-x' => 'y', 'disabled' => true] ] --}}
@elseif(is_array($opt) && array_key_exists('value', $opt))
{!! $renderOption($opt['value'], $opt['label'] ?? $opt['value'], $opt['attrs'] ?? []) !!}
{{-- simple options: [ 'en' => 'English', ... ] --}}
@else
{!! $renderOption($key, $opt) !!}
@endif
@endforeach
</select>

View File

@ -12,6 +12,6 @@
name="{{ $name }}"
value="{{ $value }}"
placeholder="{{ $placeholder }}"
{{ $attributes->class($class) }}
{{ $attributes->merge(['class' => 'text']) }}
@if($style !== '') style="{{ $style }}" @endif
@required($required) />

View File

@ -0,0 +1,39 @@
<div class="drawers aside-inset">
<details open>
<summary>{{ __('General settings') }}</summary>
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('account.info') }}"
:active="request()->routeIs('account.info')"
:label="__('account.settings.information.title')"
icon="info-circle"
/>
</li>
<li>
<x-app.pagelink
href="{{ route('account.password') }}"
:active="request()->routeIs('account.password')"
:label="__('common.password')"
icon="key"
/>
</li>
<li>
<x-app.pagelink
href="{{ route('account.addresses') }}"
:active="request()->routeIs('account.addresses')"
:label="__('common.addresses')"
icon="pin"
/>
</li>
<li>
<x-app.pagelink
href="{{ route('account.delete') }}"
:active="request()->routeIs('account.delete', 'account.delete.*')"
:label="__('account.delete')"
icon="bomb"
/>
</li>
</menu>
</details>
</div>

View File

@ -4,11 +4,11 @@
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('calendar.settings.subscribe') }}"
:active="request()->routeIs('calendar.settings')">
<x-icon-globe width="20" />
<span>Language and region</span>
</x-app.pagelink>
href="{{ route('calendar.settings.language') }}"
:active="request()->routeIs('calendar.settings.language')"
:label="__('calendar.settings.language_region.title')"
icon="globe"
/>
</li>
</menu>
</details>
@ -18,10 +18,10 @@
<li>
<x-app.pagelink
href="{{ route('calendar.settings.subscribe') }}"
:active="request()->routeIs('calendar.settings.subscribe')">
<x-icon-calendar-sync width="20" />
<span>Subscribe to a calendar</span>
</x-app.pagelink>
:active="request()->routeIs('calendar.settings.subscribe')"
:label="__('calendar.settings.subscribe.title')"
icon="calendar-sync"
/>
</li>
</menu>
</details>

View File

@ -1,4 +1,4 @@
<form method="dialog">
<form method="dialog" class="close-modal">
<x-button.icon type="submit" label="Close the modal" autofocus>
<x-icon-x />
</x-button.icon>

View File

@ -0,0 +1,3 @@
<footer>
{{ $slot }}
</footer>

View File

@ -1,7 +1,10 @@
<dialog>
<dialog
hx-on:click="if(event.target === this) this.close()"
hx-on:close="document.getElementById('modal').innerHTML=''"
>
<div id="modal"
hx-target="this"
hx-on::after-swap="document.querySelector('dialog').showModal();"
hx-on::after-swap="this.closest('dialog')?.showModal()"
hx-swap="innerHTML">
</div>
</dialog>

View File

@ -2,8 +2,8 @@
'border' => false,
])
<header @class(['px-8 py-6', 'header--with-border' => $border])>
<h2 class="text-lg font-semibold">
<header @class(['header--with-border' => $border])>
<h2>
{{ $slot }}
</h2>
</header>

View File

@ -31,7 +31,7 @@
<x-input-label for="title" :value="__('Title')" />
<x-text-input id="title" name="title" type="text" class="mt-1 block w-full"
:value="old('title', $event->meta?->title ?? '')" required autofocus />
<x-input-error class="mt-2" :messages="$errors->get('title')" />
<x-input.error class="mt-2" :messages="$errors->get('title')" />
</div>
{{-- Description --}}
@ -39,7 +39,7 @@
<x-input-label for="description" :value="__('Description')" />
<textarea id="description" name="description" rows="3"
class="mt-1 block w-full rounded-md shadow-xs border-gray-300 focus:border-indigo-300 focus:ring-3">{{ old('description', $event->meta?->description ?? '') }}</textarea>
<x-input-error class="mt-2" :messages="$errors->get('description')" />
<x-input.error class="mt-2" :messages="$errors->get('description')" />
</div>
{{-- Location --}}
@ -70,7 +70,7 @@
<input type="hidden" id="loc_lat" name="loc_lat" />
<input type="hidden" id="loc_lon" name="loc_lon" />
<x-input-error class="mt-2" :messages="$errors->get('location')" />
<x-input.error class="mt-2" :messages="$errors->get('location')" />
</div>
{{-- Start / End --}}
@ -82,7 +82,7 @@
class="mt-1 block w-full"
:value="old('start_at', $start)"
required />
<x-input-error class="mt-2" :messages="$errors->get('start_at')" />
<x-input.error class="mt-2" :messages="$errors->get('start_at')" />
</div>
<div>
@ -91,7 +91,7 @@
class="mt-1 block w-full"
:value="old('end_at', $end)"
required />
<x-input-error class="mt-2" :messages="$errors->get('end_at')" />
<x-input.error class="mt-2" :messages="$errors->get('end_at')" />
</div>
</div>

View File

@ -35,20 +35,18 @@
</main>
<!-- messages -->
<figure>
@if (session('toast'))
<figcaption
x-data="{ open: true }"
x-show="open"
x-init="setTimeout(() => open = false, 4000)"
class="fixed top-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg"
x-transition.opacity.duration.300ms
>
{{ session('toast') }}
</figcaption>
<!-- toasts -->
<dl class="toasts" role="status" aria-live="polite" aria-atomic="true">
@if (session()->has('toast'))
@php
$toast = session('toast');
$message = is_array($toast) ? ($toast['message'] ?? '') : $toast;
$type = is_array($toast) ? ($toast['type'] ?? 'success') : 'success';
@endphp
<dt class="{{ $type }}">{{ $type }}</dt>
<dd class="message">{{ $message }}</dd>
@endif
</figure>
</dl>
<!-- modal -->
<x-modal />

View File

@ -8,15 +8,21 @@
<!-- app nav -->
<menu>
<x-app.nav-button :href="route('dashboard')" :active="request()->routeIs('dashboard')">
<x-icon-home class="w-7 h-7" />
</x-app.nav-button>
<x-app.nav-button :href="route('calendar.index')" :active="request()->routeIs('calendar*')">
<x-icon-calendar class="w-7 h-7" />
</x-app.nav-button>
<x-app.nav-button :href="route('book.index')" :active="request()->routeIs('books*')">
<x-icon-book-user class="w-7 h-7" />
</x-app.nav-button>
<x-app.nav-button
:href="route('dashboard')"
:active="request()->routeIs('dashboard')"
icon="home"
/>
<x-app.nav-button
:href="route('calendar.index')"
:active="request()->routeIs('calendar*')"
icon="calendar"
/>
<x-app.nav-button
:href="route('book.index')"
:active="request()->routeIs('book.index')"
icon="book-user"
/>
<menu>
</section>
@ -25,7 +31,7 @@
<x-button.icon type="anchor" :href="route('settings')">
<x-icon-settings class="w-7 h-7" />
</x-button.icon>
<x-button.icon type="anchor" :href="route('profile.edit')">
<x-button.icon type="anchor" :href="route('account.index')">
<x-icon-user-circle class="w-7 h-7" />
</x-button.icon>
</div>

View File

@ -1,153 +0,0 @@
<section>
<header class="mb-4">
<h2 class="text-lg font-medium text-gray-900">
{{ __('Addresses') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Manage your Home and Billing addresses.') }}
</p>
</header>
<form method="post" action="{{ route('profile.addresses.save') }}" class="space-y-8">
@csrf
@method('patch')
{{-- home address --}}
<div class="space-y-4">
<h3 class="text-md font-semibold text-gray-800">{{ __('Home Address') }}</h3>
<div>
<x-input-label for="home_label" :value="__('Label')" />
<x-text-input id="home_label" name="home[label]" type="text" class="mt-1 block w-full"
:value="old('home.label', $home->label ?? 'Home')" />
<x-input-error class="mt-2" :messages="$errors->get('home.label')" />
</div>
<div>
<x-input-label for="home_line1" :value="__('Address line 1')" />
<x-text-input id="home_line1" name="home[line1]" type="text" class="mt-1 block w-full"
:value="old('home.line1', $home->line1 ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('home.line1')" />
</div>
<div>
<x-input-label for="home_line2" :value="__('Address line 2')" />
<x-text-input id="home_line2" name="home[line2]" type="text" class="mt-1 block w-full"
:value="old('home.line2', $home->line2 ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('home.line2')" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<x-input-label for="home_city" :value="__('City')" />
<x-text-input id="home_city" name="home[city]" type="text" class="mt-1 block w-full"
:value="old('home.city', $home->city ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('home.city')" />
</div>
<div>
<x-input-label for="home_state" :value="__('State/Region')" />
<x-text-input id="home_state" name="home[state]" type="text" class="mt-1 block w-full"
:value="old('home.state', $home->state ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('home.state')" />
</div>
<div>
<x-input-label for="home_postal" :value="__('Postal code')" />
<x-text-input id="home_postal" name="home[postal]" type="text" class="mt-1 block w-full"
:value="old('home.postal', $home->postal ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('home.postal')" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<x-input-label for="home_country" :value="__('Country')" />
<x-text-input id="home_country" name="home[country]" type="text" class="mt-1 block w-full"
:value="old('home.country', $home->country ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('home.country')" />
</div>
<div>
<x-input-label for="home_phone" :value="__('Phone')" />
<x-text-input id="home_phone" name="home[phone]" type="text" class="mt-1 block w-full"
:value="old('home.phone', $home->phone ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('home.phone')" />
</div>
</div>
</div>
{{-- billing address --}}
<div class="space-y-4">
<h3 class="text-md font-semibold text-gray-800">{{ __('Billing Address') }}</h3>
<div>
<x-input-label for="bill_label" :value="__('Label')" />
<x-text-input id="bill_label" name="billing[label]" type="text" class="mt-1 block w-full"
:value="old('billing.label', $billing->label ?? 'Billing')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.label')" />
</div>
<div>
<x-input-label for="bill_line1" :value="__('Address line 1')" />
<x-text-input id="bill_line1" name="billing[line1]" type="text" class="mt-1 block w-full"
:value="old('billing.line1', $billing->line1 ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.line1')" />
</div>
<div>
<x-input-label for="bill_line2" :value="__('Address line 2')" />
<x-text-input id="bill_line2" name="billing[line2]" type="text" class="mt-1 block w-full"
:value="old('billing.line2', $billing->line2 ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.line2')" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<x-input-label for="bill_city" :value="__('City')" />
<x-text-input id="bill_city" name="billing[city]" type="text" class="mt-1 block w-full"
:value="old('billing.city', $billing->city ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.city')" />
</div>
<div>
<x-input-label for="bill_state" :value="__('State/Region')" />
<x-text-input id="bill_state" name="billing[state]" type="text" class="mt-1 block w-full"
:value="old('billing.state', $billing->state ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.state')" />
</div>
<div>
<x-input-label for="bill_postal" :value="__('Postal code')" />
<x-text-input id="bill_postal" name="billing[postal]" type="text" class="mt-1 block w-full"
:value="old('billing.postal', $billing->postal ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.postal')" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<x-input-label for="bill_country" :value="__('Country')" />
<x-text-input id="bill_country" name="billing[country]" type="text" class="mt-1 block w-full"
:value="old('billing.country', $billing->country ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.country')" />
</div>
<div>
<x-input-label for="bill_phone" :value="__('Phone')" />
<x-text-input id="bill_phone" name="billing[phone]" type="text" class="mt-1 block w-full"
:value="old('billing.phone', $billing->phone ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('billing.phone')" />
</div>
</div>
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save Addresses') }}</x-primary-button>
@if (session('status') === 'addresses-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>

View File

@ -1,55 +0,0 @@
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Delete Account') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('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.') }}
</p>
</header>
<x-danger-button
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
>{{ __('Delete Account') }}</x-danger-button>
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
<form method="post" action="{{ route('profile.destroy') }}" class="p-6">
@csrf
@method('delete')
<h2 class="text-lg font-medium text-gray-900">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('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.') }}
</p>
<div class="mt-6">
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
<x-text-input
id="password"
name="password"
type="password"
class="mt-1 block w-3/4"
placeholder="{{ __('Password') }}"
/>
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-danger-button class="ms-3">
{{ __('Delete Account') }}
</x-danger-button>
</div>
</form>
</x-modal>
</section>

View File

@ -1,48 +0,0 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Update Password') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Ensure your account is using a long, random password to stay secure.') }}
</p>
</header>
<form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
@csrf
@method('put')
<div>
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
<x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
<x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password" :value="__('New Password')" />
<x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'password-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>

View File

@ -1,64 +0,0 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Profile Information') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __("Update your account's profile information and email address.") }}
</p>
</header>
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
@csrf
</form>
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
@csrf
@method('patch')
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
<x-input-error class="mt-2" :messages="$errors->get('name')" />
</div>
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="username" />
<x-input-error class="mt-2" :messages="$errors->get('email')" />
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
<div>
<p class="text-sm mt-2 text-gray-800">
{{ __('Your email address is unverified.') }}
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@endif
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>

View File

@ -1,7 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\AccountController;
use App\Http\Controllers\BookController;
use App\Http\Controllers\CalendarController;
use App\Http\Controllers\CalendarSettingsController;
@ -23,7 +23,7 @@ Route::view('/', 'welcome');
/**
*
* starter pages
* @todo replace thse
* @todo replace these
*/
Route::view('/dashboard', 'dashboard')
@ -41,12 +41,29 @@ Route::view('/settings', 'settings')
Route::middleware('auth')->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');