Files
crm/apps/web/src/views/NotesView.vue
T
MahdiandCursor f43aa125ea Fix note color swatches outside teleported editor panel.
Use hex palettes so Keep-style colors render when the create panel is portaled to body.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 19:41:39 +03:30

2234 lines
58 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import UiIcon from '@/components/UiIcon.vue';
import { appendSpokenText } from '@/lib/spoken-text';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
toFaDigits,
} from '@/lib/jalali';
import type {
ActivityDto,
DealDto,
NoteAttachmentDto,
NoteAiSuggestionDto,
NoteChecklistItemDto,
NoteDto,
NoteType,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
} from '@mento/shared';
type ViewMode = 'grid' | 'list';
type SortKey = 'updatedAt' | 'createdAt' | 'reminderAt';
type ColorKey = 'sand' | 'rose' | 'sky' | 'mint' | 'lavender' | 'default';
type OpenMode = 'default' | 'image' | 'mic' | 'attach' | 'audio' | 'checklist';
const route = useRoute();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const q = ref('');
const typeFilter = ref<NoteType | 'all'>('all');
const sortKey = ref<SortKey>('updatedAt');
const viewMode = ref<ViewMode>('grid');
const notes = ref<NoteDto[]>([]);
const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const activities = ref<ActivityDto[]>([]);
const pendingAi = ref<Record<string, boolean>>({});
const menuOpenId = ref<string | null>(null);
const blobCache = ref<Record<string, string>>({});
const audioDurations = ref<Record<string, string>>({});
const editorOpen = ref(false);
const mobileEditor = ref(false);
const moreSheetOpen = ref(false);
const showChecklistEditor = ref(false);
const editingId = ref<string | null>(null);
const draftTitle = ref('');
const draftBody = ref('');
const draftType = ref<NoteType>('general');
const draftColor = ref<ColorKey>('sand');
const draftTags = ref('');
const draftPersonId = ref('');
const draftDealId = ref('');
const draftActivityId = ref('');
const draftReminder = ref('');
const draftLocation = ref('');
const draftAnalyze = ref(true);
const draftPinned = ref(false);
const draftChecklist = ref<NoteChecklistItemDto[]>([]);
const draftFiles = ref<File[]>([]);
const existingAttachments = ref<NoteAttachmentDto[]>([]);
const imageInput = ref<HTMLInputElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const audioInput = ref<HTMLInputElement | null>(null);
const bodyTextarea = ref<HTMLTextAreaElement | null>(null);
const MOBILE_MQ = '(max-width: 860px)';
const isMobile = ref(false);
let mq: MediaQueryList | null = null;
const categoryChips: Array<{ value: NoteType | 'all'; label: string; tone: string }> = [
{ value: 'all', label: 'همه', tone: 'all' },
{ value: 'general', label: 'معمولی', tone: 'general' },
{ value: 'follow_up', label: 'پیگیری', tone: 'follow_up' },
{ value: 'idea', label: 'ایده', tone: 'idea' },
{ value: 'suggestion', label: 'پیشنهاد', tone: 'suggestion' },
{ value: 'meeting', label: 'جلسه', tone: 'meeting' },
{ value: 'personal', label: 'شخصی', tone: 'personal' },
];
const noteTypeOptions: Array<{ value: NoteType; label: string }> = [
{ value: 'general', label: 'معمولی' },
{ value: 'follow_up', label: 'پیگیری' },
{ value: 'idea', label: 'ایده' },
{ value: 'suggestion', label: 'پیشنهاد' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'personal', label: 'شخصی' },
{ value: 'call_result', label: 'نتیجه تماس' },
{ value: 'important', label: 'نکته مهم' },
{ value: 'warning', label: 'هشدار' },
{ value: 'next_action', label: 'کار بعدی' },
];
const colorSwatches: Array<{ value: ColorKey; label: string }> = [
{ value: 'sand', label: 'شن' },
{ value: 'rose', label: 'گل' },
{ value: 'sky', label: 'آسمان' },
{ value: 'mint', label: 'نعناع' },
{ value: 'lavender', label: 'یاسی' },
{ value: 'default', label: 'سفید' },
];
const typeLabelMap: Record<string, string> = {
general: 'معمولی',
follow_up: 'پیگیری',
idea: 'ایده',
suggestion: 'پیشنهاد',
meeting: 'جلسه',
personal: 'شخصی',
call_result: 'نتیجه تماس',
important: 'نکته مهم',
warning: 'هشدار',
next_action: 'کار بعدی',
};
const editorTitle = computed(() => (editingId.value ? 'ویرایش' : 'یادداشت جدید'));
const draftColorCss = computed(() => colorToCss(draftColor.value));
const personName = (id: string) =>
people.value.find((p) => p.id === id)?.fullName ?? '';
/** Hex only — panel is Teleported outside `.notes-page`, so CSS vars would not inherit. */
const NOTE_COLOR_HEX: Record<ColorKey, string> = {
sand: '#fff8e1',
rose: '#fce4ec',
sky: '#e3f2fd',
mint: '#e0f2f1',
lavender: '#f3e5f5',
default: '#ffffff',
};
function colorToCss(raw: string | null | undefined): string {
return NOTE_COLOR_HEX[normalizeColor(raw)];
}
function normalizeColor(raw: string | null | undefined): ColorKey {
const c = (raw || 'default').toLowerCase();
if (c === 'yellow' || c === 'sand') return 'sand';
if (c === 'pink' || c === 'rose') return 'rose';
if (c === 'blue' || c === 'sky') return 'sky';
if (c === 'teal' || c === 'mint' || c === 'green') return 'mint';
if (c === 'purple' || c === 'lavender') return 'lavender';
if (c === 'white' || c === 'default' || !c) return 'default';
if (c === 'sand' || c === 'rose' || c === 'sky' || c === 'mint' || c === 'lavender') {
return c;
}
return 'default';
}
function typeLabel(t: string) {
return typeLabelMap[t] ?? t;
}
function typeTone(t: string) {
if (t === 'call_result' || t === 'next_action') return 'follow_up';
if (t === 'important') return 'suggestion';
if (t === 'warning') return 'follow_up';
if (
t === 'general' ||
t === 'follow_up' ||
t === 'idea' ||
t === 'suggestion' ||
t === 'meeting' ||
t === 'personal'
) {
return t;
}
return 'general';
}
function formatNoteTime(iso: string) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const days = calendarDaysBetween(d, new Date());
if (days <= 0) return `${formatClockFa(d)} امروز`;
if (days === 1) return 'دیروز';
return formatJalaliDay(d);
} catch {
return iso;
}
}
function parseTags(raw: string) {
return raw
.split(/[,\s#]+/)
.map((t) => t.trim())
.filter(Boolean);
}
function newChecklistItem(text = ''): NoteChecklistItemDto {
return {
id: crypto.randomUUID().slice(0, 12),
text,
done: false,
};
}
function previewBody(body: string) {
const t = body.trim();
if (t.length <= 160) return t;
return `${t.slice(0, 160)}…`;
}
function isImageAtt(att: NoteAttachmentDto) {
return att.mimeType.startsWith('image/');
}
function isPdfAtt(att: NoteAttachmentDto) {
return att.mimeType === 'application/pdf' || /\.pdf$/i.test(att.fileName);
}
function isAudioAtt(att: NoteAttachmentDto) {
return att.mimeType.startsWith('audio/');
}
function firstImage(note: NoteDto) {
return note.attachments.find(isImageAtt) ?? null;
}
function firstPdf(note: NoteDto) {
return note.attachments.find(isPdfAtt) ?? null;
}
function firstAudio(note: NoteDto) {
return note.attachments.find(isAudioAtt) ?? null;
}
function blobKey(noteId: string, attId: string) {
return `${noteId}:${attId}`;
}
async function ensureBlob(noteId: string, att: NoteAttachmentDto) {
const key = blobKey(noteId, att.id);
if (blobCache.value[key]) return blobCache.value[key];
const { data } = await api.get<Blob>(
`/notes/${noteId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
);
const url = URL.createObjectURL(data);
blobCache.value = { ...blobCache.value, [key]: url };
return url;
}
async function prefetchCardMedia(items: NoteDto[]) {
for (const note of items) {
const img = firstImage(note);
const audio = firstAudio(note);
try {
if (img) await ensureBlob(note.id, img);
if (audio) {
const url = await ensureBlob(note.id, audio);
probeAudioDuration(blobKey(note.id, audio.id), url);
}
} catch {
/* ignore missing blobs */
}
}
}
function probeAudioDuration(key: string, url: string) {
const el = new Audio(url);
el.addEventListener('loadedmetadata', () => {
const sec = Math.round(el.duration || 0);
if (!Number.isFinite(sec) || sec <= 0) return;
const mm = String(Math.floor(sec / 60)).padStart(2, '0');
const ss = String(sec % 60).padStart(2, '0');
audioDurations.value = {
...audioDurations.value,
[key]: toFaDigits(`${mm}:${ss}`),
};
});
}
function revokeAllBlobs() {
for (const url of Object.values(blobCache.value)) {
URL.revokeObjectURL(url);
}
blobCache.value = {};
}
async function refreshAiBadges(items: NoteDto[]) {
const slice = items.slice(0, 40);
const entries = await Promise.all(
slice.map(async (n) => {
try {
const { data } = await api.get<NoteAiSuggestionDto | null>(
`/notes/${n.id}/ai-suggestion`,
);
return [n.id, data?.status === 'pending'] as const;
} catch {
return [n.id, false] as const;
}
}),
);
const map: Record<string, boolean> = { ...pendingAi.value };
for (const [id, pending] of entries) map[id] = pending;
pendingAi.value = map;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [notesRes, peopleRes, boardRes] = await Promise.all([
api.get<PaginatedResponse<NoteDto>>('/notes', {
params: {
q: q.value || undefined,
noteType: typeFilter.value === 'all' ? undefined : typeFilter.value,
sort: sortKey.value,
limit: 100,
},
}),
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
notes.value = notesRes.data.items;
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
void prefetchCardMedia(notes.value);
void refreshAiBadges(notes.value);
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری یادداشت‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function loadActivities() {
try {
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { limit: 50 },
});
activities.value = data.items;
} catch {
activities.value = [];
}
}
function resetDraft() {
draftTitle.value = '';
draftBody.value = '';
draftType.value = 'general';
draftColor.value = 'sand';
draftTags.value = '';
draftPersonId.value = typeof route.query.personId === 'string' ? route.query.personId : '';
draftDealId.value = '';
draftActivityId.value = '';
draftReminder.value = '';
draftLocation.value = '';
draftAnalyze.value = true;
draftPinned.value = false;
draftChecklist.value = [];
draftFiles.value = [];
existingAttachments.value = [];
showChecklistEditor.value = false;
editingId.value = null;
}
function openEditor(mode: OpenMode = 'default', note?: NoteDto) {
menuOpenId.value = null;
if (note) {
editingId.value = note.id;
draftTitle.value = note.title ?? '';
draftBody.value = note.body;
draftType.value = (note.noteType as NoteType) || 'general';
draftColor.value = normalizeColor(note.color);
draftTags.value = note.tags.join(' ');
draftPersonId.value = note.person?.id ?? '';
draftDealId.value = note.deal?.id ?? '';
draftActivityId.value = note.activity?.id ?? '';
draftReminder.value = note.reminderAt ? note.reminderAt.slice(0, 16) : '';
draftLocation.value = note.locationText ?? '';
draftAnalyze.value = note.analyzeEnabled !== false;
draftPinned.value = note.pinned;
draftChecklist.value = [...(note.checklist ?? [])];
draftFiles.value = [];
existingAttachments.value = [...note.attachments];
showChecklistEditor.value = (note.checklist?.length ?? 0) > 0;
} else {
resetDraft();
if (mode === 'checklist') {
showChecklistEditor.value = true;
draftChecklist.value = [newChecklistItem()];
}
}
mobileEditor.value = isMobile.value;
editorOpen.value = true;
moreSheetOpen.value = false;
void nextTick(() => {
bodyTextarea.value?.focus();
if (mode === 'image') imageInput.value?.click();
if (mode === 'attach') fileInput.value?.click();
if (mode === 'audio') audioInput.value?.click();
});
}
function closeEditor() {
editorOpen.value = false;
mobileEditor.value = false;
moreSheetOpen.value = false;
resetDraft();
}
function onFilesSelected(event: Event, kind: 'image' | 'file' | 'audio') {
const input = event.target as HTMLInputElement;
const picked = Array.from(input.files ?? []);
input.value = '';
if (!picked.length) return;
const filtered =
kind === 'image'
? picked.filter((f) => f.type.startsWith('image/'))
: kind === 'audio'
? picked.filter((f) => f.type.startsWith('audio/') || !f.type)
: picked;
draftFiles.value = [...draftFiles.value, ...filtered].slice(0, 8);
}
function removeDraftFile(index: number) {
draftFiles.value = draftFiles.value.filter((_, i) => i !== index);
}
async function removeExistingAttachment(attachmentId: string) {
if (!editingId.value) return;
if (!confirm('این پیوست حذف شود؟')) return;
await api.delete(`/notes/${editingId.value}/attachments/${attachmentId}`);
existingAttachments.value = existingAttachments.value.filter((a) => a.id !== attachmentId);
await load();
}
async function uploadFiles(noteId: string, files: File[]) {
for (const file of files) {
const form = new FormData();
form.append('file', file);
await api.post(`/notes/${noteId}/attachments`, form);
}
}
function addChecklistRow() {
draftChecklist.value = [...draftChecklist.value, newChecklistItem()];
}
function removeChecklistRow(id: string) {
draftChecklist.value = draftChecklist.value.filter((c) => c.id !== id);
}
function toggleDraftCheck(id: string) {
draftChecklist.value = draftChecklist.value.map((c) =>
c.id === id ? { ...c, done: !c.done } : c,
);
}
async function toggleCardChecklist(note: NoteDto, itemId: string) {
const checklist = (note.checklist ?? []).map((c) =>
c.id === itemId ? { ...c, done: !c.done } : c,
);
note.checklist = checklist;
try {
await api.patch(`/notes/${note.id}`, { checklist });
} catch {
await load();
}
}
async function saveNote() {
const body = draftBody.value.trim();
const checklist = draftChecklist.value.filter((c) => c.text.trim());
if (!body && !checklist.length) {
error.value = 'متن یادداشت یا چک‌لیست لازم است.';
return;
}
saving.value = true;
error.value = '';
try {
const payload = {
title: draftTitle.value.trim() || null,
body: body || (checklist.length ? 'چک‌لیست' : ''),
noteType: draftType.value,
color: draftColor.value === 'default' ? 'default' : draftColor.value,
tags: parseTags(draftTags.value),
personId: draftPersonId.value || null,
dealId: draftDealId.value || null,
activityId: draftActivityId.value || null,
reminderAt: draftReminder.value
? new Date(draftReminder.value).toISOString()
: null,
locationText: draftLocation.value.trim() || null,
analyzeEnabled: draftAnalyze.value,
pinned: draftPinned.value,
checklist,
};
const pending = [...draftFiles.value];
let noteId = editingId.value;
if (editingId.value) {
await api.patch(`/notes/${editingId.value}`, payload);
} else {
const { data } = await api.post<NoteDto>('/notes', {
...payload,
title: draftTitle.value.trim() || undefined,
});
noteId = data.id;
}
if (noteId && pending.length) {
await uploadFiles(noteId, pending);
draftFiles.value = [];
}
if (noteId && draftAnalyze.value && body.length >= 8) {
void api.post(`/notes/${noteId}/analyze`).catch(() => undefined);
}
closeEditor();
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره یادداشت ناموفق بود');
} else {
error.value = 'ذخیره یادداشت ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function togglePin(id: string) {
menuOpenId.value = null;
await api.patch(`/notes/${id}/pin`);
await load();
if (editingId.value === id) {
const n = notes.value.find((x) => x.id === id);
if (n) draftPinned.value = n.pinned;
}
}
async function onPinClick() {
const next = !draftPinned.value;
draftPinned.value = next;
if (!editingId.value) return;
const note = notes.value.find((n) => n.id === editingId.value);
if (note && note.pinned === next) return;
try {
await api.patch(`/notes/${editingId.value}/pin`);
if (note) note.pinned = next;
} catch {
draftPinned.value = !next;
}
}
async function removeNote(id: string) {
menuOpenId.value = null;
if (!confirm('این یادداشت حذف شود؟')) return;
await api.delete(`/notes/${id}`);
if (editingId.value === id) closeEditor();
await load();
}
function promptLocation() {
const next = window.prompt('موقعیت مکانی', draftLocation.value || '');
if (next == null) return;
draftLocation.value = next.trim();
}
function openMoreSheet() {
moreSheetOpen.value = true;
if (!activities.value.length) void loadActivities();
}
function syncMobileFlag() {
isMobile.value = mq?.matches ?? false;
}
function onDocClick(ev: MouseEvent) {
const t = ev.target as HTMLElement | null;
if (!t?.closest?.('.card-menu')) menuOpenId.value = null;
}
watch(typeFilter, () => {
void load();
});
watch(sortKey, () => {
void load();
});
onMounted(() => {
mq = window.matchMedia(MOBILE_MQ);
syncMobileFlag();
mq.addEventListener('change', syncMobileFlag);
document.addEventListener('click', onDocClick);
if (typeof route.query.q === 'string' && route.query.q) q.value = route.query.q;
if (typeof route.query.personId === 'string' && route.query.personId) {
draftPersonId.value = route.query.personId;
}
void load();
void loadActivities();
});
onBeforeUnmount(() => {
mq?.removeEventListener('change', syncMobileFlag);
document.removeEventListener('click', onDocClick);
revokeAllBlobs();
});
</script>
<template>
<section class="notes-page">
<header class="page-head">
<div>
<h1>یادداشت‌ها</h1>
<p class="lead">فکر کنید، یادداشت کنید، هیچ چیز را فراموش نکنید.</p>
</div>
</header>
<p v-if="error" class="banner-error">{{ error }}</p>
<!-- Desktop toolbar -->
<div class="toolbar desktop-only">
<div class="chips">
<button
v-for="chip in categoryChips"
:key="chip.value"
type="button"
class="chip"
:class="[`tone-${chip.tone}`, { active: typeFilter === chip.value }]"
@click="typeFilter = chip.value"
>
<span v-if="chip.value !== 'all'" class="dot" />
{{ chip.label }}
</button>
</div>
<div class="toolbar-end">
<div class="view-toggle" role="group" aria-label="نمای لیست">
<button
type="button"
:class="{ active: viewMode === 'grid' }"
title="شبکه"
@click="viewMode = 'grid'"
>
<UiIcon name="grid" :size="18" />
</button>
<button
type="button"
:class="{ active: viewMode === 'list' }"
title="لیست"
@click="viewMode = 'list'"
>
<UiIcon name="list" :size="18" />
</button>
</div>
<select v-model="sortKey" class="sort-select" aria-label="مرتب‌سازی">
<option value="updatedAt">جدیدترین</option>
<option value="createdAt">تاریخ ایجاد</option>
<option value="reminderAt">یادآوری</option>
</select>
<button type="button" class="icon-btn" title="فیلتر" @click="q = q; load()">
<UiIcon name="filter" :size="18" />
</button>
</div>
</div>
<!-- Desktop quick-add -->
<div class="quick-add desktop-only" @click="openEditor()">
<input
class="quick-input"
type="text"
readonly
placeholder="یادداشتی بنویسید..."
@focus.prevent="openEditor()"
/>
<div class="quick-actions" @click.stop>
<button type="button" class="icon-btn" title="تصویر" @click="openEditor('image')">
<UiIcon name="layers" :size="18" />
</button>
<button type="button" class="icon-btn" title="صدا" @click="openEditor('mic')">
<UiIcon name="mic" :size="18" />
</button>
<button type="button" class="icon-btn" title="پیوست" @click="openEditor('attach')">
<UiIcon name="document" :size="18" />
</button>
</div>
</div>
<!-- Mobile quick bar -->
<div class="mobile-quick mobile-only">
<button type="button" class="plus-teal" @click="openEditor()">
<UiIcon name="plus" :size="20" />
</button>
<input
class="quick-input"
type="text"
readonly
placeholder="یادداشتی بنویسید..."
@focus.prevent="openEditor()"
/>
<button type="button" class="icon-btn" @click="openEditor('image')">
<UiIcon name="layers" :size="18" />
</button>
<button type="button" class="icon-btn" @click="openEditor('mic')">
<UiIcon name="mic" :size="18" />
</button>
<button type="button" class="icon-btn" @click="openEditor('attach')">
<UiIcon name="document" :size="18" />
</button>
</div>
<!-- Mobile chips -->
<div class="chips chips-scroll mobile-only">
<button
v-for="chip in categoryChips"
:key="`m-${chip.value}`"
type="button"
class="chip"
:class="[`tone-${chip.tone}`, { active: typeFilter === chip.value }]"
@click="typeFilter = chip.value"
>
<span v-if="chip.value !== 'all'" class="dot" />
{{ chip.label }}
</button>
</div>
<p v-if="loading" class="state-msg">در حال بارگذاری…</p>
<p v-else-if="!notes.length" class="state-msg">هنوز یادداشتی نیست — یکی اضافه کنید.</p>
<div
v-else
class="notes-board"
:class="viewMode === 'list' ? 'is-list' : 'is-grid'"
>
<article
v-for="note in notes"
:key="note.id"
class="note-card"
:style="{ background: colorToCss(note.color) }"
@click="openEditor('default', note)"
>
<div class="card-top">
<span v-if="note.pinned" class="pin" title="سنجاق‌شده" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path
d="M14.5 3.5 16 5l-1.2 1.2 3.7 3.7 1.3-1.3 1.4 1.4-7.8 7.8-1.4-1.4 1.3-1.3-3.7-3.7L8 13.5 6.5 12l8-8.5ZM7 18l-3 3"
/>
</svg>
</span>
<div class="card-menu" @click.stop>
<button
type="button"
class="icon-btn menu-btn"
@click="menuOpenId = menuOpenId === note.id ? null : note.id"
>
<UiIcon name="dots-v" :size="18" />
</button>
<div v-if="menuOpenId === note.id" class="menu-pop">
<button type="button" @click="openEditor('default', note)">ویرایش</button>
<button type="button" @click="togglePin(note.id)">
{{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }}
</button>
<button type="button" class="danger" @click="removeNote(note.id)">حذف</button>
</div>
</div>
</div>
<h3 v-if="note.title" class="card-title">{{ note.title }}</h3>
<ul v-if="note.checklist?.length" class="card-check" @click.stop>
<li v-for="item in note.checklist" :key="item.id">
<label>
<input
type="checkbox"
:checked="item.done"
@change="toggleCardChecklist(note, item.id)"
/>
<span :class="{ done: item.done }">{{ item.text }}</span>
</label>
</li>
</ul>
<p v-else-if="note.body" class="card-body">{{ previewBody(note.body) }}</p>
<div v-if="firstImage(note)" class="card-thumb">
<img
v-if="blobCache[blobKey(note.id, firstImage(note)!.id)]"
:src="blobCache[blobKey(note.id, firstImage(note)!.id)]"
:alt="firstImage(note)!.fileName"
/>
</div>
<div v-if="firstPdf(note)" class="att-chip pdf">
<UiIcon name="document" :size="14" />
<span>{{ firstPdf(note)!.fileName }}</span>
</div>
<div
v-if="firstAudio(note) && blobCache[blobKey(note.id, firstAudio(note)!.id)]"
class="audio-row"
@click.stop
>
<audio
controls
preload="metadata"
:src="blobCache[blobKey(note.id, firstAudio(note)!.id)]"
/>
<span
v-if="audioDurations[blobKey(note.id, firstAudio(note)!.id)]"
class="dur"
>
{{ audioDurations[blobKey(note.id, firstAudio(note)!.id)] }}
</span>
</div>
<div class="card-meta">
<span class="type-badge" :class="`tone-${typeTone(String(note.noteType))}`">
{{ typeLabel(String(note.noteType)) }}
</span>
<span v-if="pendingAi[note.id]" class="ai-badge" title="پیشنهاد AI">
<UiIcon name="sparkle" :size="12" />
AI
</span>
</div>
<footer class="card-foot">
<time>{{ formatNoteTime(note.updatedAt) }}</time>
<span v-if="note.person" class="person">{{ note.person.fullName }}</span>
</footer>
</article>
<button
type="button"
class="note-card add-card desktop-only"
@click="openEditor()"
>
<UiIcon name="plus" :size="22" />
<span>یادداشت جدید</span>
</button>
</div>
<!-- Mobile FAB -->
<button
v-if="!editorOpen"
type="button"
class="fab mobile-only"
aria-label="یادداشت جدید"
@click="openEditor()"
>
<UiIcon name="plus" :size="24" />
</button>
<!-- Hidden file inputs -->
<input
ref="imageInput"
type="file"
accept="image/*"
multiple
hidden
@change="onFilesSelected($event, 'image')"
/>
<input
ref="fileInput"
type="file"
multiple
hidden
@change="onFilesSelected($event, 'file')"
/>
<input
ref="audioInput"
type="file"
accept="audio/*"
multiple
hidden
@change="onFilesSelected($event, 'audio')"
/>
<!-- Desktop slide-over (RTL: start = left) -->
<Teleport to="body">
<div
v-if="editorOpen && !mobileEditor"
class="panel-root desktop-panel"
>
<div class="panel-backdrop" @click="closeEditor" />
<aside
class="editor-panel"
:style="{
'--canvas': draftColorCss,
background: draftColorCss,
}"
>
<header class="panel-head">
<h2>{{ editorTitle }}</h2>
<button type="button" class="icon-btn" @click="closeEditor">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="panel-scroll">
<input
v-model="draftTitle"
class="title-input"
type="text"
placeholder="عنوان (اختیاری)"
/>
<textarea
ref="bodyTextarea"
v-model="draftBody"
class="body-canvas"
rows="8"
placeholder="یادداشت خود را بنویسید…"
/>
<div class="palette-row">
<div class="swatches" role="listbox" aria-label="رنگ یادداشت">
<button
v-for="sw in colorSwatches"
:key="sw.value"
type="button"
class="swatch"
:class="[
`tone-${sw.value}`,
{ selected: draftColor === sw.value },
]"
:style="{ backgroundColor: NOTE_COLOR_HEX[sw.value] }"
:title="sw.label"
:aria-label="sw.label"
:aria-selected="draftColor === sw.value"
@click="draftColor = sw.value"
>
<UiIcon
v-if="draftColor === sw.value"
name="check-plain"
:size="14"
/>
</button>
</div>
<button
type="button"
class="media-btn pin-btn"
:class="{ on: draftPinned }"
title="سنجاق"
@click="onPinClick"
>
سنجاق
</button>
</div>
<div class="media-row">
<button type="button" class="media-btn" @click="imageInput?.click()">
<UiIcon name="layers" :size="18" /> تصویر
</button>
<button type="button" class="media-btn" @click="fileInput?.click()">
<UiIcon name="document" :size="18" /> فایل
</button>
<button type="button" class="media-btn" @click="audioInput?.click()">
<UiIcon name="mic" :size="18" /> صدا
</button>
<button
type="button"
class="media-btn"
:class="{ on: showChecklistEditor }"
@click="
showChecklistEditor = !showChecklistEditor;
if (showChecklistEditor && !draftChecklist.length)
draftChecklist = [newChecklistItem()];
"
>
<UiIcon name="check" :size="18" /> چک‌لیست
</button>
<VoiceToTextButton
variant="toolbar"
hide-error
@transcript="(t) => (draftBody = appendSpokenText(draftBody, t))"
/>
</div>
<div v-if="showChecklistEditor" class="checklist-ed">
<div
v-for="(item, idx) in draftChecklist"
:key="item.id"
class="check-row"
>
<input
type="checkbox"
:checked="item.done"
@change="toggleDraftCheck(item.id)"
/>
<input
v-model="draftChecklist[idx]!.text"
type="text"
placeholder="مورد چک‌لیست"
/>
<button type="button" class="icon-btn" @click="removeChecklistRow(item.id)">
<UiIcon name="x" :size="16" />
</button>
</div>
<button type="button" class="linkish" @click="addChecklistRow">+ مورد جدید</button>
</div>
<div v-if="draftFiles.length || existingAttachments.length" class="pending-files">
<div v-for="(f, i) in draftFiles" :key="`df-${i}`" class="file-chip">
<span>{{ f.name }}</span>
<button type="button" @click="removeDraftFile(i)">×</button>
</div>
<div
v-for="att in existingAttachments"
:key="att.id"
class="file-chip"
>
<span>{{ att.fileName }}</span>
<button type="button" @click="removeExistingAttachment(att.id)">×</button>
</div>
</div>
<label class="field">
<span>نوع یادداشت</span>
<select v-model="draftType">
<option
v-for="opt in noteTypeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</label>
<div class="field-row">
<label class="field">
<span>مرتبط با — مشتری</span>
<select v-model="draftPersonId">
<option value="">—</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
</label>
<label class="field">
<span>معامله</span>
<select v-model="draftDealId">
<option value="">—</option>
<option v-for="d in deals" :key="d.id" :value="d.id">
{{ d.title }}
</option>
</select>
</label>
</div>
<label class="field">
<span>یادآوری</span>
<JalaliDateInput v-model="draftReminder" mode="datetime" icon="bell" />
</label>
<label class="field">
<span>برچسب‌ها</span>
<input
v-model="draftTags"
type="text"
placeholder="مثلاً قیمت جلسه"
/>
</label>
<label class="field">
<span>موقعیت</span>
<input v-model="draftLocation" type="text" placeholder="اختیاری" />
</label>
<label class="ai-toggle">
<input v-model="draftAnalyze" type="checkbox" />
<div>
<strong>تحلیل با هوش مصنوعی</strong>
<p>پس از ذخیره، پیشنهاد کار بعدی و حافظه مشتری استخراج می‌شود.</p>
</div>
</label>
<NoteAiSuggestionPanel
v-if="editingId"
:note-id="editingId"
:has-person="Boolean(draftPersonId)"
@applied="load"
@dismissed="load"
/>
</div>
<footer class="panel-foot">
<button
type="button"
class="btn-primary"
:disabled="saving"
@click="saveNote"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره یادداشت' }}
</button>
<button type="button" class="btn-ghost" @click="closeEditor">لغو</button>
</footer>
</aside>
</div>
</Teleport>
<!-- Mobile full-screen editor -->
<Teleport to="body">
<div
v-if="editorOpen && mobileEditor"
class="mobile-editor"
:style="{ '--canvas': draftColorCss, background: draftColorCss }"
>
<header class="mob-head">
<button type="button" class="icon-btn" @click="closeEditor">
<UiIcon name="x" :size="22" />
</button>
<button
type="button"
class="btn-save"
:disabled="saving"
@click="saveNote"
>
{{ saving ? '…' : 'ذخیره' }}
</button>
</header>
<div class="mob-canvas">
<input
v-model="draftTitle"
class="title-input"
type="text"
placeholder="عنوان (اختیاری)"
/>
<textarea
ref="bodyTextarea"
v-model="draftBody"
class="body-canvas"
placeholder="یادداشت خود را بنویسید…"
/>
<div class="palette-row">
<div class="swatches" role="listbox" aria-label="رنگ یادداشت">
<button
v-for="sw in colorSwatches"
:key="`m-${sw.value}`"
type="button"
class="swatch"
:class="[`tone-${sw.value}`, { selected: draftColor === sw.value }]"
:style="{ backgroundColor: NOTE_COLOR_HEX[sw.value] }"
:title="sw.label"
:aria-label="sw.label"
:aria-selected="draftColor === sw.value"
@click="draftColor = sw.value"
>
<UiIcon
v-if="draftColor === sw.value"
name="check-plain"
:size="14"
/>
</button>
</div>
<button
type="button"
class="media-btn pin-btn"
:class="{ on: draftPinned }"
@click="onPinClick"
>
سنجاق
</button>
</div>
<div v-if="showChecklistEditor" class="checklist-ed">
<div
v-for="(item, idx) in draftChecklist"
:key="item.id"
class="check-row"
>
<input
type="checkbox"
:checked="item.done"
@change="toggleDraftCheck(item.id)"
/>
<input v-model="draftChecklist[idx]!.text" type="text" placeholder="مورد" />
<button type="button" class="icon-btn" @click="removeChecklistRow(item.id)">
<UiIcon name="x" :size="16" />
</button>
</div>
<button type="button" class="linkish" @click="addChecklistRow">+ مورد جدید</button>
</div>
<div class="chip-summary">
<span v-for="(f, i) in draftFiles" :key="`mf-${i}`" class="tag-chip">{{ f.name }}</span>
<span v-for="t in parseTags(draftTags)" :key="t" class="tag-chip">#{{ t }}</span>
<span v-if="draftPersonId" class="tag-chip">{{ personName(draftPersonId) }}</span>
<span v-if="draftDealId" class="tag-chip">معامله</span>
<span v-if="draftLocation" class="tag-chip">{{ draftLocation }}</span>
</div>
<NoteAiSuggestionPanel
v-if="editingId"
:note-id="editingId"
:has-person="Boolean(draftPersonId)"
@applied="load"
@dismissed="load"
/>
</div>
<nav class="mob-toolbar">
<button
type="button"
@click="
showChecklistEditor = !showChecklistEditor;
if (showChecklistEditor && !draftChecklist.length)
draftChecklist = [newChecklistItem()];
"
>
<UiIcon name="check" :size="20" />
<span>چک‌لیست</span>
</button>
<div class="voice-slot">
<VoiceToTextButton
variant="toolbar"
hide-error
@transcript="(t) => (draftBody = appendSpokenText(draftBody, t))"
/>
<button type="button" class="tiny" @click="audioInput?.click()">فایل صدا</button>
</div>
<button type="button" @click="imageInput?.click()">
<UiIcon name="layers" :size="20" />
<span>عکس</span>
</button>
<button type="button" @click="fileInput?.click()">
<UiIcon name="document" :size="20" />
<span>فایل</span>
</button>
<button type="button" @click="promptLocation">
<UiIcon name="flag" :size="20" />
<span>موقعیت</span>
</button>
<button type="button" @click="openMoreSheet">
<UiIcon name="dots" :size="20" />
<span>بیشتر</span>
</button>
</nav>
<div v-if="moreSheetOpen" class="sheet-root">
<div class="sheet-backdrop" @click="moreSheetOpen = false" />
<div class="sheet">
<header>
<h3>جزئیات بیشتر</h3>
<button type="button" class="icon-btn" @click="moreSheetOpen = false">
<UiIcon name="x" :size="18" />
</button>
</header>
<label class="field">
<span>انتخاب مشتری</span>
<select v-model="draftPersonId">
<option value="">—</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</label>
<label class="field">
<span>معامله</span>
<select v-model="draftDealId">
<option value="">—</option>
<option v-for="d in deals" :key="d.id" :value="d.id">{{ d.title }}</option>
</select>
</label>
<label class="field">
<span>فعالیت</span>
<select v-model="draftActivityId">
<option value="">—</option>
<option v-for="a in activities" :key="a.id" :value="a.id">{{ a.title }}</option>
</select>
</label>
<label class="field">
<span>نوع یادداشت</span>
<select v-model="draftType">
<option
v-for="opt in noteTypeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</label>
<label class="field">
<span>یادآوری</span>
<JalaliDateInput v-model="draftReminder" mode="datetime" icon="bell" />
</label>
<label class="field">
<span>برچسب‌ها</span>
<input v-model="draftTags" type="text" placeholder="برچسب‌ها" />
</label>
<label class="ai-toggle">
<input v-model="draftAnalyze" type="checkbox" />
<div>
<strong>تحلیل با هوش مصنوعی</strong>
<p>پس از ذخیره اجرا می‌شود.</p>
</div>
</label>
<button type="button" class="btn-primary sheet-done" @click="moreSheetOpen = false">
تأیید
</button>
</div>
</div>
</div>
</Teleport>
</section>
</template>
<style scoped>
.notes-page {
--note-sand: #fff8e1;
--note-rose: #fce4ec;
--note-sky: #e3f2fd;
--note-mint: #e0f2f1;
--note-lavender: #f3e5f5;
--note-default: #ffffff;
--teal: #0d9488;
--teal-soft: #ccfbf1;
--radius: 15px;
--shadow: 0 4px 18px rgba(15, 23, 42, 0.07);
max-width: 1200px;
margin: 0 auto;
padding: 0.5rem 0 4rem;
}
.page-head h1 {
margin: 0;
font-size: 1.55rem;
font-weight: 800;
color: #0f172a;
}
.lead {
margin: 0.35rem 0 0;
color: #64748b;
font-size: 0.95rem;
}
.banner-error {
margin: 0.75rem 0 0;
padding: 0.65rem 0.85rem;
border-radius: 12px;
background: #fef2f2;
color: #b91c1c;
font-size: 0.9rem;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin-top: 1.1rem;
}
.toolbar-end {
display: flex;
align-items: center;
gap: 0.45rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chips-scroll {
flex-wrap: nowrap;
overflow-x: auto;
margin: 0.85rem -0.25rem 0;
padding: 0 0.25rem 0.35rem;
-webkit-overflow-scrolling: touch;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.chip .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.chip.tone-all.active {
background: var(--teal);
border-color: var(--teal);
color: #fff;
}
.chip.tone-general {
color: #64748b;
}
.chip.tone-general.active {
background: #ecfdf5;
border-color: #6ee7b7;
color: #047857;
}
.chip.tone-follow_up {
color: #dc2626;
}
.chip.tone-follow_up.active {
background: #fef2f2;
border-color: #fca5a5;
}
.chip.tone-idea {
color: #db2777;
}
.chip.tone-idea.active {
background: #fdf2f8;
border-color: #f9a8d4;
}
.chip.tone-suggestion {
color: #d97706;
}
.chip.tone-suggestion.active {
background: #fffbeb;
border-color: #fcd34d;
}
.chip.tone-meeting {
color: #2563eb;
}
.chip.tone-meeting.active {
background: #eff6ff;
border-color: #93c5fd;
}
.chip.tone-personal {
color: #7c3aed;
}
.chip.tone-personal.active {
background: #f5f3ff;
border-color: #c4b5fd;
}
.view-toggle {
display: inline-flex;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.view-toggle button {
border: 0;
background: transparent;
padding: 0.35rem 0.45rem;
color: #64748b;
cursor: pointer;
}
.view-toggle button.active {
background: var(--teal-soft);
color: var(--teal);
}
.sort-select {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.35rem 0.55rem;
background: #fff;
color: #334155;
font-size: 0.85rem;
}
.icon-btn {
border: 0;
background: transparent;
color: #64748b;
border-radius: 10px;
padding: 0.35rem;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: #f1f5f9;
color: #0f172a;
}
.quick-add {
margin-top: 0.95rem;
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.45rem 0.55rem 0.45rem 0.85rem;
cursor: text;
}
.quick-input {
flex: 1;
border: 0;
outline: none;
background: transparent;
font-size: 0.95rem;
color: #0f172a;
cursor: pointer;
}
.quick-actions {
display: flex;
gap: 0.15rem;
}
.mobile-quick {
margin-top: 0.9rem;
display: flex;
align-items: center;
gap: 0.35rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.4rem;
}
.plus-teal {
width: 38px;
height: 38px;
border: 0;
border-radius: 12px;
background: var(--teal);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.state-msg {
margin: 1.5rem 0;
color: #64748b;
text-align: center;
}
.notes-board.is-grid {
margin-top: 1.1rem;
column-count: 3;
column-gap: 0.9rem;
}
.notes-board.is-list {
margin-top: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.notes-board.is-list .note-card {
break-inside: auto;
width: 100%;
}
.note-card {
break-inside: avoid;
margin-bottom: 0.9rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid rgba(15, 23, 42, 0.05);
padding: 0.85rem 0.9rem 0.75rem;
cursor: pointer;
position: relative;
text-align: start;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.note-card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
}
.add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 140px;
width: 100%;
background: transparent;
border: 1.5px dashed #cbd5e1;
color: #64748b;
box-shadow: none;
font-size: 0.92rem;
}
.add-card:hover {
border-color: var(--teal);
color: var(--teal);
background: #f0fdfa;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
min-height: 1.25rem;
margin-bottom: 0.25rem;
}
.pin {
color: #ef4444;
display: inline-flex;
}
.card-menu {
position: relative;
margin-inline-start: auto;
}
.menu-pop {
position: absolute;
inset-inline-end: 0;
top: 100%;
z-index: 5;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: var(--shadow);
min-width: 140px;
padding: 0.25rem;
display: flex;
flex-direction: column;
}
.menu-pop button {
border: 0;
background: transparent;
text-align: start;
padding: 0.45rem 0.65rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
color: #334155;
}
.menu-pop button:hover {
background: #f8fafc;
}
.menu-pop .danger {
color: #dc2626;
}
.card-title {
margin: 0 0 0.35rem;
font-size: 1rem;
font-weight: 700;
color: #0f172a;
}
.card-body {
margin: 0;
white-space: pre-wrap;
color: #334155;
font-size: 0.9rem;
line-height: 1.55;
}
.card-check {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.card-check label {
display: flex;
gap: 0.45rem;
align-items: flex-start;
font-size: 0.88rem;
color: #334155;
cursor: pointer;
}
.card-check .done {
text-decoration: line-through;
color: #94a3b8;
}
.card-thumb {
margin-top: 0.65rem;
border-radius: 12px;
overflow: hidden;
max-height: 160px;
}
.card-thumb img {
display: block;
width: 100%;
height: 160px;
object-fit: cover;
}
.att-chip {
margin-top: 0.55rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.5rem;
border-radius: 8px;
background: rgba(15, 23, 42, 0.06);
font-size: 0.75rem;
color: #475569;
max-width: 100%;
}
.att-chip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-row {
margin-top: 0.55rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.audio-row audio {
width: 100%;
height: 32px;
}
.audio-row .dur {
font-size: 0.75rem;
color: #64748b;
flex-shrink: 0;
}
.card-meta {
margin-top: 0.7rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.type-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
}
.type-badge.tone-general {
background: #ecfdf5;
color: #047857;
}
.type-badge.tone-follow_up {
background: #fef2f2;
color: #dc2626;
}
.type-badge.tone-idea {
background: #fdf2f8;
color: #db2777;
}
.type-badge.tone-suggestion {
background: #fffbeb;
color: #d97706;
}
.type-badge.tone-meeting {
background: #eff6ff;
color: #2563eb;
}
.type-badge.tone-personal {
background: #f5f3ff;
color: #7c3aed;
}
.ai-badge {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.7rem;
font-weight: 700;
color: var(--teal);
background: var(--teal-soft);
border-radius: 999px;
padding: 0.15rem 0.4rem;
}
.card-foot {
margin-top: 0.55rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.75rem;
color: #94a3b8;
}
.card-foot .person {
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fab {
position: fixed;
inset-inline-end: 1.1rem;
bottom: 1.25rem;
z-index: 40;
width: 54px;
height: 54px;
border: 0;
border-radius: 50%;
background: var(--teal);
color: #fff;
box-shadow: 0 10px 28px rgba(13, 148, 136, 0.4);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.panel-root {
position: fixed;
inset: 0;
z-index: 80;
}
.panel-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.35);
}
.editor-panel {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: min(420px, 94vw);
background: #fff;
box-shadow: 8px 0 40px rgba(15, 23, 42, 0.18);
display: flex;
flex-direction: column;
animation: slide-in 0.22s ease;
}
@keyframes slide-in {
from {
transform: translateX(-12px);
opacity: 0.6;
}
to {
transform: none;
opacity: 1;
}
}
.panel-head,
.mob-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid #e2e8f0;
}
.panel-head h2 {
margin: 0;
font-size: 1.05rem;
}
.panel-scroll {
flex: 1;
overflow: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.panel-foot {
display: flex;
gap: 0.5rem;
padding: 0.85rem 1rem;
border-top: 1px solid #e2e8f0;
}
.title-input {
border: 0;
border-bottom: 1px solid #e2e8f0;
padding: 0.35rem 0;
font-size: 1rem;
font-weight: 600;
outline: none;
background: transparent;
}
.body-canvas {
width: 100%;
border: 0;
border-radius: 14px;
padding: 0.85rem;
resize: vertical;
min-height: 160px;
background: var(--canvas, var(--note-sand));
color: #0f172a;
font-size: 0.98rem;
line-height: 1.6;
outline: none;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
}
.swatches {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.palette-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
flex-wrap: wrap;
}
.swatch {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1.5px solid rgba(15, 23, 42, 0.18);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: #0f766e;
padding: 0;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.swatch.tone-default {
border-color: #94a3b8;
background-image: linear-gradient(
45deg,
#f1f5f9 25%,
transparent 25%,
transparent 75%,
#f1f5f9 75%
);
background-size: 8px 8px;
}
.swatch.selected {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--teal, #0d9488);
}
.pin-btn.on {
border-color: #f59e0b;
color: #b45309;
background: #fffbeb;
}
.panel-root {
--note-sand: #fff8e1;
--note-rose: #fce4ec;
--note-sky: #e3f2fd;
--note-mint: #e0f2f1;
--note-lavender: #f3e5f5;
--note-default: #ffffff;
--teal: #0d9488;
--teal-soft: #ccfbf1;
}
.media-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.media-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 10px;
padding: 0.35rem 0.55rem;
font-size: 0.8rem;
color: #475569;
cursor: pointer;
}
.media-btn.on {
border-color: var(--teal);
color: var(--teal);
background: var(--teal-soft);
}
.field,
.field-row .field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.82rem;
color: #64748b;
}
.field select,
.field input[type='text'] {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.45rem 0.55rem;
font-size: 0.9rem;
color: #0f172a;
background: #fff;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.55rem;
}
.ai-toggle {
display: flex;
gap: 0.65rem;
align-items: flex-start;
padding: 0.7rem 0.75rem;
border-radius: 12px;
background: #f0fdfa;
border: 1px solid #99f6e4;
cursor: pointer;
}
.ai-toggle strong {
display: block;
color: #0f766e;
font-size: 0.9rem;
}
.ai-toggle p {
margin: 0.2rem 0 0;
font-size: 0.78rem;
color: #64748b;
}
.checklist-ed {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.check-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.check-row input[type='text'] {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.5rem;
}
.linkish {
border: 0;
background: transparent;
color: var(--teal);
cursor: pointer;
font-size: 0.85rem;
text-align: start;
padding: 0.2rem 0;
}
.pending-files {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.file-chip,
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: #f1f5f9;
border-radius: 8px;
padding: 0.25rem 0.45rem;
font-size: 0.75rem;
color: #475569;
}
.file-chip button {
border: 0;
background: transparent;
cursor: pointer;
color: #94a3b8;
}
.btn-primary,
.btn-save {
border: 0;
background: var(--teal);
color: #fff;
border-radius: 12px;
padding: 0.55rem 1rem;
font-weight: 700;
cursor: pointer;
}
.btn-primary:disabled,
.btn-save:disabled {
opacity: 0.65;
cursor: wait;
}
.btn-ghost {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 12px;
padding: 0.55rem 1rem;
cursor: pointer;
}
.btn-primary {
flex: 1;
}
.mobile-editor {
position: fixed;
inset: 0;
z-index: 90;
background: #fff;
display: flex;
flex-direction: column;
}
.mob-canvas {
flex: 1;
overflow: auto;
padding: 0.85rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mob-canvas .body-canvas {
min-height: 42vh;
flex: 1;
}
.chip-summary {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.mob-toolbar {
display: flex;
gap: 0.15rem;
padding: 0.45rem 0.35rem calc(0.55rem + env(safe-area-inset-bottom));
border-top: 1px solid #e2e8f0;
background: #fff;
overflow-x: auto;
}
.mob-toolbar > button,
.voice-slot {
flex: 1 0 auto;
min-width: 3.4rem;
border: 0;
background: transparent;
color: #475569;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
font-size: 0.65rem;
cursor: pointer;
padding: 0.25rem;
}
.voice-slot .tiny {
border: 0;
background: transparent;
color: #94a3b8;
font-size: 0.6rem;
cursor: pointer;
}
.sheet-root {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.sheet-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.4);
}
.sheet {
position: relative;
background: #fff;
border-radius: 18px 18px 0 0;
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
max-height: 85vh;
overflow: auto;
display: flex;
flex-direction: column;
gap: 0.7rem;
animation: sheet-up 0.2s ease;
}
@keyframes sheet-up {
from {
transform: translateY(24px);
}
to {
transform: none;
}
}
.sheet header {
display: flex;
justify-content: space-between;
align-items: center;
}
.sheet h3 {
margin: 0;
font-size: 1rem;
}
.sheet-done {
width: 100%;
margin-top: 0.25rem;
}
.mobile-only {
display: none !important;
}
@media (max-width: 1100px) {
.notes-board.is-grid {
column-count: 2;
}
}
@media (max-width: 860px) {
.desktop-only {
display: none !important;
}
.mobile-quick.mobile-only,
.chips.mobile-only {
display: flex !important;
}
.fab.mobile-only {
display: inline-flex !important;
}
.notes-board.is-grid {
column-count: 2;
column-gap: 0.65rem;
}
.note-card {
padding: 0.7rem;
margin-bottom: 0.65rem;
}
}
@media (min-width: 861px) {
.mobile-editor {
display: none !important;
}
}
</style>