Add group creation and show current groups

This commit is contained in:
2026-03-02 00:19:55 +01:00
parent e405fec5c2
commit 4bdaf7a8ab
19 changed files with 1010 additions and 160 deletions

View File

@@ -0,0 +1,419 @@
<?php
use App\Models\Crew;
use App\Models\Image;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithFileUploads;
new class extends Component
{
use WithFileUploads;
public bool $isOpen = false;
public string $name = '';
public string $description = '';
// Avatar state
public bool $avatarModalOpen = false;
public string $avatarTab = 'icon'; // icon | photo
public string $iconSearch = '';
public ?string $avatarIcon = null;
/** @var \Livewire\Features\SupportFileUploads\TemporaryUploadedFile|null */
public $avatarPhoto = null;
/**
* Icon list using your installed Blade icon packs.
* Store the component string in DB (crews.avatar_icon).
*/
public array $icons = [
['component' => 'phosphor-mountains', 'label' => 'Mountains'],
['component' => 'phosphor-compass', 'label' => 'Compass'],
['component' => 'phosphor-tent', 'label' => 'Tent'],
['component' => 'phosphor-map-trifold', 'label' => 'Map'],
['component' => 'phosphor-footprints', 'label' => 'Footprints'],
['component' => 'phosphor-fire', 'label' => 'Fire'],
['component' => 'phosphor-star', 'label' => 'Star'],
['component' => 'phosphor-flag', 'label' => 'Flag'],
['component' => 'phosphor-trophy', 'label' => 'Trophy'],
['component' => 'phosphor-target', 'label' => 'Target'],
['component' => 'solar-sun-linear', 'label' => 'Sun'],
['component' => 'solar-moon-linear', 'label' => 'Moon'],
['component' => 'bi-heart', 'label' => 'Heart'],
];
public function openModal(): void
{
$this->resetValidation();
$this->isOpen = true;
}
public function closeModal(): void
{
$this->isOpen = false;
$this->avatarModalOpen = false;
}
public function openAvatarModal(): void
{
$this->resetValidation();
$this->avatarModalOpen = true;
}
public function closeAvatarModal(): void
{
$this->avatarModalOpen = false;
}
public function setAvatarTab(string $tab): void
{
if (!in_array($tab, ['icon', 'photo'], true)) {
return;
}
$this->avatarTab = $tab;
$this->resetValidation();
}
public function selectIcon(string $component): void
{
$allowed = array_column($this->icons, 'component');
if (!in_array($component, $allowed, true)) {
return;
}
$this->avatarIcon = $component;
$this->avatarPhoto = null;
$this->avatarTab = 'icon';
}
public function updatedAvatarPhoto(): void
{
if ($this->avatarPhoto) {
$this->avatarIcon = null;
$this->avatarTab = 'photo';
}
}
#[Computed]
public function filteredIcons(): array
{
$q = trim(mb_strtolower($this->iconSearch));
if ($q === '') {
return $this->icons;
}
return array_values(array_filter($this->icons, function (array $icon) use ($q) {
return str_contains(mb_strtolower($icon['label']), $q)
|| str_contains(mb_strtolower($icon['component']), $q);
}));
}
#[Computed]
public function avatarPreviewType(): string
{
if ($this->avatarPhoto) {
return 'photo';
}
if ($this->avatarIcon) {
return 'icon';
}
return 'none';
}
public function save()
{
$this->validate([
'name' => ['required', 'string', 'max:60'],
'description' => ['nullable', 'string', 'max:140'],
'avatarIcon' => ['nullable', 'string', 'max:100'],
'avatarPhoto' => ['nullable', 'image', 'max:4096'],
]);
$crew = Crew::create([
'name' => $this->name,
'description' => $this->description ?: null,
'avatar_icon' => $this->avatarIcon,
'image_id' => null,
'cover_image_id' => null,
]);
if ($this->avatarPhoto) {
$disk = 'public';
$ext = $this->avatarPhoto->getClientOriginalExtension();
$ext = $ext ? mb_strtolower($ext) : 'jpg';
$path = "crews/{$crew->id}/avatar.{$ext}";
Storage::disk($disk)->putFileAs(
"crews/{$crew->id}",
$this->avatarPhoto,
"avatar.{$ext}"
);
$absolute = Storage::disk($disk)->path($path);
$width = null;
$height = null;
$sizeData = @getimagesize($absolute);
if (is_array($sizeData)) {
$width = $sizeData[0] ?? null;
$height = $sizeData[1] ?? null;
}
$image = Image::create([
'disk' => $disk,
'bucket' => null,
'path' => $path,
'original_name' => $this->avatarPhoto->getClientOriginalName(),
'mime_type' => $this->avatarPhoto->getMimeType(),
'size' => $this->avatarPhoto->getSize(),
'width' => $width,
'height' => $height,
'variants' => null,
'visibility' => 'public',
'checksum' => null,
'exif_stripped' => true,
'uploaded_by_user_id' => Auth::id(),
]);
$crew->image_id = $image->id;
$crew->save();
}
$crew->users()->attach(Auth::id());
// ToastMagic success
$this->dispatch('toastmagic', type: 'success', message: 'Group created');
return redirect('/groups');
}
};
?>
<div class="relative">
<button type="button" wire:click="openModal" class="btn-primary w-full text-base">
Create
</button>
@if($isOpen)
<div class="fixed inset-0 z-[9999]" role="dialog" aria-modal="true" wire:keydown.escape.window="closeModal">
<button
type="button"
class="absolute inset-0 bg-black/50"
wire:click="closeModal"
aria-label="Close modal"
></button>
<div class="absolute inset-0 bg-card">
<div class="mx-auto flex h-full w-full max-w-[420px] flex-col px-6 pt-6 pb-8">
<div class="flex items-center gap-3">
<button type="button" wire:click="closeModal" class="grid h-10 w-10 place-items-center rounded-xl bg-background ring-1 ring-border">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h3 class="text-lg font-semibold">Create Group</h3>
</div>
<div class="mt-6 rounded-2xl bg-background p-4 ring-1 ring-border">
<div class="mx-auto grid h-16 w-16 place-items-center rounded-2xl bg-muted">
@if($this->avatarPreviewType === 'photo')
<img src="{{ $avatarPhoto->temporaryUrl() }}" alt="Avatar preview" class="h-16 w-16 rounded-2xl object-cover" />
@elseif($this->avatarPreviewType === 'icon')
<x-dynamic-component :component="$avatarIcon" class="h-8 w-8 text-foreground" />
@else
<x-phosphor-mountains class="h-8 w-8 text-foreground/60" />
@endif
</div>
<div class="mt-4 text-center">
<p class="text-sm font-semibold text-foreground">
{{ $name !== '' ? $name : 'Group Name' }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $description !== '' ? $description : 'Group description...' }}
</p>
</div>
</div>
<div class="mt-6">
<p class="text-sm font-semibold">Group Avatar</p>
<button
type="button"
wire:click="openAvatarModal"
class="mt-3 flex w-full items-center gap-3 rounded-2xl bg-background p-4 ring-1 ring-border"
>
<div class="grid h-12 w-12 place-items-center rounded-2xl border border-dashed border-border bg-muted">
@if($this->avatarPreviewType === 'photo')
<img src="{{ $avatarPhoto->temporaryUrl() }}" alt="Avatar preview" class="h-12 w-12 rounded-2xl object-cover" />
@elseif($this->avatarPreviewType === 'icon')
<x-dynamic-component :component="$avatarIcon" class="h-6 w-6 text-foreground" />
@else
<x-phosphor-mountains class="h-6 w-6 text-foreground" />
@endif
</div>
<div class="text-left">
<p class="text-sm font-medium">Tap to pick an icon or upload a photo</p>
<p class="mt-0.5 text-xs text-muted-foreground">This will show on your group.</p>
</div>
</button>
</div>
<div class="mt-6 flex flex-col gap-3">
<label class="rounded-2xl bg-background p-4 ring-1 ring-border">
<p class="text-xs text-muted-foreground">Group Name</p>
<input
type="text"
wire:model.live="name"
class="mt-2 w-full rounded bg-transparent text-sm outline-none"
placeholder="e.g. Weekend Warriors"
/>
@error('name')
<p class="mt-2 text-xs text-red-500">{{ $message }}</p>
@enderror
</label>
<label class="rounded-2xl bg-background p-4 ring-1 ring-border">
<p class="text-xs text-muted-foreground">Description</p>
<input
type="text"
wire:model.live="description"
class="mt-2 w-full rounded bg-transparent text-sm outline-none"
placeholder="What's this group about?"
/>
@error('description')
<p class="mt-2 text-xs text-red-500">{{ $message }}</p>
@enderror
</label>
</div>
<div class="mt-auto pt-6">
<button type="button" wire:click="save" wire:loading.attr="disabled" class="btn-primary w-full text-base">
<span wire:loading.remove wire:target="save">Create Group</span>
<span wire:loading wire:target="save">Creating...</span>
</button>
</div>
</div>
</div>
{{-- Avatar Picker Modal --}}
@if($avatarModalOpen)
<div class="absolute inset-0 z-[10000]" role="dialog" aria-modal="true" wire:keydown.escape.window="closeAvatarModal">
<button
type="button"
class="absolute inset-0 bg-black/50"
wire:click="closeAvatarModal"
aria-label="Close avatar modal"
></button>
<div class="absolute left-1/2 top-1/2 w-[92%] max-w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-card p-5 shadow-xl ring-1 ring-border">
<div class="flex items-center justify-between">
<h4 class="text-base font-semibold">Group Avatar</h4>
<button
type="button"
wire:click="closeAvatarModal"
class="grid h-9 w-9 place-items-center rounded-xl bg-background ring-1 ring-border"
aria-label="Close"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="mt-4 rounded-2xl bg-muted p-1 ring-1 ring-border">
<div class="grid grid-cols-2 gap-1">
<button
type="button"
wire:click="setAvatarTab('icon')"
class="rounded-xl px-3 py-2 text-sm font-medium {{ $avatarTab === 'icon' ? 'bg-card ring-1 ring-border' : 'opacity-70' }}"
>
Icon
</button>
<button
type="button"
wire:click="setAvatarTab('photo')"
class="rounded-xl px-3 py-2 text-sm font-medium {{ $avatarTab === 'photo' ? 'bg-card ring-1 ring-border' : 'opacity-70' }}"
>
Photo
</button>
</div>
</div>
@if($avatarTab === 'icon')
<div class="mt-4">
<div class="flex items-center gap-2 rounded-2xl bg-background px-3 py-2 ring-1 ring-border">
<svg class="h-4 w-4 opacity-60" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 21l-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
</svg>
<input
type="text"
wire:model.live="iconSearch"
class="w-full bg-transparent text-sm outline-none"
placeholder="Search icons..."
/>
</div>
<div class="mt-4 max-h-[320px] overflow-auto pr-1">
<div class="grid grid-cols-6 gap-2 p-2">
@foreach($this->filteredIcons as $icon)
<button
type="button"
wire:click="selectIcon(@js($icon['component']))"
class="grid h-12 w-12 place-items-center rounded-xl ring-1 ring-border {{ $avatarIcon === $icon['component'] ? 'bg-primary/15' : 'bg-background' }}"
aria-label="Select icon {{ $icon['label'] }}"
title="{{ $icon['label'] }}"
>
<x-dynamic-component :component="$icon['component']" class="h-6 w-6" />
</button>
@endforeach
</div>
</div>
</div>
@else
<div class="mt-5">
<label class="block cursor-pointer rounded-2xl bg-background p-5 ring-1 ring-border">
<div class="grid place-items-center rounded-2xl border border-dashed border-border bg-muted px-6 py-8 text-center">
<svg class="h-7 w-7" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 7a3 3 0 1 0 6 0a3 3 0 1 0-6 0Z" stroke="currentColor" stroke-width="2"/>
<path d="M4 20l5-6 4 4 3-3 4 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<p class="mt-2 text-sm font-medium">Upload Photo</p>
<p class="mt-1 text-xs text-muted-foreground">Choose a photo for your group</p>
@if($avatarPhoto)
<img src="{{ $avatarPhoto->temporaryUrl() }}" class="mt-4 h-20 w-20 rounded-2xl object-cover" alt="Uploaded preview" />
@endif
</div>
<input type="file" class="hidden" wire:model="avatarPhoto" accept="image/*" />
</label>
@error('avatarPhoto')
<p class="mt-2 text-xs text-red-500">{{ $message }}</p>
@enderror
<div class="mt-3 flex items-center justify-end">
<button type="button" wire:click="closeAvatarModal" class="btn-outline px-4 py-2 text-sm">
Done
</button>
</div>
</div>
@endif
</div>
</div>
@endif
</div>
@endif
</div>

View File

@@ -0,0 +1,13 @@
<?php
use Livewire\Component;
new class extends Component
{
//
};
?>
<div>
{{-- Always remember that you are absolutely unique. Just like everyone else. - Margaret Mead --}}
</div>

View File

@@ -0,0 +1,37 @@
<?php
use App\Models\Crew;
use Livewire\Component;
new class extends Component {
public Crew $crew;
public function mount(Crew $crew): void
{
$this->crew = $crew;
}
};
?>
<div
class="card bg-card flex items-center gap-4 rounded-xl transition-colors hover:bg-secondary/50 active:scale-[0.98] p-4">
<div class="grid h-10 w-10 place-items-center rounded-xl bg-primary/15 text-primary-foreground mb-2">
<x-bi-people class="h-6 w-6 text-primary"/>
</div>
<div class="flex-1 overflow-hidden">
<h3 class="truncate text-sm font-semibold">{{ $crew->name }}</h3>
<p>{{ $crew->description }}</p>
<div class="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1">
<x-bi-people class="h-3 w-3"/>
{{ $crew->users_count }}
</span>
<span>
{{ $crew->patches_count }} patches
</span>
<span class="text-primary font-medium">
{{ $crew->patches_count }} earned
</span>
</div>
</div>
</div>