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

2193 lines
57 KiB
Vue
Raw Normal View History

<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 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()];
}
}
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;
}
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"
@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" @click="closeEditor" />
<aside
class="editor-panel"
:style="{
'--canvas': draftColorCss,
background: draftColorCss,
}"
>
<header class="panel-head">
<h2>{{ editorTitle }}</h2>
<div class="panel-head-actions">
<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">
<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>
<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>
</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>
</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" @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>
</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 h1 {
margin: 0;
font-size: 1.55rem;
font-weight: 600;
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: 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;
}
.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;
}
.menu-pop {
position: absolute;
inset-inline-end: 0;
top: 100%;
z-index: 5;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
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: var(--radius);
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: 0.95rem;
font-weight: 700;
color: #0f172a;
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;
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-shrink: 0;
max-height: 72px;
}
.card-thumb img {
display: block;
width: 100%;
height: 72px;
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;
}
.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: 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;
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 {
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: 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) {
.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;
}
}
</style>