Files
CRM_Montzar/apps/web/src/views/NotesView.vue
T

2669 lines
71 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 { useEscClose } from '@/lib/modal-stack';
import { useRoute } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
import NoteCustomFields from '@/components/notes/NoteCustomFields.vue';
import NoteCategoriesManager from '@/components/notes/NoteCategoriesManager.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { appendSpokenText } from '@/lib/spoken-text';
import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
toFaDigits,
} from '@/lib/jalali';
import type {
ActivityDto,
DealDto,
NoteAttachmentDto,
NoteAiSuggestionDto,
NoteChecklistItemDto,
NoteDto,
NoteCategoryDto,
NoteRevisionDto,
NoteVisibility,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
CustomFieldDefinitionDto,
} from '@mento/shared';
import { NOTE_VISIBILITY_LABELS } 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 auth = useAuthStore();
const canManageNoteFields = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const q = ref('');
const typeFilter = ref<string>('all');
const visibilityFilter = ref<NoteVisibility | '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 moreSheetOpen = ref(false);
const showChecklistEditor = ref(false);
const editingId = ref<string | null>(null);
const draftTitle = ref('');
const draftBody = ref('');
const draftType = ref('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 draftVisibility = ref<NoteVisibility>('team');
const draftChecklist = ref<NoteChecklistItemDto[]>([]);
const draftCustomFields = ref<Record<string, unknown>>({});
const draftFiles = ref<File[]>([]);
const existingAttachments = ref<NoteAttachmentDto[]>([]);
const noteFieldDefs = ref<CustomFieldDefinitionDto[]>([]);
const historyOpen = ref(false);
const historyLoading = ref(false);
const historyError = ref('');
const historyNoteId = ref<string | null>(null);
const historyItems = ref<NoteRevisionDto[]>([]);
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 noteCategories = ref<NoteCategoryDto[]>([
{ id: 'general', key: 'general', label: 'معمولی', tone: 'general', position: 0, isActive: true, showInBar: true, isSystem: true },
{ id: 'follow_up', key: 'follow_up', label: 'پیگیری', tone: 'follow_up', position: 1, isActive: true, showInBar: true, isSystem: true },
{ id: 'idea', key: 'idea', label: 'ایده', tone: 'idea', position: 2, isActive: true, showInBar: true, isSystem: true },
{ id: 'suggestion', key: 'suggestion', label: 'پیشنهاد', tone: 'suggestion', position: 3, isActive: true, showInBar: true, isSystem: true },
{ id: 'meeting', key: 'meeting', label: 'جلسه', tone: 'meeting', position: 4, isActive: true, showInBar: true, isSystem: true },
{ id: 'personal', key: 'personal', label: 'شخصی', tone: 'personal', position: 5, isActive: true, showInBar: true, isSystem: true },
]);
const categoriesOpen = ref(false);
const categoryChips = computed(() => [
{ value: 'all', label: 'همه', tone: 'all' },
...noteCategories.value
.filter((item) => item.isActive && item.showInBar)
.map((item) => ({ value: item.key, label: item.label, tone: item.tone || 'general' })),
]);
const noteTypeOptions = computed(() =>
noteCategories.value
.filter((item) => item.isActive)
.map((item) => ({ value: item.key, label: item.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 historyNoteRevisionCount = computed(() => {
if (!editingId.value) return 0;
return notes.value.find((n) => n.id === editingId.value)?.revisionCount ?? 0;
});
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 noteCategories.value.find((item) => item.key === t)?.label ?? typeLabelMap[t] ?? t;
}
function typeTone(t: string) {
const tone = noteCategories.value.find((item) => item.key === t)?.tone;
if (tone) return tone;
if (t === 'call_result' || t === 'next_action' || t === 'warning') return 'follow_up';
if (t === 'important') return 'suggestion';
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 customFieldLines(note: NoteDto) {
const values = note.customFields ?? {};
return noteFieldDefs.value.flatMap((field) => {
const value = values[field.key];
if (value == null || value === '') return [];
const text = field.fieldType === 'boolean' ? (value ? 'بله' : 'خیر') : String(value);
return [{ key: field.key, label: field.label, text }];
});
}
async function loadNoteCategories() {
try {
noteCategories.value = (await api.get<NoteCategoryDto[]>('/notes/categories')).data;
const visible = new Set(
noteCategories.value.filter((item) => item.isActive && item.showInBar).map((item) => item.key),
);
if (typeFilter.value !== 'all' && !visible.has(typeFilter.value)) {
typeFilter.value = 'all';
}
} catch {
/* نوار پیش‌فرض می‌ماند */
}
}
async function loadNoteFields() {
try {
noteFieldDefs.value = (
await api.get<CustomFieldDefinitionDto[]>('/custom-fields', {
params: { entityType: 'note', activeOnly: true },
})
).data;
} catch {
noteFieldDefs.value = [];
}
}
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,
visibility:
visibilityFilter.value === 'all'
? undefined
: visibilityFilter.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;
draftVisibility.value = 'team';
draftChecklist.value = [];
draftCustomFields.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 || '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;
draftVisibility.value =
note.visibility === 'personal' ? 'personal' : 'team';
draftChecklist.value = [...(note.checklist ?? [])];
draftCustomFields.value = { ...(note.customFields ?? {}) };
draftFiles.value = [];
existingAttachments.value = [...note.attachments];
showChecklistEditor.value = (note.checklist?.length ?? 0) > 0;
} else {
resetDraft();
if (mode === 'checklist') {
showChecklistEditor.value = true;
draftChecklist.value = [newChecklistItem()];
}
}
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;
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;
}
const missingField = noteFieldDefs.value.find((field) => {
if (!field.required) return false;
const value = draftCustomFields.value[field.key];
if (field.fieldType === 'boolean') return value !== true && value !== false;
return value == null || String(value).trim() === '';
});
if (missingField) {
error.value = `فیلد «${missingField.label}» لازم است.`;
return;
}
saving.value = true;
error.value = '';
try {
const payload = {
title: draftTitle.value.trim() || null,
body: body || (checklist.length ? 'چک‌لیست' : ''),
noteType: draftType.value,
visibility: draftVisibility.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,
customFields: draftCustomFields.value,
};
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 openHistory(note: NoteDto) {
menuOpenId.value = null;
historyNoteId.value = note.id;
historyOpen.value = true;
historyLoading.value = true;
historyError.value = '';
historyItems.value = [];
try {
const { data } = await api.get<NoteRevisionDto[]>(
`/notes/${note.id}/revisions`,
);
historyItems.value = data;
} catch (e) {
historyError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری تاریخچه ناموفق بود.',
)
: 'بارگذاری تاریخچه ناموفق بود.';
} finally {
historyLoading.value = false;
}
}
useEscClose(() => editorOpen.value, () => closeEditor());
useEscClose(() => categoriesOpen.value, () => {
categoriesOpen.value = false;
});
useEscClose(() => moreSheetOpen.value, () => { moreSheetOpen.value = false; });
useEscClose(() => historyOpen.value, () => closeHistory());
function closeHistory() {
historyOpen.value = false;
historyNoteId.value = null;
historyItems.value = [];
historyError.value = '';
}
async function restoreRevision(revisionId: string) {
if (!historyNoteId.value) return;
if (!confirm('این نسخه جایگزین متن فعلی شود؟')) return;
try {
await api.post(
`/notes/${historyNoteId.value}/revisions/${revisionId}/restore`,
);
closeHistory();
await load();
} catch (e) {
historyError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بازگردانی ناموفق بود.',
)
: 'بازگردانی ناموفق بود.';
}
}
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, .ui-dd-panel')) menuOpenId.value = null;
}
watch(typeFilter, () => {
void load();
});
watch(visibilityFilter, () => {
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();
void loadNoteFields();
void loadNoteCategories();
});
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>
<button
v-if="canManageNoteFields"
type="button"
class="chip chip-manage"
@click="categoriesOpen = true"
>
دسته‌ها
</button>
<span class="chip-sep" aria-hidden="true" />
<button
type="button"
class="chip"
:class="{ active: visibilityFilter === 'all' }"
@click="visibilityFilter = 'all'"
>
همه دسترسی‌ها
</button>
<button
type="button"
class="chip tone-team"
:class="{ active: visibilityFilter === 'team' }"
@click="visibilityFilter = 'team'"
>
تیمی
</button>
<button
type="button"
class="chip tone-private"
:class="{ active: visibilityFilter === 'personal' }"
@click="visibilityFilter = 'personal'"
>
فقط من
</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>
<button
v-if="canManageNoteFields"
type="button"
class="chip chip-manage"
@click="categoriesOpen = true"
>
دسته‌ها
</button>
<span class="chip-sep" aria-hidden="true" />
<button
type="button"
class="chip"
:class="{ active: visibilityFilter === 'all' }"
@click="visibilityFilter = 'all'"
>
همه دسترسی‌ها
</button>
<button
type="button"
class="chip tone-team"
:class="{ active: visibilityFilter === 'team' }"
@click="visibilityFilter = 'team'"
>
تیمی
</button>
<button
type="button"
class="chip tone-private"
:class="{ active: visibilityFilter === 'personal' }"
@click="visibilityFilter = 'personal'"
>
فقط من
</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>
<UiDropdownMenu
:open="menuOpenId === note.id"
align="end"
@update:open="(v) => (menuOpenId = v ? note.id : null)"
>
<template #trigger="{ toggle }">
<button
type="button"
class="icon-btn menu-btn"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
</template>
<button type="button" @click.stop="openEditor('default', note)">ویرایش</button>
<button type="button" @click.stop="togglePin(note.id)">
{{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }}
</button>
<button
v-if="(note.revisionCount ?? 0) > 0"
type="button"
@click.stop="openHistory(note)"
>
تاریخچه ویرایش
</button>
<button type="button" class="danger" @click.stop="removeNote(note.id)">حذف</button>
</UiDropdownMenu>
</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>
<ul v-if="customFieldLines(note).length" class="card-fields">
<li v-for="line in customFieldLines(note)" :key="line.key">
<b>{{ line.label }}:</b> {{ line.text }}
</li>
</ul>
<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
class="vis-badge"
:class="note.visibility === 'personal' ? 'private' : 'team'"
>
{{
note.visibility === 'personal'
? NOTE_VISIBILITY_LABELS.personal
: NOTE_VISIBILITY_LABELS.team
}}
</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"
@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')"
/>
<!-- Unified create/edit modal -->
<Teleport to="body">
<div v-if="editorOpen" class="panel-root">
<div class="panel-backdrop" aria-hidden="true" />
<aside
class="editor-panel"
:style="{
'--canvas': draftColorCss,
background: draftColorCss,
}"
>
<header class="panel-head">
<h2>{{ editorTitle }}</h2>
<div class="panel-head-actions">
<button
v-if="editingId && historyNoteRevisionCount > 0"
type="button"
class="btn-ghost history-btn"
@click="
openHistory({
id: editingId,
revisionCount: historyNoteRevisionCount,
} as NoteDto)
"
>
تاریخچه
</button>
<button
type="button"
class="btn-save mobile-only-flex"
:disabled="saving"
@click="saveNote"
>
{{ saving ? '…' : 'ذخیره' }}
</button>
<button type="button" class="icon-btn" @click="closeEditor">
<UiIcon name="x" :size="20" />
</button>
</div>
</header>
<div class="panel-scroll">
<p v-if="error" class="banner-error">{{ error }}</p>
<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 desktop-only-flex">
<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>
<div class="desktop-fields">
<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>
<select v-model="draftVisibility">
<option value="team">{{ NOTE_VISIBILITY_LABELS.team }}</option>
<option value="personal">
{{ NOTE_VISIBILITY_LABELS.personal }}
</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>
<NoteCustomFields v-model="draftCustomFields" :fields="noteFieldDefs" />
</div>
<div class="chip-summary mobile-only-flex">
<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>
<span class="tag-chip">
{{
draftVisibility === 'personal'
? NOTE_VISIBILITY_LABELS.personal
: NOTE_VISIBILITY_LABELS.team
}}
</span>
</div>
<NoteAiSuggestionPanel
v-if="editingId"
:note-id="editingId"
:has-person="Boolean(draftPersonId)"
@applied="load"
@dismissed="load"
/>
</div>
<footer class="panel-foot desktop-only-flex">
<button
type="button"
class="btn-primary"
:disabled="saving"
@click="saveNote"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره یادداشت' }}
</button>
<button type="button" class="btn-ghost" @click="closeEditor">لغو</button>
</footer>
<nav class="mob-toolbar mobile-only-flex">
<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" aria-hidden="true" />
<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>
<select v-model="draftVisibility">
<option value="team">{{ NOTE_VISIBILITY_LABELS.team }}</option>
<option value="personal">
{{ NOTE_VISIBILITY_LABELS.personal }}
</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>
<NoteCustomFields v-model="draftCustomFields" :fields="noteFieldDefs" />
<button type="button" class="btn-primary sheet-done" @click="moreSheetOpen = false">
تأیید
</button>
</div>
</div>
</aside>
</div>
</Teleport>
<NoteCategoriesManager
v-if="categoriesOpen"
@close="categoriesOpen = false"
@saved="loadNoteCategories"
/>
<Teleport to="body">
<div v-if="historyOpen" class="history-root">
<div class="history-backdrop" aria-hidden="true" />
<aside class="history-panel" role="dialog" aria-label="تاریخچه ویرایش">
<header class="history-head">
<h2>تاریخچه ویرایش</h2>
<button type="button" class="icon-btn" @click="closeHistory">
<UiIcon name="x" :size="20" />
</button>
</header>
<p v-if="historyLoading" class="state-msg">در حال بارگذاری…</p>
<p v-else-if="historyError" class="banner-error">{{ historyError }}</p>
<p v-else-if="!historyItems.length" class="state-msg">
هنوز نسخه‌ای ذخیره نشده.
</p>
<ul v-else class="history-list">
<li v-for="rev in historyItems" :key="rev.id" class="history-item">
<div class="history-meta">
<strong>
{{ formatJalaliDay(new Date(rev.createdAt)) }}
·
{{ formatClockFa(new Date(rev.createdAt)) }}
</strong>
<span v-if="rev.editor?.fullName" class="muted">
{{ rev.editor.fullName }}
</span>
<span
class="vis-badge"
:class="rev.visibility === 'personal' ? 'private' : 'team'"
>
{{
rev.visibility === 'personal'
? NOTE_VISIBILITY_LABELS.personal
: NOTE_VISIBILITY_LABELS.team
}}
</span>
</div>
<h4 v-if="rev.title">{{ rev.title }}</h4>
<p class="history-body">{{ previewBody(rev.body) }}</p>
<button
type="button"
class="btn-ghost"
@click="restoreRevision(rev.id)"
>
بازگردانی این نسخه
</button>
</li>
</ul>
</aside>
</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: 10px;
--shadow: 0 4px 18px rgba(15, 23, 42, 0.07);
width: 100%;
max-width: 100%;
margin: 0;
padding: 0;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.page-head h1 {
margin: 0;
font-size: 1.55rem;
font-weight: 600;
color: #0f172a;
}
.lead {
margin: 0.35rem 0 0;
color: #64748b;
font-size: 0.95rem;
}
.card-fields {
margin: 0.4rem 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.82rem;
color: #334155;
}
.card-fields b {
font-weight: 700;
}
.banner-error {
margin: 0.75rem 0 0;
padding: 0.65rem 0.85rem;
border-radius: var(--radius);
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;
}
.chip-sep {
width: 1px;
align-self: stretch;
background: #e2e8f0;
margin-inline: 0.15rem;
}
.chip-manage {
border-style: dashed;
}
.chip.tone-team {
color: #0f766e;
}
.chip.tone-team.active {
background: #ccfbf1;
border-color: #5eead4;
color: #0f766e;
}
.chip.tone-private {
color: #9a3412;
}
.chip.tone-private.active {
background: #ffedd5;
border-color: #fdba74;
color: #9a3412;
}
.chip:not([class*='tone-']).active {
background: var(--teal);
border-color: var(--teal);
color: #fff;
}
.view-toggle {
display: inline-flex;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
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: var(--radius);
padding: 0.35rem 0.55rem;
background: #fff;
color: #334155;
font-size: 0.85rem;
}
.icon-btn {
border: 0;
background: transparent;
color: #64748b;
border-radius: var(--radius);
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: var(--radius);
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;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.85rem;
align-items: stretch;
}
.notes-board.is-list {
margin-top: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.notes-board.is-list .note-card {
aspect-ratio: auto;
min-height: 120px;
height: auto;
width: 100%;
}
.note-card {
aspect-ratio: 1 / 1;
width: 100%;
min-height: 0;
margin: 0;
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid rgba(15, 23, 42, 0.05);
padding: 0.75rem 0.8rem 0.65rem;
cursor: pointer;
position: relative;
text-align: start;
transition: transform 0.12s ease, box-shadow 0.12s ease;
display: flex;
flex-direction: column;
overflow: hidden;
}
.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;
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;
}
.card-title {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
line-height: 1.35;
color: #0f172a;
flex-shrink: 0;
min-width: 0;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-body {
margin: 0;
white-space: pre-wrap;
color: #334155;
font-size: 0.82rem;
line-height: 1.45;
flex: 1 1 2.9em;
min-height: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
}
.card-check {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-height: 0;
overflow: hidden;
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.45rem;
border-radius: var(--radius);
overflow: hidden;
flex: 0 1 72px;
min-height: 0;
max-height: 72px;
}
.card-thumb img {
display: block;
width: 100%;
height: 72px;
max-height: 100%;
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: var(--radius);
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 {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
margin-top: 0.35rem;
flex-shrink: 0;
}
.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;
}
.vis-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
}
.vis-badge.private {
background: #ffedd5;
color: #9a3412;
}
.vis-badge.team {
background: #ecfdf5;
color: #0f766e;
}
.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: auto;
padding-top: 0.45rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.72rem;
color: #94a3b8;
flex-shrink: 0;
}
.card-foot .person {
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fab {
position: fixed;
inset-inline-end: 1.1rem;
bottom: calc(1.25rem + var(--mobile-tabbar-h));
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;
display: grid;
place-items: center;
padding: 1rem;
--note-sand: #fff8e1;
--note-rose: #fce4ec;
--note-sky: #e3f2fd;
--note-mint: #e0f2f1;
--note-lavender: #f3e5f5;
--note-default: #ffffff;
--teal: #0d9488;
--teal-soft: #ccfbf1;
}
.panel-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.45);
}
.editor-panel {
position: relative;
z-index: 1;
width: min(520px, 100%);
max-height: min(92vh, 840px);
border-radius: var(--radius, 10px);
background: #fff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
animation: modal-in 0.2s ease;
}
@keyframes modal-in {
from {
transform: translateY(10px) scale(0.98);
opacity: 0.55;
}
to {
transform: none;
opacity: 1;
}
}
.panel-head,
.mob-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e2e8f0;
}
.panel-head h2 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--ink);
line-height: 1.4;
}
.panel-scroll {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel-foot {
display: flex;
gap: 12px;
padding: 16px;
border-top: 1px solid #e2e8f0;
}
.title-input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid #e2e8f0;
padding: 0.35rem 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
color: #0f172a;
outline: none;
background: transparent;
}
.body-canvas {
width: 100%;
border: 0;
border-radius: var(--radius);
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-head-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.desktop-only-flex {
display: flex !important;
}
.mobile-only-flex {
display: none !important;
}
.desktop-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.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: var(--radius);
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: var(--radius);
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: var(--radius);
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: var(--radius);
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: var(--radius);
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: var(--radius, 10px);
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: var(--radius, 10px);
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: var(--radius) var(--radius) 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 {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
@media (max-width: 860px) {
.notes-page {
padding-bottom: 5rem;
}
.desktop-only {
display: none !important;
}
.desktop-only-flex,
.desktop-fields {
display: none !important;
}
.mobile-only-flex {
display: flex !important;
}
.chip-summary.mobile-only-flex {
flex-wrap: wrap;
gap: 0.35rem;
}
.mobile-quick.mobile-only,
.chips.mobile-only {
display: flex !important;
}
.fab.mobile-only {
display: inline-flex !important;
}
.notes-board.is-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.note-card {
padding: 0.65rem;
}
.editor-panel {
width: min(100%, 520px);
max-height: 94vh;
border-radius: var(--radius);
}
.panel-root {
padding: 0.5rem;
place-items: center;
}
.btn-save.mobile-only-flex {
padding: 0.4rem 0.85rem;
font-size: 0.88rem;
}
}
.history-btn {
padding: 0.35rem 0.65rem;
font-size: 0.82rem;
}
.history-root {
position: fixed;
inset: 0;
z-index: 120;
display: grid;
place-items: center;
padding: 1rem;
}
.history-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.45);
}
.history-panel {
position: relative;
z-index: 1;
width: min(100%, 480px);
max-height: min(88vh, 640px);
overflow: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
padding: 1rem 1.1rem 1.25rem;
}
.history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.history-head h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 650;
color: #0f172a;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.history-item {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.75rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.history-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.55rem;
align-items: center;
font-size: 0.82rem;
color: #334155;
}
.history-meta .muted {
color: #94a3b8;
}
.history-item h4 {
margin: 0;
font-size: 0.92rem;
color: #0f172a;
}
.history-body {
margin: 0;
font-size: 0.88rem;
color: #475569;
white-space: pre-wrap;
line-height: 1.55;
}
.history-item .btn-ghost {
align-self: flex-start;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
}
</style>