Let people read notes, postpone, and mark an activity done beside the card instead of opening the full page. Co-authored-by: Cursor <cursoragent@cursor.com>
381 lines
8.4 KiB
Vue
381 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import {
|
|
formatClockFa,
|
|
formatJalaliDayClock,
|
|
startOfLocalDay,
|
|
} from '@/lib/jalali';
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
|
|
|
type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custom';
|
|
|
|
const props = defineProps<{
|
|
dueAt?: string | null;
|
|
saving?: boolean;
|
|
/** بالاتر از مودال خلاصهٔ فعالیت */
|
|
overlayZIndex?: number;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
close: [];
|
|
submit: [iso: string];
|
|
}>();
|
|
|
|
const nowTick = ref(Date.now());
|
|
const customOpen = ref(false);
|
|
const customLocal = ref('');
|
|
let tickTimer: number | null = null;
|
|
|
|
const base = computed(() => {
|
|
const now = new Date(nowTick.value);
|
|
const due = props.dueAt ? new Date(props.dueAt) : now;
|
|
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
|
|
return due;
|
|
});
|
|
|
|
function addMinutes(mins: number) {
|
|
return new Date(base.value.getTime() + mins * 60_000);
|
|
}
|
|
|
|
function tomorrowAt(hour: number, minute = 0) {
|
|
const d = startOfLocalDay(new Date(nowTick.value));
|
|
d.setDate(d.getDate() + 1);
|
|
d.setHours(hour, minute, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
/** شنبهٔ بعد، ساعت ۱۰. اگر امروز شنبه باشد، شنبهٔ هفتهٔ بعد. */
|
|
function nextSaturdayMorning() {
|
|
const d = startOfLocalDay(new Date(nowTick.value));
|
|
const untilSaturday = (6 - d.getDay() + 7) % 7;
|
|
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
|
|
d.setHours(10, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function nextWeekSameTime() {
|
|
const d = new Date(base.value.getTime());
|
|
d.setDate(d.getDate() + 7);
|
|
return d;
|
|
}
|
|
|
|
const options = computed(() => {
|
|
const h1 = addMinutes(60);
|
|
const am = tomorrowAt(10);
|
|
const saturday = nextSaturdayMorning();
|
|
const week = nextWeekSameTime();
|
|
return [
|
|
{
|
|
key: 'h1' as const,
|
|
label: 'یک ساعت دیرتر',
|
|
time: formatClockFa(h1),
|
|
at: h1,
|
|
},
|
|
{
|
|
key: 'tomorrow-am' as const,
|
|
label: 'فردا صبح',
|
|
time: formatClockFa(am),
|
|
at: am,
|
|
},
|
|
{
|
|
key: 'next-saturday' as const,
|
|
label: 'شنبه آینده',
|
|
time: formatJalaliDayClock(saturday),
|
|
at: saturday,
|
|
},
|
|
{
|
|
key: 'next-week' as const,
|
|
label: 'هفته آینده',
|
|
time: formatJalaliDayClock(week),
|
|
at: week,
|
|
},
|
|
];
|
|
});
|
|
|
|
function toLocalInput(d: Date) {
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
const customDate = computed(() => {
|
|
if (!customLocal.value) return null;
|
|
const d = new Date(customLocal.value);
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
});
|
|
|
|
const customPreview = computed(() =>
|
|
customDate.value ? formatJalaliDayClock(customDate.value) : '',
|
|
);
|
|
|
|
function apply(at: Date) {
|
|
if (props.saving) return;
|
|
emit('submit', at.toISOString());
|
|
}
|
|
|
|
function pick(key: PostponeKey) {
|
|
if (key === 'custom') {
|
|
customOpen.value = true;
|
|
if (!customLocal.value) customLocal.value = toLocalInput(addMinutes(60));
|
|
return;
|
|
}
|
|
const at = options.value.find((item) => item.key === key)?.at;
|
|
if (at) apply(at);
|
|
}
|
|
|
|
function submitCustom() {
|
|
if (!customDate.value || props.saving) return;
|
|
apply(customDate.value);
|
|
}
|
|
|
|
function close() {
|
|
emit('close');
|
|
}
|
|
|
|
function onKeydown(ev: KeyboardEvent) {
|
|
if (ev.key === 'Escape') {
|
|
ev.preventDefault();
|
|
close();
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.body.style.overflow = 'hidden';
|
|
window.addEventListener('keydown', onKeydown);
|
|
tickTimer = window.setInterval(() => {
|
|
nowTick.value = Date.now();
|
|
}, 15_000);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.body.style.overflow = '';
|
|
window.removeEventListener('keydown', onKeydown);
|
|
if (tickTimer != null) window.clearInterval(tickTimer);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
|
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
|
|
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
|
|
<div class="handle" aria-hidden="true" />
|
|
|
|
<header class="head">
|
|
<h2 id="postpone-title">به تعویق انداختن</h2>
|
|
<button class="icon" type="button" aria-label="بستن" @click="close">
|
|
<UiIcon name="x" :size="20" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="body">
|
|
<div class="presets">
|
|
<button
|
|
v-for="item in options"
|
|
:key="item.key"
|
|
class="card"
|
|
type="button"
|
|
:disabled="saving"
|
|
@click="pick(item.key)"
|
|
>
|
|
<span class="label">{{ item.label }}</span>
|
|
<span class="when">{{ item.time }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
class="card custom"
|
|
type="button"
|
|
:class="{ on: customOpen }"
|
|
:disabled="saving"
|
|
@click="pick('custom')"
|
|
>
|
|
<span class="label">زمان دلخواه</span>
|
|
<span class="when">{{ customOpen && customPreview ? customPreview : 'تاریخ و ساعت' }}</span>
|
|
</button>
|
|
|
|
<div v-if="customOpen" class="custom-box">
|
|
<label class="picker">
|
|
<span>تاریخ و ساعت جدید</span>
|
|
<JalaliDateInput v-model="customLocal" mode="datetime" />
|
|
</label>
|
|
<button
|
|
class="confirm"
|
|
type="button"
|
|
:disabled="saving || !customDate"
|
|
@click="submitCustom"
|
|
>
|
|
{{ saving ? 'در حال ثبت…' : 'ثبت تعویق' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.layer {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 90;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.backdrop {
|
|
position: absolute;
|
|
inset: 0;
|
|
border: 0;
|
|
background: rgba(15, 23, 42, 0.28);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.sheet {
|
|
position: relative;
|
|
width: 100%;
|
|
max-height: 92dvh;
|
|
background: #fff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-family: IRANSans, Tahoma, sans-serif;
|
|
border-radius: 16px 16px 0 0;
|
|
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.12);
|
|
}
|
|
|
|
.handle {
|
|
width: 42px;
|
|
height: 4px;
|
|
margin: 0.55rem auto 0.15rem;
|
|
border-radius: 999px;
|
|
background: #d4d4d8;
|
|
}
|
|
|
|
.head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
padding: 0.15rem 0.85rem 0.35rem;
|
|
}
|
|
|
|
.head h2 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
border: 0;
|
|
background: none;
|
|
color: #64748b;
|
|
display: grid;
|
|
place-items: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.body {
|
|
overflow: auto;
|
|
padding: 0.35rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
|
display: grid;
|
|
gap: 0.55rem;
|
|
}
|
|
|
|
.presets {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.55rem;
|
|
}
|
|
|
|
.card {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
gap: 0.2rem;
|
|
min-height: 64px;
|
|
padding: 0.7rem 0.85rem;
|
|
border: 1px solid #e8eef3;
|
|
border-radius: var(--radius, 10px);
|
|
background: #fff;
|
|
font: inherit;
|
|
text-align: start;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.card.on,
|
|
.card:hover:not(:disabled) {
|
|
border-color: #99f6e4;
|
|
background: #f0fdfa;
|
|
}
|
|
|
|
.card:disabled {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.label {
|
|
font-size: 0.92rem;
|
|
font-weight: 700;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.when {
|
|
color: #0f766e;
|
|
font-size: 0.84rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.custom {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.custom-box {
|
|
display: grid;
|
|
gap: 0.65rem;
|
|
}
|
|
|
|
.picker {
|
|
display: grid;
|
|
gap: 0.4rem;
|
|
color: #64748b;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.confirm {
|
|
border: 0;
|
|
background: #0f766e;
|
|
color: #fff;
|
|
border-radius: var(--radius, 10px);
|
|
min-height: 42px;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.confirm:disabled {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
@media (min-width: 900px) {
|
|
.layer {
|
|
align-items: center;
|
|
padding: 1.2rem;
|
|
}
|
|
|
|
.sheet {
|
|
max-width: 420px;
|
|
border-radius: 16px;
|
|
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
|
|
}
|
|
}
|
|
</style>
|