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

@@ -70,7 +70,15 @@ class EventController extends Controller
],
]);
return view('schedule.index', compact('events', 'clients', 'calendarEvents', 'categoryTree'));
$holidays = \Illuminate\Support\Facades\Cache::remember('indonesian_holidays', 86400, function() {
$url = "https://libur.deno.dev/api";
$response = @file_get_contents($url);
if ($response === false) return [];
$data = json_decode($response, true);
return is_array($data) ? $data : [];
});
return view('schedule.index', compact('events', 'clients', 'calendarEvents', 'categoryTree', 'holidays'));
}
public function store(Request $request): RedirectResponse

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;
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: targetStatus }),
}).then(r => { if (r.ok) window.KolManager.notify('Status updated! 🎬'); });
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>

View File

@@ -7,16 +7,14 @@
@section('content')
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-5">
<a href="{{ route('schedule.index') }}"
class="text-xs px-3 py-2 rounded-full border border-pink-200 text-gray-500 hover:bg-pink-50">
<a href="{{ route('schedule.index') }}" class="btn-sakura text-sm px-5 py-2">
← Back to Schedule
</a>
</div>
<div class="cat-card p-4 lg:p-8">
<form action="{{ route('schedule.store') }}" method="POST" id="create-event-form"
x-data="contractCheck('{{ route('contracts.check', [], false) }}')"
class="space-y-5">
x-data="contractCheck('{{ route('contracts.check', [], false) }}')" class="space-y-5">
@csrf
<input type="hidden" name="start_time" id="start_time_hidden">
<input type="hidden" name="end_time" id="end_time_hidden">
@@ -26,8 +24,8 @@
<label class="block text-sm font-semibold text-gray-700 mb-1">
Title <span class="text-pink-400">*</span>
</label>
<input type="text" name="title" value="{{ old('title') }}"
class="input-soft" placeholder="Event title" required>
<input type="text" name="title" value="{{ old('title') }}" class="input-soft" placeholder="Event title"
required>
@error('title') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
@@ -140,8 +138,7 @@
{{-- Client --}}
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
<select name="client_id" id="client-select" class="input-soft"
x-model="clientId"
<select name="client_id" id="client-select" class="input-soft" x-model="clientId"
@change="onClientChange($event); if($event.target.value !== '__new__') checkContract()">
<option value="">— None —</option>
<option value="__new__" class="font-semibold text-pink-600">+ Create New Client…</option>
@@ -159,16 +156,20 @@
<p class="text-xs font-bold text-purple-700">🐱 New Client Basic Info</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Name <span class="text-pink-400">*</span></label>
<input type="text" x-model="newName" class="input-soft text-sm" placeholder="Client / brand name">
<label class="block text-xs font-semibold text-gray-600 mb-1">Name <span
class="text-pink-400">*</span></label>
<input type="text" x-model="newName" class="input-soft text-sm"
placeholder="Client / brand name">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Company</label>
<input type="text" x-model="newCompany" class="input-soft text-sm" placeholder="Company name">
<input type="text" x-model="newCompany" class="input-soft text-sm"
placeholder="Company name">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Email</label>
<input type="email" x-model="newEmail" class="input-soft text-sm" placeholder="contact@brand.com">
<input type="email" x-model="newEmail" class="input-soft text-sm"
placeholder="contact@brand.com">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Phone</label>
@@ -178,8 +179,10 @@
<p x-show="errMsg" x-text="errMsg" class="text-xs text-red-500"></p>
<div class="flex gap-2 pt-1">
<button type="button" @click="saveNewClient()" :disabled="saving"
class="btn-sakura text-sm px-4 py-2" x-text="saving ? 'Saving…' : '✓ Add & Select'"></button>
<button type="button" @click="isNew = false; document.getElementById('client-select').value = ''"
class="btn-sakura text-sm px-4 py-2"
x-text="saving ? 'Saving…' : '✓ Add & Select'"></button>
<button type="button"
@click="isNew = false; document.getElementById('client-select').value = ''"
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-gray-500 hover:bg-pink-50">
Cancel
</button>
@@ -190,7 +193,8 @@
{{-- Category --}}
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Category</label>
<select name="category_id" id="category-select" class="input-soft" x-model="categoryId" @change="onCategoryChange($event)">
<select name="category_id" id="category-select" class="input-soft" x-model="categoryId"
@change="onCategoryChange($event)">
<option value="">— None —</option>
<option value="__new__" class="font-semibold text-pink-600">+ Create New Category…</option>
@foreach($categoryTree as $parent)
@@ -211,14 +215,18 @@
class="sm:col-span-2 bg-pink-50 rounded-2xl p-4 space-y-3 border border-pink-100">
<p class="text-xs font-bold text-pink-700">🏷️ New Category</p>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Category Name <span class="text-pink-400">*</span></label>
<input type="text" x-model="newCatName" class="input-soft text-sm" placeholder="e.g. Sponsored Post">
<label class="block text-xs font-semibold text-gray-600 mb-1">Category Name <span
class="text-pink-400">*</span></label>
<input type="text" x-model="newCatName" class="input-soft text-sm"
placeholder="e.g. Sponsored Post">
</div>
<p x-show="catErrMsg" x-text="catErrMsg" class="text-xs text-red-500"></p>
<div class="flex gap-2 pt-1">
<button type="button" @click="saveNewCategory()" :disabled="savingCat"
class="btn-sakura text-sm px-4 py-2" x-text="savingCat ? 'Saving…' : '✓ Add & Select'"></button>
<button type="button" @click="isNewCat = false; document.getElementById('category-select').value = ''"
class="btn-sakura text-sm px-4 py-2"
x-text="savingCat ? 'Saving…' : '✓ Add & Select'"></button>
<button type="button"
@click="isNewCat = false; document.getElementById('category-select').value = ''"
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-gray-500 hover:bg-pink-50">
Cancel
</button>
@@ -238,8 +246,8 @@
<label class="block text-sm font-semibold text-gray-700 mb-1">
Start Date <span class="text-pink-400">*</span>
</label>
<input type="date" id="start_date" class="input-soft"
value="{{ old('_start_date') }}" required @change="checkContract()">
<input type="date" id="start_date" class="input-soft" value="{{ old('_start_date') }}" required
@change="checkContract()">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Start Time</label>
@@ -259,8 +267,8 @@
{{-- Location --}}
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Location</label>
<input type="text" name="location" value="{{ old('location') }}"
class="input-soft" placeholder="Online / venue address">
<input type="text" name="location" value="{{ old('location') }}" class="input-soft"
placeholder="Online / venue address">
</div>
</div>
@@ -284,8 +292,7 @@
{{-- Priority --}}
<div class="flex items-center gap-2">
<input type="checkbox" name="is_priority" value="1" id="priority"
class="w-4 h-4 accent-pink-400" {{ old('is_priority') ? 'checked' : '' }}>
<input type="checkbox" name="is_priority" value="1" id="priority" class="w-4 h-4 accent-pink-400" {{ old('is_priority') ? 'checked' : '' }}>
<label for="priority" class="text-sm text-gray-600 cursor-pointer">⭐ Mark as Priority</label>
</div>
@@ -310,19 +317,26 @@
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Platform</label>
<select name="content_platform" class="input-soft text-sm">
<option value="instagram" {{ old('content_platform') === 'instagram' ? 'selected' : '' }}>📸 Instagram</option>
<option value="tiktok" {{ old('content_platform') === 'tiktok' ? 'selected' : '' }}>🎵 TikTok</option>
<option value="youtube" {{ old('content_platform') === 'youtube' ? 'selected' : '' }}>▶️ YouTube</option>
<option value="other" {{ old('content_platform') === 'other' ? 'selected' : '' }}>📱 Other</option>
<option value="instagram" {{ old('content_platform') === 'instagram' ? 'selected' : '' }}>📸
Instagram</option>
<option value="tiktok" {{ old('content_platform') === 'tiktok' ? 'selected' : '' }}>🎵 TikTok
</option>
<option value="youtube" {{ old('content_platform') === 'youtube' ? 'selected' : '' }}>▶️
YouTube</option>
<option value="other" {{ old('content_platform') === 'other' ? 'selected' : '' }}>📱 Other
</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Content Type</label>
<select name="content_type" class="input-soft text-sm">
<option value="post" {{ old('content_type') === 'post' ? 'selected' : '' }}>Post / Feed</option>
<option value="reel" {{ old('content_type') === 'reel' ? 'selected' : '' }}>Reel / Short</option>
<option value="post" {{ old('content_type') === 'post' ? 'selected' : '' }}>Post / Feed
</option>
<option value="reel" {{ old('content_type') === 'reel' ? 'selected' : '' }}>Reel / Short
</option>
<option value="story" {{ old('content_type') === 'story' ? 'selected' : '' }}>Story</option>
<option value="video" {{ old('content_type') === 'video' ? 'selected' : '' }}>Long Video</option>
<option value="video" {{ old('content_type') === 'video' ? 'selected' : '' }}>Long Video
</option>
</select>
</div>
<p class="sm:col-span-2 text-xs text-purple-500">
@@ -345,8 +359,7 @@
<div class="sm:col-span-2">
<label class="block text-xs font-semibold text-gray-600 mb-1">Contract Title</label>
<input type="text" name="contract_title" class="input-soft text-sm"
placeholder="e.g. Exclusive Posting Contract — BrandX"
value="{{ old('contract_title') }}">
placeholder="e.g. Exclusive Posting Contract — BrandX" value="{{ old('contract_title') }}">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Contract Start</label>
@@ -363,8 +376,7 @@
<div class="flex flex-col sm:flex-row gap-3 pt-1">
<button type="submit" class="btn-sakura flex-1 py-3"
:class="{ 'opacity-50 cursor-not-allowed': contractWarning }"
:disabled="contractWarning">
:class="{ 'opacity-50 cursor-not-allowed': contractWarning }" :disabled="contractWarning">
🐾 Schedule It!
</button>
<a href="{{ route('schedule.index') }}"

