feat: add notes management feature with CRUD operations

- Created Note model and controller for handling notes.
- Added routes for notes CRUD operations.
- Implemented notes index view with search and pinning functionality.
- Created migration for notes table with necessary fields.
- Developed event creation form with client and contract options.
This commit is contained in:
I-Fun
2026-04-02 23:24:38 +07:00
parent 5748b163b6
commit 1b90f730a6
25 changed files with 1524 additions and 390 deletions

View File

@@ -0,0 +1,483 @@
@extends('layouts.app')
@section('title', 'Notes')
@section('page-title', '📝 Notes')
@section('page-subtitle', 'Quick notes, checklists & reminders')
@section('content')
<div x-data="notesApp({{ json_encode($notes) }})" x-cloak>
{{-- ── Toolbar ──────────────────────────────────────────────── --}}
<div class="flex items-center gap-2 mb-4">
<button @click="openNew()"
class="btn-sakura text-sm px-4 py-2 flex items-center gap-1.5 shrink-0">
+ New Note
</button>
{{-- Search --}}
<div class="relative flex-1 max-w-xs">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none">🔍</span>
<input type="text" x-model="searchQuery" placeholder="Search notes…"
class="input-soft pl-9 text-sm py-2 w-full">
</div>
{{-- Count --}}
<span class="ml-auto text-xs text-gray-400 shrink-0"
x-text="notes.length + ' note' + (notes.length !== 1 ? 's' : '')"></span>
</div>
{{-- ── Pinned section ───────────────────────────────────────── --}}
<template x-if="filteredPinned.length > 0">
<div class="mb-5">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
📌 Pinned
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
<template x-for="note in filteredPinned" :key="note.id">
<div class="rounded-3xl p-4 flex flex-col gap-2 shadow-sm border border-black/5 cursor-pointer
hover:shadow-md transition-shadow duration-200 group relative"
:style="`background:${note.color || '#FFFFFF'}`"
@click="openEdit(note)">
{{-- Pin badge --}}
<div class="absolute top-2.5 right-2.5 text-xs text-amber-400 opacity-60 group-hover:opacity-100">📌</div>
{{-- Title --}}
<p class="font-bold text-gray-800 text-sm leading-snug pr-5 line-clamp-2"
x-text="note.title || 'Untitled'"></p>
{{-- Content preview --}}
<div class="flex-1 text-xs text-gray-500 space-y-0.5 line-clamp-4">
<template x-for="(block, i) in (note.content || []).slice(0, 4)" :key="i">
<div class="flex items-start gap-1.5 min-w-0">
<template x-if="block.type === 'bullet'">
<span class="shrink-0 text-gray-400 mt-px">•</span>
</template>
<template x-if="block.type === 'checkbox'">
<span class="shrink-0 mt-px" x-text="block.checked ? '☑' : '☐'"></span>
</template>
<span class="truncate"
:class="block.type === 'checkbox' && block.checked ? 'line-through text-gray-400' : ''"
x-text="block.text || '…'"></span>
</div>
</template>
</div>
{{-- Footer --}}
<div class="flex items-center justify-between mt-1">
<span class="text-xs text-gray-400" x-text="formatDate(note.updated_at)"></span>
<button @click.stop="deleteNote(note)"
class="opacity-0 group-hover:opacity-100 text-xs text-red-400 hover:text-red-600 transition-opacity leading-none px-1">✕</button>
</div>
</div>
</template>
</div>
</div>
</template>
{{-- ── All / Other notes ────────────────────────────────────── --}}
<template x-if="filteredUnpinned.length > 0 || (filteredPinned.length > 0)">
<div>
<template x-if="filteredPinned.length > 0 && filteredUnpinned.length > 0">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Others</div>
</template>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
<template x-for="note in filteredUnpinned" :key="note.id">
<div class="rounded-3xl p-4 flex flex-col gap-2 shadow-sm border border-black/5 cursor-pointer
hover:shadow-md transition-shadow duration-200 group"
:style="`background:${note.color || '#FFFFFF'}`"
@click="openEdit(note)">
{{-- Title --}}
<p class="font-bold text-gray-800 text-sm leading-snug line-clamp-2"
x-text="note.title || 'Untitled'"></p>
{{-- Content preview --}}
<div class="flex-1 text-xs text-gray-500 space-y-0.5">
<template x-for="(block, i) in (note.content || []).slice(0, 4)" :key="i">
<div class="flex items-start gap-1.5 min-w-0">
<template x-if="block.type === 'bullet'">
<span class="shrink-0 text-gray-400 mt-px">•</span>
</template>
<template x-if="block.type === 'checkbox'">
<span class="shrink-0 mt-px" x-text="block.checked ? '☑' : '☐'"></span>
</template>
<span class="truncate"
:class="block.type === 'checkbox' && block.checked ? 'line-through text-gray-400' : ''"
x-text="block.text || '…'"></span>
</div>
</template>
</div>
{{-- Footer --}}
<div class="flex items-center justify-between mt-1">
<span class="text-xs text-gray-400" x-text="formatDate(note.updated_at)"></span>
<button @click.stop="deleteNote(note)"
class="opacity-0 group-hover:opacity-100 text-xs text-red-400 hover:text-red-600 transition-opacity leading-none px-1">✕</button>
</div>
</div>
</template>
</div>
</div>
</template>
{{-- ── Empty state ──────────────────────────────────────────── --}}
<template x-if="notes.length === 0">
<div class="text-center py-20 text-gray-400">
<span class="text-5xl block mb-3">📝</span>
<p class="text-sm font-medium mb-4">No notes yet.</p>
<button @click="openNew()" class="btn-sakura text-sm">Write your first note</button>
</div>
</template>
<template x-if="notes.length > 0 && filteredPinned.length === 0 && filteredUnpinned.length === 0">
<div class="text-center py-12 text-gray-400">
<span class="text-3xl block mb-2">🔍</span>
<p class="text-sm">No notes match "<span x-text="searchQuery"></span>"</p>
</div>
</template>
{{-- ════════════════════════════════════════════════════════════
Note Editor Modal
════════════════════════════════════════════════════════════ --}}
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 px-0 sm:px-4"
style="display:none"
@keydown.escape.window="showModal = false">
<div class="w-full sm:max-w-lg rounded-t-3xl sm:rounded-3xl shadow-2xl flex flex-col overflow-hidden"
:style="`background:${editorColor || '#FFFFFF'}`"
style="max-height:90vh"
@click.stop>
{{-- ── Modal Header ─────────────────────────────── --}}
<div class="flex items-center gap-2 px-4 pt-4 pb-3 border-b border-black/5 shrink-0">
{{-- Color swatches --}}
<div class="flex gap-1.5 flex-wrap">
<template x-for="c in noteColors" :key="c">
<button @click="editorColor = c" type="button"
class="w-5 h-5 rounded-full border-2 transition-transform hover:scale-110 shrink-0"
:style="`background:${c}`"
:class="editorColor === c ? 'border-gray-500 scale-110' : 'border-gray-200'">
</button>
</template>
</div>
{{-- Pin toggle --}}
<button @click="editorPinned = !editorPinned" type="button"
class="ml-auto text-sm px-2.5 py-1 rounded-full border transition-colors shrink-0"
:class="editorPinned ? 'border-amber-300 bg-amber-50 text-amber-600' : 'border-gray-200 text-gray-400 hover:border-amber-200 hover:text-amber-400'">
📌 <span x-text="editorPinned ? 'Pinned' : 'Pin'"></span>
</button>
{{-- Close --}}
<button @click="showModal = false" type="button"
class="text-gray-400 hover:text-gray-600 text-xl leading-none shrink-0 ml-1">✕</button>
</div>
{{-- ── Scrollable Body ──────────────────────────── --}}
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-1">
{{-- Title --}}
<input type="text" x-model="editorTitle" x-ref="titleInput"
placeholder="Note title…"
class="w-full bg-transparent border-none outline-none font-bold text-gray-800 text-lg placeholder-gray-300 mb-2"
@keydown.enter.prevent="focusFirstBlock()">
{{-- Blocks --}}
<template x-for="(block, idx) in editorBlocks" :key="block.id">
<div class="flex items-center gap-2 group/block py-0.5">
{{-- Left side: bullet / checkbox / empty --}}
<template x-if="block.type === 'bullet'">
<span class="text-gray-400 text-sm shrink-0 select-none mt-px">•</span>
</template>
<template x-if="block.type === 'checkbox'">
<input type="checkbox" x-model="block.checked"
class="w-4 h-4 accent-pink-400 shrink-0 cursor-pointer mt-px"
@click.stop>
</template>
<template x-if="block.type === 'paragraph'">
<span class="w-4 shrink-0"></span>
</template>
{{-- Text input --}}
<input type="text"
x-model="block.text"
:placeholder="block.type === 'paragraph' ? 'Add text…' : block.type === 'bullet' ? 'List item…' : 'To-do item…'"
:class="block.type === 'checkbox' && block.checked ? 'line-through text-gray-400' : 'text-gray-700'"
class="flex-1 bg-transparent border-none outline-none text-sm placeholder-gray-300 py-0.5"
:data-block-id="block.id"
@keydown.enter.prevent="insertBlockAfter(idx)"
@keydown.backspace="handleBackspace($event, block, idx)">
{{-- Block type switcher (hover) --}}
<div class="flex gap-0.5 opacity-0 group-hover/block:opacity-100 transition-opacity shrink-0">
<button @click.stop="changeType(idx, 'paragraph')" type="button"
:class="block.type === 'paragraph' ? 'text-pink-500' : 'text-gray-300 hover:text-gray-500'"
class="text-xs px-1 leading-none" title="Text">T</button>
<button @click.stop="changeType(idx, 'bullet')" type="button"
:class="block.type === 'bullet' ? 'text-pink-500' : 'text-gray-300 hover:text-gray-500'"
class="text-xs px-1 leading-none" title="Bullet">•</button>
<button @click.stop="changeType(idx, 'checkbox')" type="button"
:class="block.type === 'checkbox' ? 'text-pink-500' : 'text-gray-300 hover:text-gray-500'"
class="text-xs px-1 leading-none" title="Checkbox">☐</button>
<button @click.stop="removeBlock(idx)" type="button"
class="text-xs px-1 leading-none text-gray-300 hover:text-red-400 ml-1" title="Remove">✕</button>
</div>
</div>
</template>
{{-- Add block toolbar --}}
<div class="flex gap-2 pt-3 border-t border-black/5 mt-2">
<button @click="addBlock('paragraph')" type="button"
class="flex items-center gap-1 text-xs px-3 py-1.5 rounded-full border border-gray-200 text-gray-500 hover:bg-white/60 hover:border-gray-300 transition-colors">
<span class="font-semibold">T</span> Text
</button>
<button @click="addBlock('bullet')" type="button"
class="flex items-center gap-1 text-xs px-3 py-1.5 rounded-full border border-gray-200 text-gray-500 hover:bg-white/60 hover:border-gray-300 transition-colors">
• Bullet
</button>
<button @click="addBlock('checkbox')" type="button"
class="flex items-center gap-1 text-xs px-3 py-1.5 rounded-full border border-gray-200 text-gray-500 hover:bg-white/60 hover:border-gray-300 transition-colors">
☐ Checkbox
</button>
</div>
</div>
{{-- ── Modal Footer ─────────────────────────────── --}}
<div class="flex gap-2 px-4 pb-5 pt-3 border-t border-black/5 shrink-0">
<button @click="saveNote()" type="button"
class="btn-sakura flex-1 py-2.5 text-sm flex items-center justify-center gap-2"
:disabled="saving">
<span x-show="!saving">💾 Save Note</span>
<span x-show="saving" class="flex items-center gap-1.5"><span class="animate-spin inline-block">🐾</span> Saving…</span>
</button>
<button @click="showModal = false" type="button"
class="flex-1 py-2.5 rounded-full border border-pink-200 text-gray-600 text-sm hover:bg-pink-50 transition-colors">
Cancel
</button>
</div>
</div>
</div>
</div>{{-- end x-data --}}
@push('scripts')
<script>
function notesApp(initialNotes) {
return {
notes: initialNotes,
showModal: false,
editing: null,
// editor state
editorTitle: '',
editorBlocks: [],
editorColor: '#FFFFFF',
editorPinned: false,
saving: false,
nextBlockId: 1,
searchQuery: '',
noteColors: [
'#FFFFFF', '#FFE4E8', '#FFF9C4', '#E8F5E9',
'#E3F2FD', '#F3E5F5', '#FFF3E0', '#E0F7FA'
],
// ── Computed / Filtered ───────────────────────────────
get filteredPinned() {
return this.filterNotes(this.notes.filter(n => n.is_pinned));
},
get filteredUnpinned() {
return this.filterNotes(this.notes.filter(n => !n.is_pinned));
},
filterNotes(list) {
const q = this.searchQuery.trim().toLowerCase();
if (!q) return list;
return list.filter(n =>
(n.title || '').toLowerCase().includes(q) ||
(n.content || []).some(b => (b.text || '').toLowerCase().includes(q))
);
},
// ── Modal open/close ─────────────────────────────────
openNew() {
this.editing = null;
this.editorTitle = '';
this.editorBlocks = [this.makeBlock('paragraph')];
this.editorColor = '#FFFFFF';
this.editorPinned = false;
this.showModal = true;
this.$nextTick(() => this.$refs.titleInput?.focus());
},
openEdit(note) {
this.editing = note;
this.editorTitle = note.title || '';
this.editorBlocks = (note.content && note.content.length)
? note.content.map(b => ({ ...b, id: this.nextBlockId++ }))
: [this.makeBlock('paragraph')];
this.editorColor = note.color || '#FFFFFF';
this.editorPinned = !!note.is_pinned;
this.showModal = true;
this.$nextTick(() => this.$refs.titleInput?.focus());
},
// ── CRUD ─────────────────────────────────────────────
async saveNote() {
if (!this.editorTitle.trim()) {
this.$refs.titleInput?.focus();
return;
}
this.saving = true;
const csrfToken = document.querySelector('meta[name=csrf-token]').content;
const payload = {
title: this.editorTitle.trim(),
content: this.editorBlocks.map(({ type, text, checked }) => ({ type, text: text || '', checked: !!checked })),
color: this.editorColor,
is_pinned: this.editorPinned,
};
const url = this.editing ? `/notes/${this.editing.id}` : '/notes';
const method = this.editing ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify(payload),
});
const saved = await res.json();
if (this.editing) {
const idx = this.notes.findIndex(n => n.id === this.editing.id);
if (idx !== -1) this.notes.splice(idx, 1, saved);
} else {
this.notes.unshift(saved);
}
this.showModal = false;
} catch (err) {
console.error('Save failed', err);
}
this.saving = false;
},
async deleteNote(note) {
if (!confirm(`Delete "${note.title || 'this note'}"?`)) return;
const csrfToken = document.querySelector('meta[name=csrf-token]').content;
const res = await fetch(`/notes/${note.id}`, {
method: 'DELETE',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
});
if (res.ok) {
this.notes = this.notes.filter(n => n.id !== note.id);
if (this.editing && this.editing.id === note.id) {
this.showModal = false;
}
}
},
async togglePin(note) {
const csrfToken = document.querySelector('meta[name=csrf-token]').content;
const res = await fetch(`/notes/${note.id}/pin`, {
method: 'PATCH',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
});
if (res.ok) {
const updated = await res.json();
const idx = this.notes.findIndex(n => n.id === note.id);
if (idx !== -1) this.notes.splice(idx, 1, updated);
}
},
// ── Block editor helpers ──────────────────────────────
makeBlock(type = 'paragraph') {
return { id: this.nextBlockId++, type, text: '', checked: false };
},
insertBlockAfter(idx) {
const currentType = this.editorBlocks[idx]?.type ?? 'paragraph';
const newBlock = this.makeBlock(currentType);
this.editorBlocks.splice(idx + 1, 0, newBlock);
this.$nextTick(() => {
document.querySelector(`[data-block-id="${newBlock.id}"]`)?.focus();
});
},
handleBackspace(e, block, idx) {
if (block.text === '' && this.editorBlocks.length > 1) {
e.preventDefault();
this.editorBlocks.splice(idx, 1);
this.$nextTick(() => {
const target = this.editorBlocks[Math.max(0, idx - 1)];
if (target) document.querySelector(`[data-block-id="${target.id}"]`)?.focus();
});
}
},
addBlock(type) {
const nb = this.makeBlock(type);
this.editorBlocks.push(nb);
this.$nextTick(() => {
document.querySelector(`[data-block-id="${nb.id}"]`)?.focus();
});
},
removeBlock(idx) {
if (this.editorBlocks.length > 1) {
this.editorBlocks.splice(idx, 1);
}
},
changeType(idx, type) {
this.editorBlocks[idx].type = type;
},
focusFirstBlock() {
const first = this.editorBlocks[0];
if (first) {
this.$nextTick(() => {
document.querySelector(`[data-block-id="${first.id}"]`)?.focus();
});
}
},
// ── Utilities ─────────────────────────────────────────
formatDate(dt) {
if (!dt) return '';
const d = new Date(dt);
const now = new Date();
const diff = now - d;
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
},
};
}
</script>
@endpush
@endsection