420 lines
18 KiB
PHP
420 lines
18 KiB
PHP
<?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>
|