WIP: February 2026 event improvements and calendar refactor #1
@ -20,9 +20,9 @@ class PasswordController extends Controller
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
$request->user()->forceFill([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
])->save();
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
@ -35,11 +35,11 @@ class RegisteredUserController extends Controller
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
$user = new User();
|
||||
$user->name = $request->name;
|
||||
$user->email = $request->email;
|
||||
$user->password = $request->password;
|
||||
$user->save();
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ class User extends Authenticatable
|
||||
'firstname',
|
||||
'lastname',
|
||||
'displayname',
|
||||
'name',
|
||||
'email',
|
||||
'timezone',
|
||||
'phone',
|
||||
@ -59,6 +60,62 @@ class User extends Authenticatable
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose a Breeze-compatible "name" attribute without a physical column.
|
||||
* Preference: displayname (explicit override), then first + last, then email.
|
||||
*/
|
||||
public function getNameAttribute(): string
|
||||
{
|
||||
$displayname = is_string($this->displayname) ? trim($this->displayname) : '';
|
||||
if ($displayname !== '') {
|
||||
return $displayname;
|
||||
}
|
||||
|
||||
$first = is_string($this->firstname) ? trim($this->firstname) : '';
|
||||
$last = is_string($this->lastname) ? trim($this->lastname) : '';
|
||||
$full = trim($first . ' ' . $last);
|
||||
|
||||
if ($full !== '') {
|
||||
return $full;
|
||||
}
|
||||
|
||||
return (string) ($this->email ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map "name" writes to first/last names, keeping displayname optional.
|
||||
*/
|
||||
public function setNameAttribute(?string $value): void
|
||||
{
|
||||
$incoming = trim((string) $value);
|
||||
|
||||
$currentFirst = is_string($this->attributes['firstname'] ?? null)
|
||||
? trim((string) $this->attributes['firstname'])
|
||||
: '';
|
||||
$currentLast = is_string($this->attributes['lastname'] ?? null)
|
||||
? trim((string) $this->attributes['lastname'])
|
||||
: '';
|
||||
$currentGenerated = trim($currentFirst . ' ' . $currentLast);
|
||||
|
||||
if ($incoming === '') {
|
||||
$this->attributes['firstname'] = null;
|
||||
$this->attributes['lastname'] = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s+/', $incoming, 2);
|
||||
$this->attributes['firstname'] = $parts[0] ?? null;
|
||||
$this->attributes['lastname'] = $parts[1] ?? null;
|
||||
|
||||
$displayname = is_string($this->attributes['displayname'] ?? null)
|
||||
? trim((string) $this->attributes['displayname'])
|
||||
: '';
|
||||
|
||||
if ($displayname !== '' && $displayname === $currentGenerated) {
|
||||
$this->attributes['displayname'] = $incoming;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* user can own many calendars
|
||||
*/
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
@ -12,26 +13,38 @@ return new class extends Migration
|
||||
return base_path("vendor/sabre/dav/examples/sql/{$file}");
|
||||
}
|
||||
|
||||
private function prefix(): string
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
|
||||
return match ($driver) {
|
||||
'sqlite' => 'sqlite',
|
||||
'pgsql' => 'pgsql',
|
||||
default => 'mysql',
|
||||
};
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
// Disable FK checks for smooth batch execution
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
$prefix = $this->prefix();
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// Principals (users & groups)
|
||||
DB::unprepared(File::get($this->sql('mysql.principals.sql')));
|
||||
DB::unprepared(File::get($this->sql("{$prefix}.principals.sql")));
|
||||
|
||||
// CalDAV calendars + objects
|
||||
DB::unprepared(File::get($this->sql('mysql.calendars.sql')));
|
||||
DB::unprepared(File::get($this->sql("{$prefix}.calendars.sql")));
|
||||
|
||||
// CardDAV address books + cards
|
||||
DB::unprepared(File::get($this->sql('mysql.addressbooks.sql')));
|
||||
DB::unprepared(File::get($this->sql("{$prefix}.addressbooks.sql")));
|
||||
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
$this->prefix();
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// Drop in reverse dependency order
|
||||
DB::statement('DROP TABLE IF EXISTS
|
||||
@ -47,6 +60,6 @@ return new class extends Migration
|
||||
groupmembers
|
||||
');
|
||||
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
|
||||
@ -9,7 +9,9 @@ return new class extends Migration
|
||||
// add composite + geo + optional fulltext indexes to locations
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('locations', function (Blueprint $table) {
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
Schema::table('locations', function (Blueprint $table) use ($driver) {
|
||||
// composite btree index for common lookups
|
||||
$table->index(
|
||||
['display_name', 'city', 'state', 'postal', 'country'],
|
||||
@ -21,17 +23,23 @@ return new class extends Migration
|
||||
|
||||
// optional: fulltext index for free-form text searching
|
||||
// note: requires mysql/mariadb version with innodb fulltext support
|
||||
$table->fullText('raw_address', 'locations_raw_address_fulltext');
|
||||
if (in_array($driver, ['mysql', 'pgsql'], true)) {
|
||||
$table->fullText('raw_address', 'locations_raw_address_fulltext');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// drop the indexes added in up()
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('locations', function (Blueprint $table) {
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
Schema::table('locations', function (Blueprint $table) use ($driver) {
|
||||
$table->dropIndex('locations_name_city_idx');
|
||||
$table->dropIndex('locations_lat_lon_idx');
|
||||
$table->dropFullText('locations_raw_address_fulltext');
|
||||
if (in_array($driver, ['mysql', 'pgsql'], true)) {
|
||||
$table->dropFullText('locations_raw_address_fulltext');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -188,9 +188,9 @@ main {
|
||||
container: content / inline-size;
|
||||
|
||||
/* main content title and actions */
|
||||
> header {
|
||||
header {
|
||||
@apply flex flex-row items-center justify-between w-full;
|
||||
@apply bg-white sticky top-0 z-10;
|
||||
@apply bg-white sticky top-0 z-20;
|
||||
|
||||
/* app hedar; if h1 exists it means there's no aside, so force the width from that */
|
||||
h1 {
|
||||
@ -210,7 +210,9 @@ main {
|
||||
|
||||
/* header menu */
|
||||
menu {
|
||||
@apply flex flex-row items-center justify-end gap-4;
|
||||
@apply fixed right-0 top-2 flex flex-col bg-gray-100 gap-6 p-6 rounded-l-xl;
|
||||
height: calc(100dvh - 0.5rem);
|
||||
width: 33dvw;
|
||||
}
|
||||
}
|
||||
|
||||
@ -250,6 +252,21 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
/* container sizing */
|
||||
@container content (width >= 64rem)
|
||||
{
|
||||
main {
|
||||
article {
|
||||
header {
|
||||
menu {
|
||||
@apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent;
|
||||
@apply flex flex-row items-center justify-end gap-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* app logo */
|
||||
.logo {
|
||||
@apply w-10 h-10 flex;
|
||||
|
||||
@ -158,7 +158,7 @@
|
||||
--event-fg: var(--color-primary);
|
||||
|
||||
li.event {
|
||||
@apply flex rounded-md relative;
|
||||
@apply flex rounded-md relative border border-white;
|
||||
background-color: var(--event-bg);
|
||||
color: var(--event-fg);
|
||||
grid-row-start: var(--event-row);
|
||||
@ -166,22 +166,21 @@
|
||||
grid-column-start: var(--event-col);
|
||||
grid-column-end: calc(var(--event-col) + 1);
|
||||
top: 0.6rem;
|
||||
transition: translate 100ms ease-in;
|
||||
|
||||
> a {
|
||||
@apply flex flex-col grow px-3 py-2 gap-2px;
|
||||
a.event {
|
||||
@apply flex flex-col grow px-3 py-2 gap-2px text-sm;
|
||||
|
||||
> span {
|
||||
@apply font-semibold leading-none break-all;
|
||||
}
|
||||
|
||||
> time {
|
||||
@apply text-sm;
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply -translate-y-2px;
|
||||
animation: event-hover 125ms ease forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -216,19 +215,36 @@
|
||||
}
|
||||
|
||||
/* step handling */
|
||||
.calendar.time[data-density="30"] {
|
||||
.calendar.time[data-density="30"] { /* half-hourly */
|
||||
--row-height: 2rem;
|
||||
|
||||
ol.time li:nth-child(2n) {
|
||||
visibility: hidden; /* preserves space + row alignment */
|
||||
}
|
||||
}
|
||||
.calendar.time[data-density="60"] {
|
||||
.calendar.time[data-density="60"] { /* hourly */
|
||||
--row-height: 1.25rem;
|
||||
|
||||
ol.time li:not(:nth-child(4n + 1)) {
|
||||
visibility: hidden; /* preserves space + row alignment */
|
||||
}
|
||||
|
||||
&.week {
|
||||
ol.events {
|
||||
li.event[data-span="1"] {
|
||||
a.event > span,
|
||||
a.event > time {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
li.event[data-span="1"],
|
||||
li.event[data-span="2"] {
|
||||
> a.event {
|
||||
@apply flex-row items-center gap-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -378,7 +394,16 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes event-hover {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
z-index: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-2px);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
@keyframes header-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Profile') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<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('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('account.partials.addresses-form', [
|
||||
'home' => $home ?? null,
|
||||
'billing' => $billing ?? null,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@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('account.partials.delete-user-form')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@ -7,6 +7,7 @@
|
||||
data-calendar-id="{{ $event['calendar_slug'] }}"
|
||||
data-start="{{ $event['start_ui'] }}"
|
||||
data-duration="{{ $event['duration'] }}"
|
||||
data-span="{{ $event['row_span'] }}"
|
||||
style="
|
||||
--event-row: {{ $event['start_row'] }};
|
||||
--event-end: {{ $event['end_row'] }};
|
||||
|
||||
@ -28,9 +28,6 @@
|
||||
|
||||
<!-- bottom -->
|
||||
<section class="bottom">
|
||||
<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('account.index')">
|
||||
<x-icon-user-circle class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
|
||||
@ -2,32 +2,35 @@
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('profile page is displayed', function () {
|
||||
test('account info page is displayed', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->get('/profile');
|
||||
->get('/account/info');
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('profile information can be updated', function () {
|
||||
test('account information can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
->patch('/account/info', [
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
->assertRedirect('/account/info');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test', $user->firstname);
|
||||
$this->assertSame('User', $user->lastname);
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
@ -38,14 +41,15 @@ test('email verification status is unchanged when the email address is unchanged
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
->patch('/account/info', [
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
->assertRedirect('/account/info');
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
});
|
||||
@ -55,13 +59,13 @@ test('user can delete their account', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/profile', [
|
||||
->delete('/account', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
->assertRedirect('/dashboard');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
@ -72,14 +76,14 @@ test('correct password must be provided to delete account', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->delete('/profile', [
|
||||
->from('/account/delete/confirm')
|
||||
->delete('/account', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('userDeletion', 'password')
|
||||
->assertRedirect('/profile');
|
||||
->assertRedirect('/account/delete/confirm');
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
});
|
||||
@ -8,7 +8,7 @@ test('password can be updated', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->from('/account/password')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
@ -17,7 +17,7 @@ test('password can be updated', function () {
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
->assertRedirect('/account/password');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
});
|
||||
@ -27,7 +27,7 @@ test('correct password must be provided to update password', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->from('/account/password')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
@ -36,5 +36,5 @@ test('correct password must be provided to update password', function () {
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password')
|
||||
->assertRedirect('/profile');
|
||||
->assertRedirect('/account/password');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user