feat: calendar event popover + upcoming events strip
B — Event popover: clicking a calendar event shows a compact overlay with
full details (title, time range, location, description, color accent)
and Edit/Close actions; positioned relative to the click, closes on
outside click; Edit opens the existing EventSlideOver
C — Upcoming strip: scrollable section below the calendar showing the
next 4 weeks of events grouped by day (Today/Tomorrow/date label),
each card with color accent bar, title, time, location, description
snippet; clicking a card opens EventSlideOver for edit
Both features stay in sync with create/update/delete operations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import FullCalendar from "@fullcalendar/vue3";
|
import FullCalendar from "@fullcalendar/vue3";
|
||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
@@ -14,7 +14,7 @@ const toast = useToastStore();
|
|||||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||||
|
|
||||||
// Slide-over state
|
// Slide-over state
|
||||||
const slideOverEvent = ref<EventEntry | null>(null); // null = create mode
|
const slideOverEvent = ref<EventEntry | null>(null);
|
||||||
const slideOverOpen = ref(false);
|
const slideOverOpen = ref(false);
|
||||||
const slideOverDate = ref<string>("");
|
const slideOverDate = ref<string>("");
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ function closeSlideOver() {
|
|||||||
slideOverOpen.value = false;
|
slideOverOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event entry cache keyed by id for quick lookups when clicking FC events
|
// Event entry cache keyed by id
|
||||||
const eventCache = new Map<number, EventEntry>();
|
const eventCache = new Map<number, EventEntry>();
|
||||||
|
|
||||||
function toFcEvent(e: EventEntry) {
|
function toFcEvent(e: EventEntry) {
|
||||||
@@ -50,6 +50,74 @@ function toFcEvent(e: EventEntry) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Upcoming events list ───────────────────────────────────────────────────
|
||||||
|
const upcomingEvents = ref<EventEntry[]>([]);
|
||||||
|
|
||||||
|
async function loadUpcoming() {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
|
||||||
|
try {
|
||||||
|
const entries = await listEvents(now.toISOString(), end.toISOString());
|
||||||
|
upcomingEvents.value = entries.sort(
|
||||||
|
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
|
||||||
|
);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadUpcoming);
|
||||||
|
|
||||||
|
// ── Event popover ──────────────────────────────────────────────────────────
|
||||||
|
const popover = ref<EventEntry | null>(null);
|
||||||
|
const popoverStyle = ref<Record<string, string>>({});
|
||||||
|
const popoverEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
|
||||||
|
popover.value = entry;
|
||||||
|
nextTickPositionPopover(clickEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTickPositionPopover(clickEvent: MouseEvent) {
|
||||||
|
// Position after DOM update
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const pw = 280;
|
||||||
|
const ph = 220; // approximate
|
||||||
|
let left = clickEvent.clientX + 8;
|
||||||
|
let top = clickEvent.clientY + 8;
|
||||||
|
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
|
||||||
|
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
|
||||||
|
popoverStyle.value = {
|
||||||
|
position: "fixed",
|
||||||
|
left: `${Math.max(8, left)}px`,
|
||||||
|
top: `${Math.max(8, top)}px`,
|
||||||
|
zIndex: "9999",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopover() {
|
||||||
|
popover.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPopoverEdit() {
|
||||||
|
if (popover.value) {
|
||||||
|
openEdit(popover.value);
|
||||||
|
closePopover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close popover on outside click
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
if (popover.value && popoverEl.value && !popoverEl.value.contains(e.target as Node)) {
|
||||||
|
closePopover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener("mousedown", onDocClick));
|
||||||
|
onUnmounted(() => document.removeEventListener("mousedown", onDocClick));
|
||||||
|
|
||||||
|
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
||||||
async function loadEvents(
|
async function loadEvents(
|
||||||
fetchInfo: { startStr: string; endStr: string },
|
fetchInfo: { startStr: string; endStr: string },
|
||||||
successCallback: (events: object[]) => void,
|
successCallback: (events: object[]) => void,
|
||||||
@@ -60,19 +128,21 @@ async function loadEvents(
|
|||||||
eventCache.clear();
|
eventCache.clear();
|
||||||
for (const e of entries) eventCache.set(e.id, e);
|
for (const e of entries) eventCache.set(e.id, e);
|
||||||
successCallback(entries.map(toFcEvent));
|
successCallback(entries.map(toFcEvent));
|
||||||
|
loadUpcoming();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDateClick(arg: DateClickArg) {
|
function handleDateClick(arg: DateClickArg) {
|
||||||
|
closePopover();
|
||||||
openCreate(arg.dateStr);
|
openCreate(arg.dateStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEventClick(arg: EventClickArg) {
|
function handleEventClick(arg: EventClickArg) {
|
||||||
const id = arg.event.extendedProps.entryId as number;
|
const id = arg.event.extendedProps.entryId as number;
|
||||||
const entry = eventCache.get(id);
|
const entry = eventCache.get(id);
|
||||||
if (entry) openEdit(entry);
|
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEventDrop(arg: EventDropArg) {
|
async function handleEventDrop(arg: EventDropArg) {
|
||||||
@@ -82,6 +152,7 @@ async function handleEventDrop(arg: EventDropArg) {
|
|||||||
try {
|
try {
|
||||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
||||||
eventCache.set(id, updated);
|
eventCache.set(id, updated);
|
||||||
|
loadUpcoming();
|
||||||
} catch {
|
} catch {
|
||||||
arg.revert();
|
arg.revert();
|
||||||
toast.show("Failed to move event", "error");
|
toast.show("Failed to move event", "error");
|
||||||
@@ -95,6 +166,7 @@ async function handleEventResize(arg: EventResizeDoneArg) {
|
|||||||
try {
|
try {
|
||||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
const updated = await updateEvent(id, { start_dt, end_dt });
|
||||||
eventCache.set(id, updated);
|
eventCache.set(id, updated);
|
||||||
|
loadUpcoming();
|
||||||
} catch {
|
} catch {
|
||||||
arg.revert();
|
arg.revert();
|
||||||
toast.show("Failed to resize event", "error");
|
toast.show("Failed to resize event", "error");
|
||||||
@@ -105,11 +177,11 @@ function onCreated(entry: EventEntry) {
|
|||||||
eventCache.set(entry.id, entry);
|
eventCache.set(entry.id, entry);
|
||||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
||||||
closeSlideOver();
|
closeSlideOver();
|
||||||
|
loadUpcoming();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpdated(entry: EventEntry) {
|
function onUpdated(entry: EventEntry) {
|
||||||
eventCache.set(entry.id, entry);
|
eventCache.set(entry.id, entry);
|
||||||
// Replace the event in FullCalendar
|
|
||||||
const api = calendarRef.value?.getApi();
|
const api = calendarRef.value?.getApi();
|
||||||
if (api) {
|
if (api) {
|
||||||
const existing = api.getEventById(String(entry.id));
|
const existing = api.getEventById(String(entry.id));
|
||||||
@@ -119,15 +191,15 @@ function onUpdated(entry: EventEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeSlideOver();
|
closeSlideOver();
|
||||||
|
loadUpcoming();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDeleted(id: number) {
|
function onDeleted(id: number) {
|
||||||
eventCache.delete(id);
|
eventCache.delete(id);
|
||||||
const api = calendarRef.value?.getApi();
|
const api = calendarRef.value?.getApi();
|
||||||
if (api) {
|
if (api) api.getEventById(String(id))?.remove();
|
||||||
api.getEventById(String(id))?.remove();
|
|
||||||
}
|
|
||||||
closeSlideOver();
|
closeSlideOver();
|
||||||
|
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarOptions: CalendarOptions = {
|
const calendarOptions: CalendarOptions = {
|
||||||
@@ -148,6 +220,41 @@ const calendarOptions: CalendarOptions = {
|
|||||||
eventResize: handleEventResize,
|
eventResize: handleEventResize,
|
||||||
height: "auto",
|
height: "auto",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Formatting helpers ─────────────────────────────────────────────────────
|
||||||
|
function fmtDate(dt: string, allDay: boolean): string {
|
||||||
|
const d = new Date(dt);
|
||||||
|
if (allDay) return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||||
|
return d.toLocaleString(undefined, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(dt: string): string {
|
||||||
|
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateShort(dt: string): string {
|
||||||
|
const d = new Date(dt);
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1);
|
||||||
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
|
if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow";
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group upcoming events by day label
|
||||||
|
const upcomingGrouped = computed(() => {
|
||||||
|
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
|
||||||
|
for (const e of upcomingEvents.value) {
|
||||||
|
const label = fmtDateShort(e.start_dt);
|
||||||
|
const existing = groups.find((g) => g.label === label);
|
||||||
|
if (existing) {
|
||||||
|
existing.events.push(e);
|
||||||
|
} else {
|
||||||
|
groups.push({ label, date: e.start_dt, events: [e] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -163,6 +270,79 @@ const calendarOptions: CalendarOptions = {
|
|||||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Upcoming events strip ───────────────────────────────────────── -->
|
||||||
|
<div v-if="upcomingEvents.length" class="upcoming-section">
|
||||||
|
<h2 class="upcoming-title">Upcoming</h2>
|
||||||
|
<div class="upcoming-groups">
|
||||||
|
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
|
||||||
|
<div class="upcoming-day-label">{{ group.label }}</div>
|
||||||
|
<div class="upcoming-cards">
|
||||||
|
<div
|
||||||
|
v-for="ev in group.events"
|
||||||
|
:key="ev.id"
|
||||||
|
class="upcoming-card"
|
||||||
|
:style="ev.color ? { '--ev-color': ev.color } : {}"
|
||||||
|
@click="openEdit(ev)"
|
||||||
|
>
|
||||||
|
<div class="upcoming-card-accent"></div>
|
||||||
|
<div class="upcoming-card-body">
|
||||||
|
<div class="upcoming-card-title">{{ ev.title }}</div>
|
||||||
|
<div class="upcoming-card-time">
|
||||||
|
<template v-if="ev.all_day">All day</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ fmtTime(ev.start_dt) }}
|
||||||
|
<span v-if="ev.end_dt"> – {{ fmtTime(ev.end_dt) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="ev.location" class="upcoming-card-meta">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
{{ ev.location }}
|
||||||
|
</div>
|
||||||
|
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Event popover ───────────────────────────────────────────────── -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="popover"
|
||||||
|
ref="popoverEl"
|
||||||
|
class="event-popover"
|
||||||
|
:style="popoverStyle"
|
||||||
|
>
|
||||||
|
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
|
||||||
|
<div class="popover-content">
|
||||||
|
<div class="popover-title">{{ popover.title }}</div>
|
||||||
|
<div class="popover-time">
|
||||||
|
<template v-if="popover.all_day">
|
||||||
|
{{ fmtDate(popover.start_dt, true) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ fmtDate(popover.start_dt, false) }}
|
||||||
|
<span v-if="popover.end_dt"> – {{ fmtTime(popover.end_dt) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="popover.location" class="popover-meta">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;margin-top:1px">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
{{ popover.location }}
|
||||||
|
</div>
|
||||||
|
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
|
||||||
|
<div class="popover-actions">
|
||||||
|
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
|
||||||
|
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<EventSlideOver
|
<EventSlideOver
|
||||||
v-if="slideOverOpen"
|
v-if="slideOverOpen"
|
||||||
:event="slideOverEvent"
|
:event="slideOverEvent"
|
||||||
@@ -267,4 +447,184 @@ const calendarOptions: CalendarOptions = {
|
|||||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
||||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||||||
|
.upcoming-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-day-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--color-surface, #1a1b1e);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.upcoming-card:hover {
|
||||||
|
border-color: rgba(99,102,241,0.4);
|
||||||
|
background: var(--color-bg-secondary, #16171a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-card-accent {
|
||||||
|
width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--ev-color, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-card-body {
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-card-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-card-time {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-card-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary, #aaa);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Event popover ──────────────────────────────────────────────────────── */
|
||||||
|
.event-popover {
|
||||||
|
background: var(--color-bg-card, #1e1f24);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||||
|
width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-accent {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-primary, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-content {
|
||||||
|
padding: 0.85rem 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-secondary, #aaa);
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border, #2a2b30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.popover-btn:hover { opacity: 0.85; }
|
||||||
|
.popover-btn--edit {
|
||||||
|
background: var(--color-primary, #6366f1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.popover-btn--close {
|
||||||
|
background: var(--color-input-bg, #111113);
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user