diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php new file mode 100644 index 0000000..c231e99 --- /dev/null +++ b/app/Http/Controllers/BookController.php @@ -0,0 +1,88 @@ +email; + + $books = Book::query() + ->join('addressbook_meta as meta', 'meta.addressbook_id', '=', 'addressbooks.id') + ->where('principaluri', $principal) + ->select('addressbooks.*', 'meta.color', 'meta.is_default') + ->get(); + + return view('books.index', compact('books')); + } + + public function create() + { + return view('books.create'); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'displayname' => 'required|string|max:100', + 'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/', + ]); + + $principal = 'principals/' . Auth::user()->email; + + $id = Book::insertGetId([ + 'principaluri' => $principal, + 'uri' => (string) Str::uuid(), + 'displayname' => $data['displayname'], + ]); + + BookMeta::create([ + 'book_id' => $id, + 'color' => $data['color'] ?? '#cccccc', + 'is_default' => false, + ]); + + return redirect()->route('books.index'); + } + + public function show(Book $book) + { + $this->authorize('view', $book); + + $book->load('meta', 'cards'); + + return view('books.show', compact('book')); + } + + public function edit(Book $book) + { + $book->load('meta'); + + return view('books.edit', compact('book')); + } + + public function update(Request $request, Book $book) + { + $data = $request->validate([ + 'displayname' => 'required|string|max:100', + 'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/', + ]); + + $book->update([ + 'displayname' => $data['displayname'], + ]); + + $book->meta()->updateOrCreate([], [ + 'color' => $data['color'] ?? '#cccccc', + ]); + + return redirect()->route('books.index')->with('toast', 'Address Book updated.'); + } +} diff --git a/app/Http/Controllers/DavController.php b/app/Http/Controllers/DavController.php index f275463..3e60d72 100644 --- a/app/Http/Controllers/DavController.php +++ b/app/Http/Controllers/DavController.php @@ -5,8 +5,10 @@ namespace App\Http\Controllers; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\DB; + use App\Services\Dav\LaravelSabreAuthBackend; use App\Services\Dav\LaravelSabrePrincipalBackend; + use Sabre\DAV; use Sabre\DAV\Auth\Plugin as AuthPlugin; use Sabre\DAVACL\Plugin as ACLPlugin; @@ -15,6 +17,8 @@ use Sabre\CalDAV\CalendarRoot; use Sabre\CalDAV\Plugin as CalDavPlugin; use Sabre\CalDAV\Backend\PDO as CalDAVPDO; use Sabre\CardDAV\AddressBookRoot; +use Sabre\CardDAV\Plugin as CardDavPlugin; +use Sabre\CardDAV\Backend\PDO as CardDAVPDO; class DavController extends Controller { @@ -30,11 +34,12 @@ class DavController extends Controller $authBackend = new LaravelSabreAuthBackend(); $principalBackend = new LaravelSabrePrincipalBackend(); $calendarBackend = new CalDAVPDO($pdo); + $cardBackend = new CardDAVPDO($pdo); $nodes = [ new PrincipalCollection($principalBackend), - new CalendarRoot($principalBackend, $calendarBackend) - // Add your Calendars or Addressbooks here + new CalendarRoot($principalBackend, $calendarBackend), + new AddressBookRoot($principalBackend, $cardBackend) ]; $server = new DAV\Server($nodes); @@ -43,6 +48,7 @@ class DavController extends Controller $server->addPlugin(new AuthPlugin($authBackend, 'Kithkin DAV')); $server->addPlugin(new ACLPlugin()); $server->addPlugin(new CalDavPlugin()); + $server->addPlugin(new CardDavPlugin()); $server->on('beforeMethod', function () { \Log::info('SabreDAV beforeMethod triggered'); diff --git a/app/Models/AddressBook.php b/app/Models/Book.php similarity index 60% rename from app/Models/AddressBook.php rename to app/Models/Book.php index 3b2c0e6..e6ec858 100644 --- a/app/Models/AddressBook.php +++ b/app/Models/Book.php @@ -6,18 +6,18 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; -class AddressBook extends Model +class Book extends Model { protected $table = 'addressbooks'; // Sabre table public $timestamps = false; - public function contacts(): HasMany + public function cards(): HasMany { - return $this->hasMany(Contact::class, 'addressbookid'); + return $this->hasMany(Card::class, 'addressbookid'); } public function meta(): HasOne { - return $this->hasOne(AddressbookMeta::class, 'addressbook_id'); + return $this->hasOne(BookMeta::class, 'addressbook_id'); } } diff --git a/app/Models/AddressBookMeta.php b/app/Models/BookMeta.php similarity index 82% rename from app/Models/AddressBookMeta.php rename to app/Models/BookMeta.php index f978df3..f006739 100644 --- a/app/Models/AddressBookMeta.php +++ b/app/Models/BookMeta.php @@ -5,7 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -class AddressBookMeta extends Model +class BookMeta extends Model { protected $table = 'addressbook_meta'; protected $primaryKey = 'addressbook_id'; @@ -24,6 +24,6 @@ class AddressBookMeta extends Model public function addressBook(): BelongsTo { - return $this->belongsTo(AddressBook::class, 'addressbook_id'); + return $this->belongsTo(Book::class, 'addressbook_id'); } } diff --git a/app/Models/Contact.php b/app/Models/Card.php similarity index 52% rename from app/Models/Contact.php rename to app/Models/Card.php index 3e457a8..782a2c7 100644 --- a/app/Models/Contact.php +++ b/app/Models/Card.php @@ -6,19 +6,28 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; -class Contact extends Model +class Card extends Model { protected $table = 'cards'; // Sabre table public $timestamps = false; protected $primaryKey = 'id'; - public function addressBook(): BelongsTo + protected $fillable = [ + 'addressbookid', + 'uri', + 'lastmodified', + 'etag', + 'size', + 'carddata', + ]; + + public function book() { - return $this->belongsTo(AddressBook::class, 'addressbookid'); + return $this->belongsTo(Book::class, 'addressbookid'); } public function meta(): HasOne { - return $this->hasOne(ContactMeta::class, 'card_id'); + return $this->hasOne(CardMeta::class, 'card_id'); } } diff --git a/app/Models/ContactMeta.php b/app/Models/CardMeta.php similarity index 91% rename from app/Models/ContactMeta.php rename to app/Models/CardMeta.php index 61bd92a..518f83e 100644 --- a/app/Models/ContactMeta.php +++ b/app/Models/CardMeta.php @@ -19,7 +19,7 @@ class ContactMeta extends Model public function contact(): BelongsTo { - return $this->belongsTo(Contact::class, 'card_id'); + return $this->belongsTo(Card::class, 'card_id'); } /* convenience: return tags as array */ diff --git a/app/Policies/BookPolicy.php b/app/Policies/BookPolicy.php new file mode 100644 index 0000000..af7f71e --- /dev/null +++ b/app/Policies/BookPolicy.php @@ -0,0 +1,30 @@ +principaluri === 'principals/' . $user->email; + } + + public function update(User $user, Book $book): bool + { + return $book->principaluri === 'principals/' . $user->email; + } + + public function delete(User $user, Book $book): bool + { + return $book->principaluri === 'principals/' . $user->email; + } + + public function share(User $user, Book $book): bool + { + // Placeholder for future sharing logic + return $book->principaluri === 'principals/' . $user->email; + } +} diff --git a/composer.json b/composer.json index 3f4711d..7d2c1e7 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "php": "^8.2", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", + "omnia-digital/livewire-calendar": "^3.2", "sabre/dav": "^4.7" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 14f89e7..8e2be02 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5c25965fd8b365e18fdf5a50055d481f", + "content-hash": "7376fcf2b57a1242a21836781b34e006", "packages": [ { "name": "brick/math", @@ -2006,6 +2006,82 @@ ], "time": "2024-12-08T08:18:47+00:00" }, + { + "name": "livewire/livewire", + "version": "v3.6.4", + "source": { + "type": "git", + "url": "https://github.com/livewire/livewire.git", + "reference": "ef04be759da41b14d2d129e670533180a44987dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ef04be759da41b14d2d129e670533180a44987dc", + "reference": "ef04be759da41b14d2d129e670533180a44987dc", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "league/mime-type-detection": "^1.9", + "php": "^8.1", + "symfony/console": "^6.0|^7.0", + "symfony/http-kernel": "^6.2|^7.0" + }, + "require-dev": { + "calebporzio/sushi": "^2.1", + "laravel/framework": "^10.15.0|^11.0|^12.0", + "mockery/mockery": "^1.3.1", + "orchestra/testbench": "^8.21.0|^9.0|^10.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", + "phpunit/phpunit": "^10.4|^11.5", + "psy/psysh": "^0.11.22|^0.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Livewire": "Livewire\\Livewire" + }, + "providers": [ + "Livewire\\LivewireServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "A front-end framework for Laravel.", + "support": { + "issues": "https://github.com/livewire/livewire/issues", + "source": "https://github.com/livewire/livewire/tree/v3.6.4" + }, + "funding": [ + { + "url": "https://github.com/livewire", + "type": "github" + } + ], + "time": "2025-07-17T05:12:15+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -2507,6 +2583,79 @@ ], "time": "2025-05-08T08:14:37+00:00" }, + { + "name": "omnia-digital/livewire-calendar", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/omnia-digital/livewire-calendar.git", + "reference": "9488ebaa84bf96f09c25dfbc2538d394aec365a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/omnia-digital/livewire-calendar/zipball/9488ebaa84bf96f09c25dfbc2538d394aec365a7", + "reference": "9488ebaa84bf96f09c25dfbc2538d394aec365a7", + "shasum": "" + }, + "require": { + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "livewire/livewire": "^2.0||^3.0", + "php": "^7.2|^8.0|^8.1|^8.2" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.0|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "LivewireCalendar": "Omnia\\LivewireCalendar\\LivewireCalendarFacade" + }, + "providers": [ + "Omnia\\LivewireCalendar\\LivewireCalendarServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Omnia\\LivewireCalendar\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Torres", + "email": "josht@omniadigital.io", + "role": "Developer" + }, + { + "name": "Andrés Santibáñez", + "email": "santibanez.andres@gmail.com", + "role": "Developer" + }, + { + "name": "Osei Quashie", + "email": "osei@omniadigital.io", + "role": "Developer" + } + ], + "description": "Laravel Livewire calendar component", + "homepage": "https://github.com/omnia-digital/livewire-calendar", + "keywords": [ + "livewire-calendar", + "omnia", + "omnia-digital" + ], + "support": { + "issues": "https://github.com/omnia-digital/livewire-calendar/issues", + "source": "https://github.com/omnia-digital/livewire-calendar/tree/3.2.0" + }, + "time": "2025-02-28T18:18:23+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.3", diff --git a/database/migrations/2025_07_15_000003_create_addressbook_meta_table.php b/database/migrations/2025_07_15_000003_create_book_meta_table.php similarity index 100% rename from database/migrations/2025_07_15_000003_create_addressbook_meta_table.php rename to database/migrations/2025_07_15_000003_create_book_meta_table.php diff --git a/database/migrations/2025_07_15_000004_create_contacts_table.php b/database/migrations/2025_07_15_000004_create_card_meta_table.php similarity index 100% rename from database/migrations/2025_07_15_000004_create_contacts_table.php rename to database/migrations/2025_07_15_000004_create_card_meta_table.php diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1785dad..fa08930 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -101,5 +101,50 @@ ICS; 'updated_at' => now(), ] ); + + /** create cards */ + $bookId = DB::table('addressbooks')->insertGetId([ + 'principaluri' => $user->uri, + 'uri' => 'default', + 'displayname' => 'Default Address Book', + ]); + + $vcard = <<insert([ + 'addressbook_id' => $bookId, + 'color' => '#ff40ff', + 'is_default' => true, + 'settings' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $cardId = DB::table('cards')->insertGetId([ + 'addressbookid' => $bookId, + 'uri' => Str::uuid().'.vcf', + 'lastmodified' => now()->timestamp, + 'etag' => md5($vcard), + 'size' => strlen($vcard), + 'carddata' => $vcard, + ]); + + DB::table('contact_meta')->insert([ + 'card_id' => $cardId, + 'avatar_url' => null, + 'tags' => 'demo,seed', + 'notes' => 'Seeded contact from DatabaseSeeder.', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } } diff --git a/package-lock.json b/package-lock.json index 2107959..71dde9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.2", "@tailwindcss/vite": "^4.0.0", - "alpinejs": "^3.4.2", "autoprefixer": "^10.4.2", "axios": "^1.8.2", "concurrently": "^9.0.1", @@ -1196,33 +1195,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@vue/reactivity": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", - "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.1.5" - } - }, - "node_modules/@vue/shared": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", - "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/alpinejs": { - "version": "3.14.9", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz", - "integrity": "sha512-gqSOhTEyryU9FhviNqiHBHzgjkvtukq9tevew29fTj+ofZtfsYriw4zPirHHOAy9bw8QoL3WGhyk7QqCh5AYlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "~3.1.1" - } - }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", diff --git a/package.json b/package.json index 03ff0cd..e2627b0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.2", "@tailwindcss/vite": "^4.0.0", - "alpinejs": "^3.4.2", "autoprefixer": "^10.4.2", "axios": "^1.8.2", "concurrently": "^9.0.1", diff --git a/resources/js/app.js b/resources/js/app.js index a8093be..e59d6a0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1 @@ import './bootstrap'; - -import Alpine from 'alpinejs'; - -window.Alpine = Alpine; - -Alpine.start(); diff --git a/resources/views/books/create.blade.php b/resources/views/books/create.blade.php new file mode 100644 index 0000000..2a89040 --- /dev/null +++ b/resources/views/books/create.blade.php @@ -0,0 +1,4 @@ + +

Create Address Book

+ @include('addressbooks.form', ['action' => route('addressbooks.store'), 'isEdit' => false]) +
\ No newline at end of file diff --git a/resources/views/books/edit.blade.php b/resources/views/books/edit.blade.php new file mode 100644 index 0000000..251ed6e --- /dev/null +++ b/resources/views/books/edit.blade.php @@ -0,0 +1,4 @@ + +

Edit Address Book

+ @include('addressbooks.form', ['action' => route('addressbooks.update', $addressbook), 'isEdit' => true]) +
\ No newline at end of file diff --git a/resources/views/books/form.blade.php b/resources/views/books/form.blade.php new file mode 100644 index 0000000..985dcab --- /dev/null +++ b/resources/views/books/form.blade.php @@ -0,0 +1,12 @@ +
+ @csrf + @if($isEdit) @method('PUT') @endif + + + + + + + + +
diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php new file mode 100644 index 0000000..16a609a --- /dev/null +++ b/resources/views/books/index.blade.php @@ -0,0 +1,28 @@ + + +

+ {{ __('My Address Books') }} +

+
+ +
+
+ + {{-- Books list --}} +
+ +
+ +
+
+
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php new file mode 100644 index 0000000..f943e3b --- /dev/null +++ b/resources/views/books/show.blade.php @@ -0,0 +1,15 @@ + +

{{ $book->displayname }}

+ +

Color: {{ $book->meta->color }}

+ +

Contacts

+ + + Edit Book + Back to all +
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 936f7a2..15a6b54 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -7,10 +7,6 @@ {{ config('app.name', 'Laravel') }} - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 34b7a20..db30634 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -21,6 +21,11 @@ {{ __('Calendars') }} + diff --git a/routes/web.php b/routes/web.php index 49dbfa8..7b85ef3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,9 +2,11 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\ProfileController; +use App\Http\Controllers\BookController; use App\Http\Controllers\CalendarController; -use App\Http\Controllers\EventController; +use App\Http\Controllers\CardController; use App\Http\Controllers\DavController; +use App\Http\Controllers\EventController; use App\Http\Controllers\SubscriptionController; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; @@ -44,6 +46,19 @@ Route::middleware('auth')->group(function () { Route::get ('events/{event}/edit', [EventController::class, 'edit' ])->name('events.edit'); Route::put ('events/{event}', [EventController::class, 'update'])->name('events.update'); }); + + /** address books */ + Route::resource('books', BookController::class) + ->names('books') // books.index, books.create, … + ->parameter('books', 'book'); // {book} binding + + /** contacts inside a book + nested so urls look like /books/{book}/contacts/{contact} */ + Route::resource('books.contacts', ContactController::class) + ->names('books.contacts') + ->parameter('contacts', 'contact') + ->except(['index']) // you may add an index later + ->shallow(); }); /* Breeze auth routes (login, register, password reset, etc.) */