Make note cards equal squares and open the editor as a centered modal.

Unify desktop and mobile create/edit into one modal dialog with a consistent grid layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 19:59:47 +03:30
co-authored by Cursor
parent f43aa125ea
commit 495b529aaa
+274 -319
View File
@@ -51,7 +51,6 @@ const blobCache = ref<Record<string, string>>({});
const audioDurations = ref<Record<string, string>>({});
const editorOpen = ref(false);
const mobileEditor = ref(false);
const moreSheetOpen = ref(false);
const showChecklistEditor = ref(false);
const editingId = ref<string | null>(null);
@@ -397,7 +396,6 @@ function openEditor(mode: OpenMode = 'default', note?: NoteDto) {
}
}
mobileEditor.value = isMobile.value;
editorOpen.value = true;
moreSheetOpen.value = false;
@@ -411,7 +409,6 @@ function openEditor(mode: OpenMode = 'default', note?: NoteDto) {
function closeEditor() {
editorOpen.value = false;
mobileEditor.value = false;
moreSheetOpen.value = false;
resetDraft();
}
@@ -836,7 +833,7 @@ onBeforeUnmount(() => {
<button
type="button"
class="note-card add-card desktop-only"
class="note-card add-card"
@click="openEditor()"
>
<UiIcon name="plus" :size="22" />
@@ -880,12 +877,9 @@ onBeforeUnmount(() => {
@change="onFilesSelected($event, 'audio')"
/>
<!-- Desktop slide-over (RTL: start = left) -->
<!-- Unified create/edit modal -->
<Teleport to="body">
<div
v-if="editorOpen && !mobileEditor"
class="panel-root desktop-panel"
>
<div v-if="editorOpen" class="panel-root">
<div class="panel-backdrop" @click="closeEditor" />
<aside
class="editor-panel"
@@ -896,9 +890,19 @@ onBeforeUnmount(() => {
>
<header class="panel-head">
<h2>{{ editorTitle }}</h2>
<button type="button" class="icon-btn" @click="closeEditor">
<UiIcon name="x" :size="20" />
</button>
<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">
@@ -923,21 +927,14 @@ onBeforeUnmount(() => {
:key="sw.value"
type="button"
class="swatch"
:class="[
`tone-${sw.value}`,
{ selected: draftColor === sw.value },
]"
: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"
/>
<UiIcon v-if="draftColor === sw.value" name="check-plain" :size="14" />
</button>
</div>
<button
@@ -951,7 +948,7 @@ onBeforeUnmount(() => {
</button>
</div>
<div class="media-row">
<div class="media-row desktop-only-flex">
<button type="button" class="media-btn" @click="imageInput?.click()">
<UiIcon name="layers" :size="18" /> تصویر
</button>
@@ -1018,66 +1015,67 @@ onBeforeUnmount(() => {
</div>
</div>
<label class="field">
<span>نوع یادداشت</span>
<select v-model="draftType">
<option
v-for="opt in noteTypeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</label>
<div class="field-row">
<div class="desktop-fields">
<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 }}
<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>
<select v-model="draftDealId">
<option value="">—</option>
<option v-for="d in deals" :key="d.id" :value="d.id">
{{ d.title }}
</option>
</select>
<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>
<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 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"
@@ -1088,7 +1086,7 @@ onBeforeUnmount(() => {
/>
</div>
<footer class="panel-foot">
<footer class="panel-foot desktop-only-flex">
<button
type="button"
class="btn-primary"
@@ -1099,212 +1097,108 @@ onBeforeUnmount(() => {
</button>
<button type="button" class="btn-ghost" @click="closeEditor">لغو</button>
</footer>
</aside>
</div>
</Teleport>
<!-- Mobile full-screen editor -->
<Teleport to="body">
<div
v-if="editorOpen && mobileEditor"
class="mobile-editor"
:style="{ '--canvas': draftColorCss, background: draftColorCss }"
>
<header class="mob-head">
<button type="button" class="icon-btn" @click="closeEditor">
<UiIcon name="x" :size="22" />
</button>
<button
type="button"
class="btn-save"
:disabled="saving"
@click="saveNote"
>
{{ saving ? '…' : 'ذخیره' }}
</button>
</header>
<div class="mob-canvas">
<input
v-model="draftTitle"
class="title-input"
type="text"
placeholder="عنوان (اختیاری)"
/>
<textarea
ref="bodyTextarea"
v-model="draftBody"
class="body-canvas"
placeholder="یادداشت خود را بنویسید…"
/>
<div class="palette-row">
<div class="swatches" role="listbox" aria-label="رنگ یادداشت">
<button
v-for="sw in colorSwatches"
:key="`m-${sw.value}`"
type="button"
class="swatch"
:class="[`tone-${sw.value}`, { selected: draftColor === sw.value }]"
:style="{ backgroundColor: NOTE_COLOR_HEX[sw.value] }"
:title="sw.label"
:aria-label="sw.label"
:aria-selected="draftColor === sw.value"
@click="draftColor = sw.value"
>
<UiIcon
v-if="draftColor === sw.value"
name="check-plain"
:size="14"
/>
</button>
</div>
<nav class="mob-toolbar mobile-only-flex">
<button
type="button"
class="media-btn pin-btn"
:class="{ on: draftPinned }"
@click="onPinClick"
@click="
showChecklistEditor = !showChecklistEditor;
if (showChecklistEditor && !draftChecklist.length)
draftChecklist = [newChecklistItem()];
"
>
سنجاق
<UiIcon name="check" :size="20" />
<span>چک‌لیست</span>
</button>
</div>
<div v-if="showChecklistEditor" class="checklist-ed">
<div
v-for="(item, idx) in draftChecklist"
:key="item.id"
class="check-row"
>
<input
type="checkbox"
:checked="item.done"
@change="toggleDraftCheck(item.id)"
<div class="voice-slot">
<VoiceToTextButton
variant="toolbar"
hide-error
@transcript="(t) => (draftBody = appendSpokenText(draftBody, t))"
/>
<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 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>
<button type="button" class="linkish" @click="addChecklistRow">+ مورد جدید</button>
</div>
<div class="chip-summary">
<span v-for="(f, i) in draftFiles" :key="`mf-${i}`" class="tag-chip">{{ f.name }}</span>
<span v-for="t in parseTags(draftTags)" :key="t" class="tag-chip">#{{ t }}</span>
<span v-if="draftPersonId" class="tag-chip">{{ personName(draftPersonId) }}</span>
<span v-if="draftDealId" class="tag-chip">معامله</span>
<span v-if="draftLocation" class="tag-chip">{{ draftLocation }}</span>
</div>
<NoteAiSuggestionPanel
v-if="editingId"
:note-id="editingId"
:has-person="Boolean(draftPersonId)"
@applied="load"
@dismissed="load"
/>
</div>
<nav class="mob-toolbar">
<button
type="button"
@click="
showChecklistEditor = !showChecklistEditor;
if (showChecklistEditor && !draftChecklist.length)
draftChecklist = [newChecklistItem()];
"
>
<UiIcon name="check" :size="20" />
<span>چک‌لیست</span>
</button>
<div class="voice-slot">
<VoiceToTextButton
variant="toolbar"
hide-error
@transcript="(t) => (draftBody = appendSpokenText(draftBody, t))"
/>
<button type="button" class="tiny" @click="audioInput?.click()">فایل صدا</button>
</div>
<button type="button" @click="imageInput?.click()">
<UiIcon name="layers" :size="20" />
<span>عکس</span>
</button>
<button type="button" @click="fileInput?.click()">
<UiIcon name="document" :size="20" />
<span>فایل</span>
</button>
<button type="button" @click="promptLocation">
<UiIcon name="flag" :size="20" />
<span>موقعیت</span>
</button>
<button type="button" @click="openMoreSheet">
<UiIcon name="dots" :size="20" />
<span>بیشتر</span>
</button>
</nav>
<div v-if="moreSheetOpen" class="sheet-root">
<div class="sheet-backdrop" @click="moreSheetOpen = false" />
<div class="sheet">
<header>
<h3>جزئیات بیشتر</h3>
<button type="button" class="icon-btn" @click="moreSheetOpen = false">
<UiIcon name="x" :size="18" />
</button>
</header>
<label class="field">
<span>انتخاب مشتری</span>
<select v-model="draftPersonId">
<option value="">—</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</label>
<label class="field">
<span>معامله</span>
<select v-model="draftDealId">
<option value="">—</option>
<option v-for="d in deals" :key="d.id" :value="d.id">{{ d.title }}</option>
</select>
</label>
<label class="field">
<span>فعالیت</span>
<select v-model="draftActivityId">
<option value="">—</option>
<option v-for="a in activities" :key="a.id" :value="a.id">{{ a.title }}</option>
</select>
</label>
<label class="field">
<span>نوع یادداشت</span>
<select v-model="draftType">
<option
v-for="opt in noteTypeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</label>
<label class="field">
<span>یادآوری</span>
<JalaliDateInput v-model="draftReminder" mode="datetime" icon="bell" />
</label>
<label class="field">
<span>برچسب‌ها</span>
<input v-model="draftTags" type="text" placeholder="برچسب‌ها" />
</label>
<label class="ai-toggle">
<input v-model="draftAnalyze" type="checkbox" />
<div>
<strong>تحلیل با هوش مصنوعی</strong>
<p>پس از ذخیره اجرا می‌شود.</p>
</div>
</label>
<button type="button" class="btn-primary sheet-done" @click="moreSheetOpen = false">
تأیید
</button>
</div>
</div>
</aside>
</div>
</Teleport>
</section>
@@ -1544,8 +1438,10 @@ onBeforeUnmount(() => {
.notes-board.is-grid {
margin-top: 1.1rem;
column-count: 3;
column-gap: 0.9rem;
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;
@@ -1554,21 +1450,28 @@ onBeforeUnmount(() => {
gap: 0.75rem;
}
.notes-board.is-list .note-card {
break-inside: auto;
aspect-ratio: auto;
min-height: 120px;
height: auto;
width: 100%;
}
.note-card {
break-inside: avoid;
margin-bottom: 0.9rem;
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.85rem 0.9rem 0.75rem;
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);
@@ -1581,8 +1484,6 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 140px;
width: 100%;
background: transparent;
border: 1.5px dashed #cbd5e1;
color: #64748b;
@@ -1643,21 +1544,34 @@ onBeforeUnmount(() => {
.card-title {
margin: 0 0 0.35rem;
font-size: 1rem;
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.9rem;
line-height: 1.55;
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;
@@ -1676,15 +1590,16 @@ onBeforeUnmount(() => {
}
.card-thumb {
margin-top: 0.65rem;
border-radius: 12px;
margin-top: 0.45rem;
border-radius: 10px;
overflow: hidden;
max-height: 160px;
flex-shrink: 0;
max-height: 72px;
}
.card-thumb img {
display: block;
width: 100%;
height: 160px;
height: 72px;
object-fit: cover;
}
@@ -1723,11 +1638,12 @@ onBeforeUnmount(() => {
}
.card-meta {
margin-top: 0.7rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
margin-top: 0.35rem;
flex-shrink: 0;
}
.type-badge {
font-size: 0.72rem;
@@ -1774,12 +1690,14 @@ onBeforeUnmount(() => {
}
.card-foot {
margin-top: 0.55rem;
margin-top: auto;
padding-top: 0.45rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.75rem;
font-size: 0.72rem;
color: #94a3b8;
flex-shrink: 0;
}
.card-foot .person {
color: #64748b;
@@ -1810,27 +1728,40 @@ onBeforeUnmount(() => {
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.35);
background: rgba(15, 23, 42, 0.45);
}
.editor-panel {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: min(420px, 94vw);
position: relative;
z-index: 1;
width: min(520px, 100%);
max-height: min(92vh, 840px);
border-radius: 18px;
background: #fff;
box-shadow: 8px 0 40px rgba(15, 23, 42, 0.18);
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
display: flex;
flex-direction: column;
animation: slide-in 0.22s ease;
overflow: hidden;
animation: modal-in 0.2s ease;
}
@keyframes slide-in {
@keyframes modal-in {
from {
transform: translateX(-12px);
opacity: 0.6;
transform: translateY(10px) scale(0.98);
opacity: 0.55;
}
to {
transform: none;
@@ -1936,15 +1867,22 @@ onBeforeUnmount(() => {
background: #fffbeb;
}
.panel-root {
--note-sand: #fff8e1;
--note-rose: #fce4ec;
--note-sky: #e3f2fd;
--note-mint: #e0f2f1;
--note-lavender: #f3e5f5;
--note-default: #ffffff;
--teal: #0d9488;
--teal-soft: #ccfbf1;
.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 {
@@ -2200,7 +2138,7 @@ onBeforeUnmount(() => {
@media (max-width: 1100px) {
.notes-board.is-grid {
column-count: 2;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
@@ -2208,6 +2146,17 @@ onBeforeUnmount(() => {
.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;
@@ -2216,18 +2165,24 @@ onBeforeUnmount(() => {
display: inline-flex !important;
}
.notes-board.is-grid {
column-count: 2;
column-gap: 0.65rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.note-card {
padding: 0.7rem;
margin-bottom: 0.65rem;
padding: 0.65rem;
}
}
@media (min-width: 861px) {
.mobile-editor {
display: none !important;
.editor-panel {
width: min(100%, 520px);
max-height: 94vh;
border-radius: 16px;
}
.panel-root {
padding: 0.5rem;
place-items: center;
}
.btn-save.mobile-only-flex {
padding: 0.4rem 0.85rem;
font-size: 0.88rem;
}
}
</style>