449 lines
25 KiB
PHP
449 lines
25 KiB
PHP
@extends('layouts.app')
|
||
|
||
@section('title', 'Notes')
|
||
@section('page-title', 'Notes Management 📝')
|
||
@section('page-subtitle', 'Quick notes, checklists & reminders')
|
||
|
||
@section('content')
|
||
|
||
<div x-data="notesApp({{ json_encode($notes) }})" x-cloak>
|
||
|
||
{{-- ── Toolbar ──────────────────────────────────────────────── --}}
|
||
<div class="flex items-center justify-between 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>
|
||
|
||
{{-- Count --}}
|
||
<span class="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 is only when NO notes exist --}}
|
||
<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>
|
||
|
||
|
||
{{-- ════════════════════════════════════════════════════════════
|
||
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.notes.filter(n => n.is_pinned);
|
||
},
|
||
|
||
get filteredUnpinned() {
|
||
return this.notes.filter(n => !n.is_pinned);
|
||
},
|
||
|
||
// ── 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 |