Files
Kol-Manager/resources/views/notes/index.blade.php
I-Fun 1b90f730a6 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.
2026-04-02 23:24:38 +07:00

484 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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