View File

@@ -104,6 +104,43 @@ document.addEventListener('DOMContentLoaded', function () {
}
},
dayCellDidMount(info) {
const holidays = @json($holidays ?? []);
const holidayMap = {};
holidays.forEach(h => holidayMap[h.date] = h.name);
const year = info.date.getFullYear();
const month = String(info.date.getMonth() + 1).padStart(2, '0');
const day = String(info.date.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
if (holidayMap[dateStr]) {
const frame = info.el.querySelector('.fc-daygrid-day-frame');
if (frame) {
// Update background color for the highlighted box
frame.style.backgroundColor = '#fff0f0'; // Light red highlighted box
frame.style.position = 'relative'; // Required for absolute positioning of text
// Add the holiday text to the top-left corner
const holidayEl = document.createElement('div');
holidayEl.textContent = holidayMap[dateStr];
holidayEl.style.position = 'absolute';
holidayEl.style.top = '4px';
holidayEl.style.left = '4px';
holidayEl.style.color = '#ef4444'; // Red text
holidayEl.style.fontSize = '0.70rem'; // Small text
holidayEl.style.fontWeight = '600';
holidayEl.style.whiteSpace = 'nowrap';
holidayEl.style.overflow = 'hidden';
holidayEl.style.textOverflow = 'ellipsis';
holidayEl.style.maxWidth = '75%'; // Prevent it from overlapping with the date number (usually on right)
holidayEl.style.zIndex = '1';
frame.appendChild(holidayEl);
}
}
},
eventDrop(info) {
fetch(`/schedule/${info.event.id}/dates`, {
method: 'PATCH',