feat: implement Alpine.js modal for content details and migrate to SortableJS for kanban board functionality

This commit is contained in:
I-Fun
2026-04-05 10:45:49 +07:00
parent 8b5992b7ce
commit 64092d3abc
4 changed files with 575 additions and 424 deletions

View File

@@ -50,16 +50,28 @@
</div>
{{-- Sortable items container --}}
<div class="flex flex-col gap-2 flex-1 min-h-12" x-sort:group="kanban"
@sort="moved($event, '{{ $status->value }}')" data-col-status="{{ $status->value }}">
<div class="flex flex-col gap-2 flex-1 min-h-12 kanban-column"
data-col-status="{{ $status->value }}">
@foreach($col['items'] as $brief)
<div class="bg-white rounded-2xl p-3 shadow-sm border border-pink-50 select-none
cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
x-sort:item="{{ $brief->id }}" data-id="{{ $brief->id }}">
cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow kanban-item"
data-id="{{ $brief->id }}">
<div class="flex items-start gap-2 mb-1.5">
<span class="text-sm shrink-0">{{ $brief->platform_icon }}</span>
<a href="{{ route('content.show', $brief) }}"
<a href="#"
data-brief="{{ json_encode([
'id' => $brief->id,
'title' => $brief->title,
'platform' => $brief->platform_icon . ' ' . ucfirst($brief->platform),
'status' => $brief->status->label(),
'client' => $brief->client?->name,
'target_date' => $brief->target_date?->format('d M Y'),
'hook' => $brief->hook,
'body' => $brief->body,
'notes' => $brief->notes
]) }}"
@click.prevent="openModal(JSON.parse($el.dataset.brief))"
class="font-semibold text-gray-800 text-xs leading-tight hover:text-pink-500 line-clamp-2 flex-1">
{{ $brief->title }}
</a>
@@ -89,24 +101,106 @@
</a>
</div>
@endforeach
{{-- Content Detail Request Modal --}}
<div x-show="modalOpen" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display: none;" x-transition>
<div @click.outside="modalOpen = false" class="bg-white p-5 w-full max-w-lg rounded-3xl shadow-xl mx-4 max-h-[90vh] flex flex-col relative"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4">
<div class="flex items-center justify-between mb-4 border-b border-pink-50 pb-3 shrink-0">
<h3 class="font-extrabold text-gray-800 text-xl truncate pr-4" x-text="activeBrief?.title"></h3>
<button @click="modalOpen = false" class="text-gray-400 hover:text-pink-500 hover:bg-pink-50 w-8 h-8 rounded-full flex items-center justify-center transition-colors">✕</button>
</div>
<div class="text-sm text-gray-600 space-y-4 flex-1 overflow-y-auto pr-2 custom-scrollbar">
<div class="flex gap-3"><span class="text-gray-400 w-20 shrink-0 font-medium">Platform</span><span class="font-bold text-gray-800" x-text="activeBrief?.platform"></span></div>
<div class="flex gap-3"><span class="text-gray-400 w-20 shrink-0 font-medium">Status</span><span class="font-bold badge bg-pink-100 text-pink-700 rounded-full px-2 py-0.5" x-text="activeBrief?.status"></span></div>
<template x-if="activeBrief?.client">
<div class="flex gap-3 items-center"><span class="text-gray-400 w-20 shrink-0 font-medium">Client</span><span class="font-semibold text-pink-500" x-text="activeBrief?.client"></span></div>
</template>
<template x-if="activeBrief?.target_date">
<div class="flex gap-3"><span class="text-gray-400 w-20 shrink-0 font-medium">Target</span><span class="font-medium" x-text="activeBrief?.target_date"></span></div>
</template>
<template x-if="activeBrief?.hook">
<div class="flex flex-col gap-1 mt-4">
<span class="text-gray-400 font-medium">🎣 Hook</span>
<div class="whitespace-pre-wrap bg-pink-50/50 border border-pink-100 p-3 rounded-2xl text-gray-700 leading-relaxed" x-text="activeBrief?.hook"></div>
</div>
</template>
<template x-if="activeBrief?.body">
<div class="flex flex-col gap-1 mt-4">
<span class="text-gray-400 font-medium">📖 Body</span>
<div class="whitespace-pre-wrap bg-gray-50 border border-gray-100 p-3 rounded-2xl text-gray-700 leading-relaxed" x-text="activeBrief?.body"></div>
</div>
</template>
<template x-if="activeBrief?.notes">
<div class="flex flex-col gap-1 mt-4">
<span class="text-gray-400 font-medium">📝 Notes</span>
<div class="whitespace-pre-wrap bg-yellow-50/50 border border-yellow-100 p-3 rounded-2xl text-gray-700 leading-relaxed" x-text="activeBrief?.notes"></div>
</div>
</template>
</div>
<div class="flex gap-3 mt-5 pt-4 border-t border-pink-50 shrink-0">
<a :href="`/content/${activeBrief?.id}/edit`" class="btn-sakura text-sm flex-1 text-center py-2.5">Edit Brief</a>
<a :href="`/content/${activeBrief?.id}`" class="py-2.5 rounded-full border border-pink-200 text-pink-500 text-sm hover:bg-pink-50 flex-1 text-center font-bold bg-white transition-colors">Details Page</a>
</div>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script>
function kanban() {
return {
moved(event, targetStatus) {
const id = event.item?.dataset?.id;
if (!id) return;
fetch(`/content/${id}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ status: targetStatus }),
}).then(r => { if (r.ok) window.KolManager.notify('Status updated! 🎬'); });
modalOpen: false,
activeBrief: null,
init() {
// Start Sortable once script loads
const initSort = () => {
if (typeof Sortable !== 'undefined') {
document.querySelectorAll('.kanban-column').forEach(el => {
Sortable.create(el, {
group: 'kanban-board',
animation: 150,
onEnd: (evt) => {
const id = evt.item.dataset.id;
const targetCol = evt.to.dataset.colStatus;
if (!id || !targetCol) return;
// If dropped in same column identically, skip fetch
if (evt.from === evt.to && evt.oldIndex === evt.newIndex) return;
fetch(`/content/${id}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ status: targetCol }),
}).then(r => {
if (r.ok) window.KolManager.notify('Status updated! 🎬');
else window.KolManager.notify('❌ Failed to update status');
}).catch(() => window.KolManager.notify('❌ Network error!'));
}
});
});
} else {
setTimeout(initSort, 100);
}
};
initSort();
},
openModal(brief) {
this.activeBrief = brief;
this.modalOpen = true;
}
};
}
</script>