refactor(scribe): remove calendar + entity surfaces from web UI (frontend)
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 44s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 44s
Frontend half of the narrowing (milestone #194); matches backend b49efdc.
- delete CalendarView, EventSlideOver, WeatherCard (orphan)
- drop /calendar route + nav link + the g→l keyboard shortcut
- strip the calendar-events API client + event/metadata bits from note
types and the notes store
- KnowledgeView: remove People/Places/Lists tabs, entity cards, create
buttons and the upcoming-events widget; keep notes/tasks/plans/processes
+ the overdue-task badge
- NoteEditorView: remove person/place/list forms + list-builder + entity
metadata; keep note + process editors (type select = Note/Process)
- DashboardView: drop the "Upcoming · 7 days" events rail card
- SettingsView: remove the CalDAV integration card + save/test (its
endpoints were deleted backend-side)
- prune the now-dead entity/event CSS
RecurrenceEditor + task recurrence rules are kept (task machinery, not
calendar). Verified by a full dangler sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
This commit is contained in:
@@ -81,7 +81,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
case "p": router.push("/projects"); break;
|
case "p": router.push("/projects"); break;
|
||||||
case "r": router.push("/rules"); break;
|
case "r": router.push("/rules"); break;
|
||||||
case "g": router.push("/graph"); break;
|
case "g": router.push("/graph"); break;
|
||||||
case "l": router.push("/calendar"); break;
|
|
||||||
case "x": router.push("/trash"); break;
|
case "x": router.push("/trash"); break;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -190,12 +189,6 @@ onUnmounted(() => {
|
|||||||
<kbd class="shortcut-key">p</kbd>
|
<kbd class="shortcut-key">p</kbd>
|
||||||
<span class="shortcut-desc">Projects</span>
|
<span class="shortcut-desc">Projects</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-row">
|
|
||||||
<kbd class="shortcut-key">g</kbd>
|
|
||||||
<span class="shortcut-key-sep">+</span>
|
|
||||||
<kbd class="shortcut-key">l</kbd>
|
|
||||||
<span class="shortcut-desc">Calendar</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-row">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">g</kbd>
|
<kbd class="shortcut-key">g</kbd>
|
||||||
<span class="shortcut-key-sep">+</span>
|
<span class="shortcut-key-sep">+</span>
|
||||||
|
|||||||
@@ -374,72 +374,6 @@ export async function apiStreamPost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Calendar events
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface EventEntry {
|
|
||||||
id: number;
|
|
||||||
uid: string;
|
|
||||||
title: string;
|
|
||||||
start_dt: string;
|
|
||||||
end_dt: string | null;
|
|
||||||
all_day: boolean;
|
|
||||||
description: string;
|
|
||||||
location: string;
|
|
||||||
color: string;
|
|
||||||
recurrence: string | null;
|
|
||||||
caldav_uid: string;
|
|
||||||
project_id: number | null;
|
|
||||||
user_id: number;
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventCreatePayload {
|
|
||||||
title: string;
|
|
||||||
start_dt: string;
|
|
||||||
end_dt?: string;
|
|
||||||
all_day?: boolean;
|
|
||||||
description?: string;
|
|
||||||
location?: string;
|
|
||||||
color?: string;
|
|
||||||
recurrence?: string;
|
|
||||||
project_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventUpdatePayload {
|
|
||||||
title?: string;
|
|
||||||
start_dt?: string;
|
|
||||||
end_dt?: string;
|
|
||||||
all_day?: boolean;
|
|
||||||
description?: string;
|
|
||||||
location?: string;
|
|
||||||
color?: string;
|
|
||||||
recurrence?: string | null;
|
|
||||||
project_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
|
|
||||||
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
|
|
||||||
return apiPost<EventEntry>('/api/events', payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEvent(id: number): Promise<EventEntry> {
|
|
||||||
return apiGet<EventEntry>(`/api/events/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
|
|
||||||
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteEvent(id: number): Promise<void> {
|
|
||||||
return apiDelete(`/api/events/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ApiKeyEntry {
|
export interface ApiKeyEntry {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ router.afterEach(() => {
|
|||||||
<div class="nav-pill-bar">
|
<div class="nav-pill-bar">
|
||||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +92,6 @@ router.afterEach(() => {
|
|||||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
|
|||||||
@@ -1,677 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
|
||||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
|
||||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
// null = create mode; EventEntry = edit mode
|
|
||||||
event: EventEntry | null;
|
|
||||||
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
|
||||||
initialDate?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "close"): void;
|
|
||||||
(e: "created", event: EventEntry): void;
|
|
||||||
(e: "updated", event: EventEntry): void;
|
|
||||||
(e: "deleted", id: number): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const toast = useToastStore();
|
|
||||||
|
|
||||||
const isEditMode = computed(() => !!props.event);
|
|
||||||
const saving = ref(false);
|
|
||||||
const deleting = ref(false);
|
|
||||||
const deleteConfirm = ref(false);
|
|
||||||
|
|
||||||
// Form fields
|
|
||||||
const title = ref("");
|
|
||||||
const startDate = ref("");
|
|
||||||
const startTime = ref("");
|
|
||||||
const endDate = ref("");
|
|
||||||
const endTime = ref("");
|
|
||||||
const allDay = ref(false);
|
|
||||||
const description = ref("");
|
|
||||||
const location = ref("");
|
|
||||||
const color = ref("");
|
|
||||||
const projectId = ref<number | null>(null);
|
|
||||||
const recurrence = ref<string>("");
|
|
||||||
|
|
||||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
|
||||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
|
||||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
|
||||||
// string is shown read-only below the select.
|
|
||||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
|
||||||
none: "",
|
|
||||||
daily: "FREQ=DAILY",
|
|
||||||
weekly: "FREQ=WEEKLY",
|
|
||||||
monthly: "FREQ=MONTHLY",
|
|
||||||
yearly: "FREQ=YEARLY",
|
|
||||||
};
|
|
||||||
|
|
||||||
const recurrencePreset = computed<string>({
|
|
||||||
get() {
|
|
||||||
const r = (recurrence.value || "").trim();
|
|
||||||
if (!r) return "none";
|
|
||||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
|
||||||
if (val && val === r) return key;
|
|
||||||
}
|
|
||||||
return "custom";
|
|
||||||
},
|
|
||||||
set(key: string) {
|
|
||||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
|
||||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
|
||||||
|
|
||||||
function dateFromIso(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (isNaN(d.getTime())) return iso.slice(0, 10);
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeFromIso(iso: string): string {
|
|
||||||
if (!iso.includes("T")) return "09:00";
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (isNaN(d.getTime())) return iso.slice(11, 16);
|
|
||||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIso(date: string, time: string): string {
|
|
||||||
if (!time) return `${date}T00:00:00`;
|
|
||||||
// Include local timezone offset so the server stores the correct UTC time
|
|
||||||
const local = new Date(`${date}T${time}:00`);
|
|
||||||
const off = -local.getTimezoneOffset();
|
|
||||||
const sign = off >= 0 ? "+" : "-";
|
|
||||||
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
|
|
||||||
const min = String(Math.abs(off) % 60).padStart(2, "0");
|
|
||||||
return `${date}T${time}:00${sign}${h}:${min}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Round up to next 30-minute boundary */
|
|
||||||
function nextRoundedTime(): string {
|
|
||||||
const now = new Date();
|
|
||||||
let h = now.getHours();
|
|
||||||
let m = now.getMinutes();
|
|
||||||
if (m <= 30) { m = 30; }
|
|
||||||
else { m = 0; h = (h + 1) % 24; }
|
|
||||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add hours to a time string (HH:MM), returns HH:MM */
|
|
||||||
function addHours(time: string, hours: number): string {
|
|
||||||
const [h, m] = time.split(":").map(Number);
|
|
||||||
const totalMin = h * 60 + m + hours * 60;
|
|
||||||
const nh = Math.floor(totalMin / 60) % 24;
|
|
||||||
const nm = totalMin % 60;
|
|
||||||
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Duration in minutes between two time strings */
|
|
||||||
function durationMin(start: string, end: string): number {
|
|
||||||
const [sh, sm] = start.split(":").map(Number);
|
|
||||||
const [eh, em] = end.split(":").map(Number);
|
|
||||||
return (eh * 60 + em) - (sh * 60 + sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if a date+time is in the past */
|
|
||||||
const isPastEvent = computed(() => {
|
|
||||||
if (allDay.value || !startDate.value || !startTime.value) return false;
|
|
||||||
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
|
|
||||||
return dt.getTime() < Date.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track duration so end moves with start
|
|
||||||
let _lastDurationMin = 60;
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
if (props.event) {
|
|
||||||
title.value = props.event.title;
|
|
||||||
allDay.value = props.event.all_day;
|
|
||||||
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
|
|
||||||
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
|
||||||
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
|
|
||||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
|
|
||||||
description.value = props.event.description || "";
|
|
||||||
location.value = props.event.location || "";
|
|
||||||
color.value = props.event.color || "";
|
|
||||||
projectId.value = props.event.project_id;
|
|
||||||
recurrence.value = props.event.recurrence || "";
|
|
||||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
|
||||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
|
||||||
} else {
|
|
||||||
title.value = "";
|
|
||||||
allDay.value = false;
|
|
||||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
|
||||||
const roundedStart = nextRoundedTime();
|
|
||||||
startDate.value = base;
|
|
||||||
startTime.value = roundedStart;
|
|
||||||
endDate.value = base;
|
|
||||||
endTime.value = addHours(roundedStart, 1);
|
|
||||||
description.value = "";
|
|
||||||
location.value = "";
|
|
||||||
color.value = "";
|
|
||||||
projectId.value = null;
|
|
||||||
recurrence.value = "";
|
|
||||||
_lastDurationMin = 60;
|
|
||||||
}
|
|
||||||
deleteConfirm.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When start time changes, move end time to preserve duration
|
|
||||||
watch(startTime, (newStart) => {
|
|
||||||
if (allDay.value || !newStart) return;
|
|
||||||
endTime.value = addHours(newStart, _lastDurationMin / 60);
|
|
||||||
});
|
|
||||||
|
|
||||||
// When start date changes, move end date to match (preserve same-day or multi-day gap)
|
|
||||||
watch(startDate, (newDate) => {
|
|
||||||
if (!newDate) return;
|
|
||||||
endDate.value = newDate;
|
|
||||||
});
|
|
||||||
|
|
||||||
// When end time changes manually, update the tracked duration (but guard against end < start)
|
|
||||||
watch(endTime, (newEnd) => {
|
|
||||||
if (allDay.value || !newEnd || !startTime.value) return;
|
|
||||||
const dur = durationMin(startTime.value, newEnd);
|
|
||||||
if (dur <= 0) {
|
|
||||||
// Snap back to start + 1 hour
|
|
||||||
endTime.value = addHours(startTime.value, 1);
|
|
||||||
_lastDurationMin = 60;
|
|
||||||
} else {
|
|
||||||
_lastDurationMin = dur;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// All-day toggle: clear/restore times
|
|
||||||
watch(allDay, (isAllDay) => {
|
|
||||||
if (isAllDay) {
|
|
||||||
startTime.value = "";
|
|
||||||
endTime.value = "";
|
|
||||||
} else {
|
|
||||||
const rounded = nextRoundedTime();
|
|
||||||
startTime.value = rounded;
|
|
||||||
endTime.value = addHours(rounded, 1);
|
|
||||||
_lastDurationMin = 60;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => props.event, resetForm, { immediate: true });
|
|
||||||
watch(() => props.initialDate, resetForm);
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
if (deleteConfirm.value) {
|
|
||||||
// Esc cancels the delete-confirm rather than closing the modal —
|
|
||||||
// gives the user a clear way out of the destructive prompt.
|
|
||||||
deleteConfirm.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
attemptClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
|
||||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
|
||||||
|
|
||||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
|
||||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
|
||||||
//
|
|
||||||
// Validity-aware behavior:
|
|
||||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
|
||||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
|
||||||
// A toast tells the user what happened so they don't think their edit
|
|
||||||
// silently landed.
|
|
||||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
|
||||||
// with; no need to call this out).
|
|
||||||
|
|
||||||
function isFormValid(): { valid: boolean; reason?: string } {
|
|
||||||
if (!title.value.trim()) {
|
|
||||||
return { valid: false, reason: "Title required" };
|
|
||||||
}
|
|
||||||
if (!startDate.value) {
|
|
||||||
return { valid: false, reason: "Start date required" };
|
|
||||||
}
|
|
||||||
if (!allDay.value && !startTime.value) {
|
|
||||||
return { valid: false, reason: "Start time required" };
|
|
||||||
}
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
let _closing = false;
|
|
||||||
|
|
||||||
async function attemptClose() {
|
|
||||||
if (_closing) return;
|
|
||||||
_closing = true;
|
|
||||||
try {
|
|
||||||
const validity = isFormValid();
|
|
||||||
if (!validity.valid) {
|
|
||||||
if (isEditMode.value) {
|
|
||||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
|
||||||
}
|
|
||||||
// Create mode + invalid: silent close. Nothing was committed.
|
|
||||||
emit("close");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await save();
|
|
||||||
emit("close");
|
|
||||||
} finally {
|
|
||||||
_closing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
|
||||||
const end_dt = endDate.value
|
|
||||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
if (isEditMode.value && props.event) {
|
|
||||||
const payload: EventUpdatePayload = {
|
|
||||||
title: title.value.trim(),
|
|
||||||
start_dt,
|
|
||||||
end_dt,
|
|
||||||
all_day: allDay.value,
|
|
||||||
description: description.value,
|
|
||||||
location: location.value,
|
|
||||||
color: color.value,
|
|
||||||
project_id: projectId.value ?? undefined,
|
|
||||||
recurrence: recurrence.value || null,
|
|
||||||
};
|
|
||||||
const updated = await updateEvent(props.event.id, payload);
|
|
||||||
emit("updated", updated);
|
|
||||||
} else {
|
|
||||||
const payload: EventCreatePayload = {
|
|
||||||
title: title.value.trim(),
|
|
||||||
start_dt,
|
|
||||||
end_dt,
|
|
||||||
all_day: allDay.value,
|
|
||||||
description: description.value,
|
|
||||||
location: location.value,
|
|
||||||
color: color.value,
|
|
||||||
project_id: projectId.value ?? undefined,
|
|
||||||
recurrence: recurrence.value || undefined,
|
|
||||||
};
|
|
||||||
const created = await createEvent(payload);
|
|
||||||
emit("created", created);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to save event", "error");
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDelete() {
|
|
||||||
if (!props.event) return;
|
|
||||||
deleting.value = true;
|
|
||||||
try {
|
|
||||||
await deleteEvent(props.event.id);
|
|
||||||
toast.show("Event deleted", "success");
|
|
||||||
emit("deleted", props.event.id);
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to delete event", "error");
|
|
||||||
deleting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div class="modal-backdrop" @click.self="attemptClose">
|
|
||||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
|
||||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<template v-if="!deleteConfirm">
|
|
||||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button
|
|
||||||
v-if="isEditMode"
|
|
||||||
class="header-btn header-btn-danger"
|
|
||||||
@click="deleteConfirm = true"
|
|
||||||
title="Delete event"
|
|
||||||
aria-label="Delete event"
|
|
||||||
><Trash2 :size="16" /></button>
|
|
||||||
<button
|
|
||||||
class="header-btn"
|
|
||||||
@click="attemptClose"
|
|
||||||
title="Close"
|
|
||||||
aria-label="Close"
|
|
||||||
><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
:disabled="deleting"
|
|
||||||
@click="doDelete"
|
|
||||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-confirm-cancel"
|
|
||||||
@click="deleteConfirm = false"
|
|
||||||
>No</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body: form (scrolls if it gets long) -->
|
|
||||||
<form class="modal-form" @submit.prevent="attemptClose">
|
|
||||||
<!-- Title -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Title <span class="required">*</span></label>
|
|
||||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- All-day toggle -->
|
|
||||||
<div class="form-field form-field-row">
|
|
||||||
<label class="form-label form-label-inline">All day</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="['toggle-btn', { active: allDay }]"
|
|
||||||
@click="allDay = !allDay"
|
|
||||||
>{{ allDay ? "Yes" : "No" }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Start -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Start <span class="required">*</span></label>
|
|
||||||
<div class="dt-row">
|
|
||||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
|
||||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
|
||||||
</div>
|
|
||||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">End</label>
|
|
||||||
<div class="dt-row">
|
|
||||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
|
||||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recurrence -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Repeat</label>
|
|
||||||
<select v-model="recurrencePreset" class="form-input">
|
|
||||||
<option value="none">Does not repeat</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="yearly">Yearly</option>
|
|
||||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
|
||||||
Custom rule: <code>{{ recurrence }}</code>
|
|
||||||
<br />
|
|
||||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Location -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
|
||||||
<input v-model="location" class="form-input" placeholder="Location" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
|
||||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Color -->
|
|
||||||
<div class="form-field form-field-row">
|
|
||||||
<label class="form-label form-label-inline">Color</label>
|
|
||||||
<div class="color-row">
|
|
||||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
|
||||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
|
||||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Project -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
|
||||||
<ProjectSelector v-model="projectId" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
|
||||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
|
||||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-panel {
|
|
||||||
background: var(--color-surface, #1a1b1e);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: min(480px, 100%);
|
|
||||||
max-height: calc(100vh - 2.5rem);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
|
||||||
background: var(--color-surface, #1a1b1e);
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.4rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
line-height: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.header-btn:hover {
|
|
||||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
|
||||||
than Save would have been — destructive actions shouldn't loom. */
|
|
||||||
.header-btn-danger:hover {
|
|
||||||
background: var(--color-action-destructive);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline delete-confirm prompt replaces the title row */
|
|
||||||
.delete-confirm-prompt {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form scrolls inside the panel when content overflows */
|
|
||||||
.modal-form {
|
|
||||||
padding: 1.25rem 1.5rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
||||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
|
||||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
|
||||||
|
|
||||||
.required { color: #f87171; }
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
background: var(--color-input-bg, #111113);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.5rem 0.65rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
|
||||||
|
|
||||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
|
||||||
|
|
||||||
.dt-row { display: flex; gap: 0.5rem; }
|
|
||||||
.dt-date { flex: 1; }
|
|
||||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
|
||||||
.form-past-hint {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recurrence-custom-hint {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.recurrence-custom-hint code {
|
|
||||||
background: var(--color-input-bg, #111113);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
background: var(--color-input-bg, #111113);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.3rem 0.9rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.toggle-btn.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
|
||||||
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
|
||||||
.color-hex { flex: 1; }
|
|
||||||
.btn-clear-color {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.2rem 0.3rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--color-action-destructive);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
|
||||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-confirm-cancel {
|
|
||||||
background: var(--color-action-secondary);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
|
||||||
|
|
||||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
|
||||||
path as X / Esc / backdrop. No visible Save button needed. */
|
|
||||||
.hidden-submit {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0,0,0,0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
interface ForecastDay {
|
|
||||||
day: string
|
|
||||||
condition: string
|
|
||||||
high: number
|
|
||||||
low: number
|
|
||||||
precip_probability: number | null
|
|
||||||
precip_mm: number | null
|
|
||||||
windspeed_max: number
|
|
||||||
precip_summary?: string
|
|
||||||
precip_peak_hour?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeatherData {
|
|
||||||
location: string
|
|
||||||
fetched_at: string
|
|
||||||
current_temp: number
|
|
||||||
condition: string
|
|
||||||
today_high: number | null
|
|
||||||
today_low: number | null
|
|
||||||
yesterday_high: number | null
|
|
||||||
yesterday_low: number | null
|
|
||||||
wind_unit?: string
|
|
||||||
precip_summary?: string | null
|
|
||||||
forecast: ForecastDay[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
weather: WeatherData | null
|
|
||||||
tempUnit?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function weatherIcon(condition: string): string {
|
|
||||||
const c = condition.toLowerCase()
|
|
||||||
if (c.includes('thunderstorm')) return '⛈️'
|
|
||||||
if (c.includes('hail')) return '🌨️'
|
|
||||||
if (c.includes('snow showers')) return '🌨️'
|
|
||||||
if (c.includes('snow')) return '❄️'
|
|
||||||
if (c.includes('rain showers: violent')) return '⛈️'
|
|
||||||
if (c.includes('rain showers')) return '🌦️'
|
|
||||||
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
|
|
||||||
if (c.includes('fog')) return '🌫️'
|
|
||||||
if (c.includes('overcast')) return '☁️'
|
|
||||||
if (c.includes('partly cloudy')) return '⛅'
|
|
||||||
if (c.includes('mainly clear')) return '🌤️'
|
|
||||||
if (c.includes('clear')) return '☀️'
|
|
||||||
return '🌡️'
|
|
||||||
}
|
|
||||||
|
|
||||||
const unit = computed(() => props.tempUnit ?? 'C')
|
|
||||||
|
|
||||||
const tempDelta = computed(() => {
|
|
||||||
const w = props.weather
|
|
||||||
if (!w || w.today_high == null || w.yesterday_high == null) return null
|
|
||||||
const diff = w.today_high - w.yesterday_high
|
|
||||||
if (Math.abs(diff) < 1) return 'Same as yesterday'
|
|
||||||
const dir = diff > 0 ? 'warmer' : 'cooler'
|
|
||||||
return `${Math.abs(diff)}° ${dir} than yesterday`
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchedAtLabel = computed(() => {
|
|
||||||
if (!props.weather?.fetched_at) return ''
|
|
||||||
try {
|
|
||||||
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function hasPrecip(day: ForecastDay): boolean {
|
|
||||||
return (day.precip_probability != null && day.precip_probability > 0) ||
|
|
||||||
(day.precip_mm != null && day.precip_mm > 0)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="weather" class="weather-card">
|
|
||||||
<div class="weather-header">
|
|
||||||
<span class="weather-location">{{ weather.location }}</span>
|
|
||||||
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="weather-current">
|
|
||||||
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
|
|
||||||
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
|
|
||||||
<span class="weather-condition">{{ weather.condition }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="weather-today" v-if="weather.today_high != null">
|
|
||||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
|
||||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
|
||||||
💧 {{ weather.precip_summary }}
|
|
||||||
</div>
|
|
||||||
<table class="weather-forecast" v-if="weather.forecast.length">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th></th>
|
|
||||||
<th>Hi / Lo</th>
|
|
||||||
<th>💧</th>
|
|
||||||
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="day in weather.forecast" :key="day.day">
|
|
||||||
<td class="forecast-day-name">{{ day.day }}</td>
|
|
||||||
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
|
||||||
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
|
||||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
|
||||||
<template v-if="day.precip_summary">
|
|
||||||
<span class="precip-detail" :title="day.precip_summary">
|
|
||||||
{{ day.precip_probability }}%
|
|
||||||
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
|
||||||
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
|
|
||||||
<template v-else>—</template>
|
|
||||||
</td>
|
|
||||||
<td class="forecast-wind">{{ day.windspeed_max }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-else class="weather-card weather-unavailable">
|
|
||||||
Weather data unavailable — will retry at next slot.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.weather-card {
|
|
||||||
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-location {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-fetched-at {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-current {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-icon {
|
|
||||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-temp {
|
|
||||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-condition {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-today {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-delta {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-precip-summary {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.83rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
|
||||||
border-radius: var(--radius-sm, 6px);
|
|
||||||
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast thead th {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-align: right;
|
|
||||||
padding: 0.5rem 0.4rem 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast thead th:first-child,
|
|
||||||
.weather-forecast thead th:nth-child(2) {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast tbody td {
|
|
||||||
padding: 0.3rem 0.4rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-day-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-icon {
|
|
||||||
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-temps {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-precip {
|
|
||||||
text-align: right;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-precip--dry {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-detail {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-peak {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-wind {
|
|
||||||
text-align: right;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-unavailable {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -108,11 +108,6 @@ const router = createRouter({
|
|||||||
name: "shared-with-me",
|
name: "shared-with-me",
|
||||||
component: () => import("@/views/SharedWithMeView.vue"),
|
component: () => import("@/views/SharedWithMeView.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/calendar",
|
|
||||||
name: "calendar",
|
|
||||||
component: () => import("@/views/CalendarView.vue"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
name: "settings",
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
note_type?: string;
|
note_type?: string;
|
||||||
metadata?: Record<string, string> | null;
|
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Note>("/api/notes", data);
|
return await apiPost<Note>("/api/notes", data);
|
||||||
@@ -43,7 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function updateNote(
|
async function updateNote(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
|
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||||
): Promise<Note> {
|
): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { System } from "@/api/systems";
|
|||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
export type TaskKind = "work" | "plan" | "issue";
|
export type TaskKind = "work" | "plan" | "issue";
|
||||||
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
export type NoteType = "note" | "process";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,7 +28,6 @@ export interface Note {
|
|||||||
task_kind?: TaskKind;
|
task_kind?: TaskKind;
|
||||||
systems?: System[];
|
systems?: System[];
|
||||||
arose_from_id?: number | null;
|
arose_from_id?: number | null;
|
||||||
metadata: Record<string, string>;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,763 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
|
||||||
import FullCalendar from "@fullcalendar/vue3";
|
|
||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
|
||||||
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
|
||||||
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
|
|
||||||
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
|
||||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
|
||||||
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
|
|
||||||
import { MapPin } from "lucide-vue-next";
|
|
||||||
|
|
||||||
const toast = useToastStore();
|
|
||||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
|
||||||
|
|
||||||
// Slide-over state
|
|
||||||
const slideOverEvent = ref<EventEntry | null>(null);
|
|
||||||
const slideOverOpen = ref(false);
|
|
||||||
const slideOverDate = ref<string>("");
|
|
||||||
|
|
||||||
function openCreate(date: string) {
|
|
||||||
slideOverEvent.value = null;
|
|
||||||
slideOverDate.value = date;
|
|
||||||
slideOverOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(event: EventEntry) {
|
|
||||||
slideOverEvent.value = event;
|
|
||||||
slideOverDate.value = "";
|
|
||||||
slideOverOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSlideOver() {
|
|
||||||
slideOverOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event entry cache keyed by id
|
|
||||||
const eventCache = new Map<number, EventEntry>();
|
|
||||||
|
|
||||||
function toFcEvent(e: EventEntry) {
|
|
||||||
// For all-day events pass date-only strings so FullCalendar never shifts
|
|
||||||
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
|
|
||||||
return {
|
|
||||||
id: String(e.id),
|
|
||||||
title: e.title,
|
|
||||||
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
|
|
||||||
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
|
|
||||||
allDay: e.all_day,
|
|
||||||
backgroundColor: e.color || undefined,
|
|
||||||
borderColor: e.color || undefined,
|
|
||||||
extendedProps: { entryId: e.id },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
|
|
||||||
// ── Month/year picker ──────────────────────────────────────────────────────
|
|
||||||
const currentViewYear = ref(new Date().getFullYear());
|
|
||||||
const currentViewMonth = ref(new Date().getMonth());
|
|
||||||
const pickerOpen = ref(false);
|
|
||||||
const pickerYear = ref(new Date().getFullYear());
|
|
||||||
const pickerStyle = ref<Record<string, string>>({});
|
|
||||||
const pickerEl = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
|
|
||||||
|
|
||||||
function handleDatesSet(arg: { view: { currentStart: Date } }) {
|
|
||||||
const d = arg.view.currentStart;
|
|
||||||
currentViewYear.value = d.getFullYear();
|
|
||||||
currentViewMonth.value = d.getMonth();
|
|
||||||
}
|
|
||||||
|
|
||||||
function jumpTo(year: number, month: number) {
|
|
||||||
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
|
|
||||||
pickerOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 / open picker on outside or title click
|
|
||||||
function onDocClick(e: MouseEvent) {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
// Title click → toggle month/year picker
|
|
||||||
const titleEl = target.closest(".fc-toolbar-title");
|
|
||||||
if (titleEl) {
|
|
||||||
if (!pickerOpen.value) {
|
|
||||||
pickerYear.value = currentViewYear.value;
|
|
||||||
const rect = titleEl.getBoundingClientRect();
|
|
||||||
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
|
|
||||||
pickerStyle.value = {
|
|
||||||
position: "fixed",
|
|
||||||
top: `${rect.bottom + 6}px`,
|
|
||||||
left: `${left}px`,
|
|
||||||
zIndex: "9999",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
pickerOpen.value = !pickerOpen.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Close picker on outside click
|
|
||||||
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
|
|
||||||
pickerOpen.value = false;
|
|
||||||
}
|
|
||||||
// Close event popover on outside click
|
|
||||||
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
|
|
||||||
closePopover();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCalendarChanged() {
|
|
||||||
calendarRef.value?.getApi().refetchEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("mousedown", onDocClick);
|
|
||||||
document.addEventListener("scribe:calendar-changed", onCalendarChanged);
|
|
||||||
});
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener("mousedown", onDocClick);
|
|
||||||
document.removeEventListener("scribe:calendar-changed", onCalendarChanged);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
|
||||||
async function loadEvents(
|
|
||||||
fetchInfo: { startStr: string; endStr: string },
|
|
||||||
successCallback: (events: object[]) => void,
|
|
||||||
failureCallback: (error: Error) => void,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
|
|
||||||
eventCache.clear();
|
|
||||||
for (const e of entries) eventCache.set(e.id, e);
|
|
||||||
successCallback(entries.map(toFcEvent));
|
|
||||||
loadUpcoming();
|
|
||||||
} catch (err) {
|
|
||||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDateClick(arg: DateClickArg) {
|
|
||||||
closePopover();
|
|
||||||
openCreate(arg.dateStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEventClick(arg: EventClickArg) {
|
|
||||||
const id = arg.event.extendedProps.entryId as number;
|
|
||||||
const entry = eventCache.get(id);
|
|
||||||
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventDrop(arg: EventDropArg) {
|
|
||||||
const id = arg.event.extendedProps.entryId as number;
|
|
||||||
const start_dt = arg.event.startStr;
|
|
||||||
const end_dt = arg.event.endStr || undefined;
|
|
||||||
try {
|
|
||||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
|
||||||
eventCache.set(id, updated);
|
|
||||||
loadUpcoming();
|
|
||||||
} catch {
|
|
||||||
arg.revert();
|
|
||||||
toast.show("Failed to move event", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventResize(arg: EventResizeDoneArg) {
|
|
||||||
const id = arg.event.extendedProps.entryId as number;
|
|
||||||
const start_dt = arg.event.startStr;
|
|
||||||
const end_dt = arg.event.endStr || undefined;
|
|
||||||
try {
|
|
||||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
|
||||||
eventCache.set(id, updated);
|
|
||||||
loadUpcoming();
|
|
||||||
} catch {
|
|
||||||
arg.revert();
|
|
||||||
toast.show("Failed to resize event", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCreated(entry: EventEntry) {
|
|
||||||
eventCache.set(entry.id, entry);
|
|
||||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
|
||||||
closeSlideOver();
|
|
||||||
loadUpcoming();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUpdated(entry: EventEntry) {
|
|
||||||
eventCache.set(entry.id, entry);
|
|
||||||
const api = calendarRef.value?.getApi();
|
|
||||||
if (api) {
|
|
||||||
const existing = api.getEventById(String(entry.id));
|
|
||||||
if (existing) {
|
|
||||||
existing.remove();
|
|
||||||
api.addEvent(toFcEvent(entry));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeSlideOver();
|
|
||||||
loadUpcoming();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDeleted(id: number) {
|
|
||||||
eventCache.delete(id);
|
|
||||||
const api = calendarRef.value?.getApi();
|
|
||||||
if (api) api.getEventById(String(id))?.remove();
|
|
||||||
closeSlideOver();
|
|
||||||
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarOptions: CalendarOptions = {
|
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
|
||||||
initialView: "dayGridMonth",
|
|
||||||
timeZone: "local",
|
|
||||||
editable: true,
|
|
||||||
selectable: false,
|
|
||||||
headerToolbar: {
|
|
||||||
left: "prev,next today",
|
|
||||||
center: "title",
|
|
||||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
|
||||||
},
|
|
||||||
events: loadEvents,
|
|
||||||
datesSet: handleDatesSet,
|
|
||||||
dateClick: handleDateClick,
|
|
||||||
eventClick: handleEventClick,
|
|
||||||
eventDrop: handleEventDrop,
|
|
||||||
eventResize: handleEventResize,
|
|
||||||
height: "auto",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 = fmtDayLabel(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>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="calendar-view">
|
|
||||||
<div class="cal-header">
|
|
||||||
<h1 class="cal-title">Calendar</h1>
|
|
||||||
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
|
|
||||||
+ New Event
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fc-wrapper">
|
|
||||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
|
||||||
</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">
|
|
||||||
<MapPin :size="16" style="flex-shrink:0" />
|
|
||||||
{{ 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">
|
|
||||||
{{ fmtDateTime(popover.start_dt, true) }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ fmtDateTime(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">
|
|
||||||
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
|
|
||||||
{{ 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>
|
|
||||||
|
|
||||||
<!-- ── Month/year picker ──────────────────────────────────────────── -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
|
|
||||||
<div class="picker-year-row">
|
|
||||||
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year">‹</button>
|
|
||||||
<span class="picker-year-label">{{ pickerYear }}</span>
|
|
||||||
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year">›</button>
|
|
||||||
</div>
|
|
||||||
<div class="picker-months">
|
|
||||||
<button
|
|
||||||
v-for="(name, i) in MONTH_NAMES"
|
|
||||||
:key="i"
|
|
||||||
class="picker-month"
|
|
||||||
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
|
|
||||||
@click="jumpTo(pickerYear, i)"
|
|
||||||
>{{ name }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<EventSlideOver
|
|
||||||
v-if="slideOverOpen"
|
|
||||||
:event="slideOverEvent"
|
|
||||||
:initial-date="slideOverDate"
|
|
||||||
@close="closeSlideOver"
|
|
||||||
@created="onCreated"
|
|
||||||
@updated="onUpdated"
|
|
||||||
@deleted="onDeleted"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.calendar-view {
|
|
||||||
max-width: var(--page-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 1.5rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cal-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* New event: Moss action-primary — workflow action, not a brand moment */
|
|
||||||
.btn-new-event {
|
|
||||||
background: var(--color-action-primary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.55rem 1.1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-new-event:hover { background: var(--color-action-primary-hover); }
|
|
||||||
|
|
||||||
.fc-wrapper {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: var(--radius-lg, 18px);
|
|
||||||
padding: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FullCalendar dark theme overrides */
|
|
||||||
:deep(.fc) {
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
:deep(.fc-toolbar-title) {
|
|
||||||
/* FullCalendar renders this as <h2>, which would otherwise pick up the
|
|
||||||
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
|
|
||||||
"Fraunces only at ≥18px" threshold, so explicit Inter. */
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
:deep(.fc-toolbar-title:hover) {
|
|
||||||
background: rgba(255,255,255,0.07);
|
|
||||||
}
|
|
||||||
:deep(.fc-button) {
|
|
||||||
background: var(--color-input-bg, var(--color-bg));
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
:deep(.fc-button:hover),
|
|
||||||
:deep(.fc-button-active) {
|
|
||||||
background: var(--color-primary) !important;
|
|
||||||
border-color: var(--color-primary) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
|
||||||
:deep(.fc-daygrid-day-number),
|
|
||||||
:deep(.fc-col-header-cell-cushion) {
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
:deep(.fc-daygrid-day.fc-day-today) {
|
|
||||||
background: var(--color-primary-tint);
|
|
||||||
}
|
|
||||||
:deep(.fc-event) {
|
|
||||||
border-radius: 4px;
|
|
||||||
border: none;
|
|
||||||
padding: 1px 4px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
:deep(.fc-event-main) { color: #fff; }
|
|
||||||
:deep(.fc-event:not([style*="background"])) {
|
|
||||||
background: var(--color-primary);
|
|
||||||
}
|
|
||||||
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
|
||||||
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
|
||||||
:deep(.fc-scrollgrid td),
|
|
||||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
|
||||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
|
||||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
|
||||||
|
|
||||||
/* ── Month/year picker ──────────────────────────────────────────────────── */
|
|
||||||
.month-picker {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
||||||
width: 280px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-year-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-year-label {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-year-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.picker-year-btn:hover {
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
border-color: rgba(255,255,255,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-months {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-month {
|
|
||||||
padding: 7px 4px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
text-align: center;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.picker-month:hover {
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
}
|
|
||||||
.picker-month.active {
|
|
||||||
background: rgba(91, 74, 138,0.22);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
|
||||||
.upcoming-section {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
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: 500;
|
|
||||||
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);
|
|
||||||
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: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-accent {
|
|
||||||
width: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--ev-color, #5B4A8A);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-body {
|
|
||||||
padding: 0.6rem 0.85rem;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-content {
|
|
||||||
padding: 0.85rem 1rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-title {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
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: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.popover-btn:hover { opacity: 0.85; }
|
|
||||||
.popover-btn--edit {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.popover-btn--close {
|
|
||||||
background: var(--color-input-bg, var(--color-bg));
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -11,13 +11,11 @@ interface ActiveProject {
|
|||||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||||
}
|
}
|
||||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||||
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
|
||||||
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||||
interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
|
interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
active_projects: ActiveProject[];
|
active_projects: ActiveProject[];
|
||||||
recently_completed: DoneItem[];
|
recently_completed: DoneItem[];
|
||||||
upcoming_events: UpcomingEvent[];
|
|
||||||
open_issues: IssueRow[];
|
open_issues: IssueRow[];
|
||||||
week_stats: WeekStats;
|
week_stats: WeekStats;
|
||||||
}
|
}
|
||||||
@@ -31,19 +29,11 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
data.value = { active_projects: [], recently_completed: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function fmtEvent(e: UpcomingEvent): string {
|
|
||||||
if (!e.start_dt) return "";
|
|
||||||
const d = new Date(e.start_dt);
|
|
||||||
const day = d.toLocaleDateString(undefined, { weekday: "short" });
|
|
||||||
if (e.all_day) return day;
|
|
||||||
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -150,17 +140,6 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="dash-label">Upcoming · 7 days</div>
|
|
||||||
<div class="rail-card">
|
|
||||||
<template v-if="data.upcoming_events.length">
|
|
||||||
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
|
|
||||||
<span class="evt-when">{{ fmtEvent(e) }}</span>
|
|
||||||
<span class="evt-title">{{ e.title }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p v-else class="rail-empty">Nothing scheduled.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dash-label">This week</div>
|
<div class="dash-label">This week</div>
|
||||||
<div class="rail-card stats">
|
<div class="rail-card stats">
|
||||||
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
||||||
@@ -241,11 +220,6 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||||
|
|
||||||
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||||
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
|
|
||||||
.evt-row:last-child { border-bottom: none; }
|
|
||||||
.evt-when { color: var(--color-muted); white-space: nowrap; }
|
|
||||||
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
|
||||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||||
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPatch, listEvents } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import { fmtCompact } from "@/utils/dateFormat";
|
|
||||||
import GraphView from "@/views/GraphView.vue";
|
import GraphView from "@/views/GraphView.vue";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
User,
|
|
||||||
MapPin,
|
|
||||||
List,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
@@ -24,28 +20,13 @@ const router = useRouter();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
note_type: "note" | "task" | "process";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
metadata: Record<string, string>;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
// type-specific
|
|
||||||
relationship?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
birthday?: string;
|
|
||||||
organization?: string;
|
|
||||||
address?: string;
|
|
||||||
hours?: string;
|
|
||||||
website?: string;
|
|
||||||
category?: string;
|
|
||||||
item_count?: number;
|
|
||||||
checked_count?: number;
|
|
||||||
list_items?: { text: string; checked: boolean }[];
|
|
||||||
body?: string;
|
|
||||||
// Task-specific
|
// Task-specific
|
||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
@@ -53,16 +34,9 @@ interface KnowledgeItem {
|
|||||||
task_kind?: "work" | "plan";
|
task_kind?: "work" | "plan";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpcomingEvent {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
start_dt: string;
|
|
||||||
all_day: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
|
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
|
||||||
const activeTag = ref("");
|
const activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -70,8 +44,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
|
|
||||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
|
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
|
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||||
|
|
||||||
async function fetchCounts() {
|
async function fetchCounts() {
|
||||||
try {
|
try {
|
||||||
@@ -212,26 +186,17 @@ watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
|||||||
|
|
||||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const upcomingEvents = ref<UpcomingEvent[]>([]);
|
|
||||||
const overdueCount = ref(0);
|
const overdueCount = ref(0);
|
||||||
|
|
||||||
async function fetchTodayBar() {
|
async function fetchTodayBar() {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const taskData = await apiGet<{ total: number }>(
|
||||||
const end = new Date(now.getTime() + 7 * 86_400_000);
|
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
||||||
const [events, taskData] = await Promise.all([
|
).catch(() => ({ total: 0 }));
|
||||||
listEvents(now.toISOString(), end.toISOString()).catch(() => [] as UpcomingEvent[]),
|
|
||||||
apiGet<{ total: number }>(
|
|
||||||
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
|
||||||
).catch(() => ({ total: 0 })),
|
|
||||||
]);
|
|
||||||
upcomingEvents.value = events.slice(0, 3);
|
|
||||||
overdueCount.value = taskData.total ?? 0;
|
overdueCount.value = taskData.total ?? 0;
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatEventDate = fmtCompact
|
|
||||||
|
|
||||||
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
||||||
@@ -250,40 +215,6 @@ function toggleGraphExpand() {
|
|||||||
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── List item toggle ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function toggleListItem(item: KnowledgeItem, index: number) {
|
|
||||||
if (!item.list_items || item.body === undefined) return;
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const newChecked = !item.list_items[index].checked;
|
|
||||||
item.list_items[index].checked = newChecked;
|
|
||||||
item.checked_count = item.list_items.filter(i => i.checked).length;
|
|
||||||
|
|
||||||
// Rebuild the body by replacing the targeted checkbox line
|
|
||||||
let listIdx = 0;
|
|
||||||
const newBody = (item.body).split('\n').map(line => {
|
|
||||||
const stripped = line.trimStart();
|
|
||||||
if (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] ')) {
|
|
||||||
if (listIdx === index) {
|
|
||||||
const indent = line.length - stripped.length;
|
|
||||||
const newLine = (' '.repeat(indent)) + (newChecked ? '- [x] ' : '- [ ] ') + item.list_items![index].text;
|
|
||||||
listIdx++;
|
|
||||||
return newLine;
|
|
||||||
}
|
|
||||||
listIdx++;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
item.body = newBody;
|
|
||||||
try {
|
|
||||||
await apiPatch(`/api/notes/${item.id}`, { body: newBody });
|
|
||||||
} catch {
|
|
||||||
reset(); // revert on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isOverdue(item: KnowledgeItem): boolean {
|
function isOverdue(item: KnowledgeItem): boolean {
|
||||||
@@ -352,22 +283,9 @@ onUnmounted(() => {
|
|||||||
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
||||||
|
|
||||||
<!-- Today bar -->
|
<!-- Today bar -->
|
||||||
<div class="today-bar">
|
<div v-if="overdueCount > 0" class="today-bar">
|
||||||
<div class="today-events">
|
|
||||||
<span v-if="upcomingEvents.length === 0" class="today-empty">No upcoming events</span>
|
|
||||||
<router-link
|
|
||||||
v-for="ev in upcomingEvents"
|
|
||||||
:key="ev.id"
|
|
||||||
:to="`/calendar`"
|
|
||||||
class="today-event-chip"
|
|
||||||
>
|
|
||||||
<span class="chip-dot"></span>
|
|
||||||
{{ ev.title }}
|
|
||||||
<span class="chip-date">{{ formatEventDate(ev.start_dt, ev.all_day) }}</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div class="today-actions">
|
<div class="today-actions">
|
||||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
<router-link to="/tasks" class="overdue-badge">
|
||||||
{{ overdueCount }} overdue
|
{{ overdueCount }} overdue
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,18 +310,6 @@ onUnmounted(() => {
|
|||||||
<CheckSquare :size="16" />
|
<CheckSquare :size="16" />
|
||||||
Task
|
Task
|
||||||
</button>
|
</button>
|
||||||
<button @click="createNew('person')">
|
|
||||||
<User :size="16" />
|
|
||||||
Person
|
|
||||||
</button>
|
|
||||||
<button @click="createNew('place')">
|
|
||||||
<MapPin :size="16" />
|
|
||||||
Place
|
|
||||||
</button>
|
|
||||||
<button @click="createNew('list')">
|
|
||||||
<List :size="16" />
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button @click="createNew('process')">
|
<button @click="createNew('process')">
|
||||||
<Workflow :size="16" />
|
<Workflow :size="16" />
|
||||||
Process
|
Process
|
||||||
@@ -422,11 +328,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
|
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
|
||||||
:key="val"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
:class="{ active: activeType === val }"
|
||||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
|
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
|
||||||
>
|
>
|
||||||
<span class="filter-btn-label">{{ label }}</span>
|
<span class="filter-btn-label">{{ label }}</span>
|
||||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||||
@@ -496,54 +402,15 @@ onUnmounted(() => {
|
|||||||
<!-- Type badge -->
|
<!-- Type badge -->
|
||||||
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
||||||
<span v-if="item.note_type === 'note'">Note</span>
|
<span v-if="item.note_type === 'note'">Note</span>
|
||||||
<span v-else-if="item.note_type === 'person'">Person</span>
|
|
||||||
<span v-else-if="item.note_type === 'place'">Place</span>
|
|
||||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||||
<span v-else>List</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="k-card-body">
|
<div class="k-card-body">
|
||||||
<div class="k-card-title">{{ item.title }}</div>
|
<div class="k-card-title">{{ item.title }}</div>
|
||||||
|
|
||||||
<!-- Person specifics -->
|
|
||||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
|
||||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
|
||||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
|
||||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Place specifics -->
|
|
||||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
|
||||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
|
||||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
|
||||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- List specifics -->
|
|
||||||
<div v-else-if="item.note_type === 'list'" class="k-card-list" @click.stop>
|
|
||||||
<label
|
|
||||||
v-for="(li, idx) in (item.list_items ?? []).slice(0, 6)"
|
|
||||||
:key="idx"
|
|
||||||
class="list-item-row"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="li.checked"
|
|
||||||
@change="toggleListItem(item, idx)"
|
|
||||||
/>
|
|
||||||
<span :class="{ 'list-item-done': li.checked }">{{ li.text }}</span>
|
|
||||||
</label>
|
|
||||||
<div v-if="(item.list_items?.length ?? 0) > 6" class="list-item-more">
|
|
||||||
+{{ (item.list_items?.length ?? 0) - 6 }} more
|
|
||||||
</div>
|
|
||||||
<span class="list-progress" style="margin-top: 6px;">
|
|
||||||
<span class="list-progress-bar" :style="{ width: item.item_count ? `${Math.round(((item.checked_count ?? 0) / item.item_count) * 100)}%` : '0%' }"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Task specifics -->
|
<!-- Task specifics -->
|
||||||
<div v-else-if="item.note_type === 'task'" class="k-card-task">
|
<div v-if="item.note_type === 'task'" class="k-card-task">
|
||||||
<div class="task-badges">
|
<div class="task-badges">
|
||||||
<span class="status-badge" :class="`status--${item.status}`">
|
<span class="status-badge" :class="`status--${item.status}`">
|
||||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||||
@@ -631,35 +498,6 @@ onUnmounted(() => {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.today-events {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.today-empty {
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
.today-event-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(91, 74, 138, 0.1);
|
|
||||||
border: 1px solid rgba(91, 74, 138, 0.2);
|
|
||||||
color: var(--color-text);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
|
|
||||||
.chip-dot {
|
|
||||||
width: 6px; height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #5B4A8A;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
|
||||||
.today-actions { display: flex; align-items: center; gap: 10px; }
|
.today-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
.overdue-badge {
|
.overdue-badge {
|
||||||
padding: 2px 9px;
|
padding: 2px 9px;
|
||||||
@@ -929,14 +767,10 @@ onUnmounted(() => {
|
|||||||
/* Type-specific card DNA */
|
/* Type-specific card DNA */
|
||||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||||
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
|
|
||||||
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
|
|
||||||
.k-card--list { border-color: rgba(56, 189, 248, 0.18); }
|
|
||||||
|
|
||||||
/* Top gradient bars */
|
/* Top gradient bars */
|
||||||
.k-card--note::before,
|
.k-card--note::before,
|
||||||
.k-card--task::before,
|
.k-card--task::before {
|
||||||
.k-card--list::before {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -952,25 +786,6 @@ onUnmounted(() => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
||||||
}
|
}
|
||||||
.k-card--list::before {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Corner accents for entity types */
|
|
||||||
.k-card--person::after,
|
|
||||||
.k-card--place::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 0 14px 0 60px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
|
|
||||||
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
|
|
||||||
|
|
||||||
/* Type badge */
|
/* Type badge */
|
||||||
.type-badge {
|
.type-badge {
|
||||||
@@ -985,9 +800,6 @@ onUnmounted(() => {
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
||||||
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
|
|
||||||
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
|
||||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
|
||||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||||
|
|
||||||
@@ -1012,70 +824,6 @@ onUnmounted(() => {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.k-card-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.meta-chip {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
.meta-muted { color: var(--color-muted); }
|
|
||||||
|
|
||||||
/* List card items */
|
|
||||||
.k-card-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.list-item-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.list-item-row input[type="checkbox"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.list-item-done {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.list-item-more {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* List progress bar */
|
|
||||||
.list-progress {
|
|
||||||
display: block;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.list-progress-bar {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
background: #38bdf8;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.k-card-footer {
|
.k-card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import type { NoteType } from "@/types/note";
|
import type { NoteType } from "@/types/note";
|
||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
@@ -34,85 +34,10 @@ const tags = ref<string[]>([]);
|
|||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
const noteType = ref<NoteType>("note");
|
const noteType = ref<NoteType>("note");
|
||||||
const entityMeta = reactive<Record<string, string>>({});
|
|
||||||
const dirty = ref(false);
|
const dirty = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
const sidebarOpen = ref(true);
|
const sidebarOpen = ref(true);
|
||||||
const notesExpanded = ref(false);
|
|
||||||
|
|
||||||
// ── List builder ─────────────────────────────────────────────────────────────
|
|
||||||
interface ListItem {
|
|
||||||
text: string;
|
|
||||||
checked: boolean;
|
|
||||||
}
|
|
||||||
const listItems = ref<ListItem[]>([]);
|
|
||||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
|
||||||
|
|
||||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
|
||||||
const lines = bodyText.split('\n');
|
|
||||||
const items: ListItem[] = [];
|
|
||||||
const extraLines: string[] = [];
|
|
||||||
let pastList = false;
|
|
||||||
for (const line of lines) {
|
|
||||||
const stripped = line.trimStart();
|
|
||||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
|
||||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
|
||||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
|
||||||
pastList = true;
|
|
||||||
} else {
|
|
||||||
pastList = true;
|
|
||||||
extraLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { items, extra: extraLines.join('\n').trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeListToBody(): string {
|
|
||||||
const listPart = listItems.value
|
|
||||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
|
||||||
.join('\n');
|
|
||||||
const extraPart = body.value.trim();
|
|
||||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addListItem(afterIndex?: number) {
|
|
||||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
|
||||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
listItemRefs.value[idx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeListItem(index: number) {
|
|
||||||
if (listItems.value.length <= 1) return;
|
|
||||||
listItems.value.splice(index, 1);
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
const focusIdx = Math.max(0, index - 1);
|
|
||||||
listItemRefs.value[focusIdx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addListItem(index);
|
|
||||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
|
||||||
e.preventDefault();
|
|
||||||
removeListItem(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemInput(_index: number) {
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleListItemCheck(index: number) {
|
|
||||||
listItems.value[index].checked = !listItems.value[index].checked;
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||||
const titleRef = ref<HTMLInputElement | null>(null);
|
const titleRef = ref<HTMLInputElement | null>(null);
|
||||||
@@ -127,9 +52,6 @@ const isEditing = computed(() => noteId.value !== null);
|
|||||||
|
|
||||||
const titlePlaceholder = computed(() => {
|
const titlePlaceholder = computed(() => {
|
||||||
switch (noteType.value) {
|
switch (noteType.value) {
|
||||||
case 'person': return 'Name';
|
|
||||||
case 'place': return 'Place name';
|
|
||||||
case 'list': return 'List title';
|
|
||||||
case 'process': return 'Process name';
|
case 'process': return 'Process name';
|
||||||
default: return 'Title';
|
default: return 'Title';
|
||||||
}
|
}
|
||||||
@@ -276,7 +198,6 @@ let savedTags: string[] = [];
|
|||||||
let savedProjectId: number | null = null;
|
let savedProjectId: number | null = null;
|
||||||
let savedMilestoneId: number | null = null;
|
let savedMilestoneId: number | null = null;
|
||||||
let savedNoteType: NoteType = "note";
|
let savedNoteType: NoteType = "note";
|
||||||
let savedEntityMeta: Record<string, string> = {};
|
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
dirty.value =
|
dirty.value =
|
||||||
@@ -285,8 +206,7 @@ function markDirty() {
|
|||||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||||
projectId.value !== savedProjectId ||
|
projectId.value !== savedProjectId ||
|
||||||
milestoneId.value !== savedMilestoneId ||
|
milestoneId.value !== savedMilestoneId ||
|
||||||
noteType.value !== savedNoteType ||
|
noteType.value !== savedNoteType;
|
||||||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -304,31 +224,19 @@ onMounted(async () => {
|
|||||||
projectId.value = store.currentNote.project_id ?? null;
|
projectId.value = store.currentNote.project_id ?? null;
|
||||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||||
Object.assign(entityMeta, store.currentNote.metadata || {});
|
|
||||||
notesExpanded.value = !!(store.currentNote.body || '').trim();
|
|
||||||
if (noteType.value === 'list') {
|
|
||||||
const parsed = parseListFromBody(body.value);
|
|
||||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
|
||||||
body.value = parsed.extra;
|
|
||||||
notesExpanded.value = !!parsed.extra;
|
|
||||||
}
|
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New note: read type from query param
|
// New note: read type from query param
|
||||||
const qt = route.query.type as string | undefined;
|
const qt = route.query.type as string | undefined;
|
||||||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
if (qt && ["note", "process"].includes(qt)) {
|
||||||
noteType.value = qt as NoteType;
|
noteType.value = qt as NoteType;
|
||||||
}
|
}
|
||||||
if (noteType.value === 'list') {
|
|
||||||
listItems.value = [{ text: '', checked: false }];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore pending draft if any
|
// Restore pending draft if any
|
||||||
@@ -349,7 +257,7 @@ onMounted(async () => {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (saving.value) return;
|
if (saving.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
@@ -359,7 +267,6 @@ async function save() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value,
|
note_type: noteType.value,
|
||||||
metadata: { ...entityMeta },
|
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
@@ -367,7 +274,6 @@ async function save() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note saved");
|
toast.show("Note saved");
|
||||||
} else {
|
} else {
|
||||||
@@ -378,7 +284,6 @@ async function save() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value,
|
note_type: noteType.value,
|
||||||
metadata: { ...entityMeta },
|
|
||||||
});
|
});
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note created");
|
toast.show("Note created");
|
||||||
@@ -414,12 +319,12 @@ async function confirmDelete() {
|
|||||||
async function doAutoSave() {
|
async function doAutoSave() {
|
||||||
if (!isEditing.value || saving.value) return;
|
if (!isEditing.value || saving.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
title: title.value, body: finalBody, tags: tags.value,
|
title: title.value, body: finalBody, tags: tags.value,
|
||||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value, metadata: { ...entityMeta },
|
note_type: noteType.value,
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
@@ -427,7 +332,6 @@ async function doAutoSave() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Auto-saved");
|
toast.show("Auto-saved");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -473,138 +377,8 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
|
|
||||||
<!-- ── Main column ────────────────────────────────────────── -->
|
<!-- ── Main column ────────────────────────────────────────── -->
|
||||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||||
<!-- ── Person form ──────────────────────────────────────── -->
|
|
||||||
<template v-if="noteType === 'person'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Relationship</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Birthday</label>
|
|
||||||
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Email</label>
|
|
||||||
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Organization</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Place form ───────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'place'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Hours</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Website</label>
|
|
||||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Category</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── List builder ─────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'list'">
|
|
||||||
<div class="list-builder">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in listItems"
|
|
||||||
:key="idx"
|
|
||||||
class="lb-item"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="item.checked"
|
|
||||||
@change="toggleListItemCheck(idx)"
|
|
||||||
class="lb-check"
|
|
||||||
tabindex="-1"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
|
||||||
v-model="item.text"
|
|
||||||
class="lb-text"
|
|
||||||
placeholder="List item..."
|
|
||||||
@keydown="onListItemKeydown($event, idx)"
|
|
||||||
@input="onListItemInput(idx)"
|
|
||||||
/>
|
|
||||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
|
||||||
</div>
|
|
||||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||||||
<template v-else-if="noteType === 'process'">
|
<template v-if="noteType === 'process'">
|
||||||
<label class="ef-label">Prompt</label>
|
<label class="ef-label">Prompt</label>
|
||||||
<textarea
|
<textarea
|
||||||
class="prompt-editor"
|
class="prompt-editor"
|
||||||
@@ -718,9 +492,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
<label class="sb-label">Type</label>
|
<label class="sb-label">Type</label>
|
||||||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||||||
<option value="note">Note</option>
|
<option value="note">Note</option>
|
||||||
<option value="person">Person</option>
|
<option value="process">Process</option>
|
||||||
<option value="place">Place</option>
|
|
||||||
<option value="list">List</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1034,18 +806,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
/* ── Process editor ─────────────────────────────────────── */
|
||||||
.entity-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.ef-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.ef-label {
|
.ef-label {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -1071,119 +832,6 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.prompt-editor:focus {
|
.prompt-editor:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.ef-input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.ef-input:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.ef-input::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Notes section (collapsible TipTap) ─────────────────── */
|
|
||||||
.notes-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
padding-top: 12px;
|
|
||||||
}
|
|
||||||
.notes-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.notes-toggle:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.notes-editor-wrap {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── List builder ───────────────────────────────────────── */
|
|
||||||
.list-builder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
.lb-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.lb-check {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.lb-text {
|
|
||||||
flex: 1;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-text:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.lb-text::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.lb-delete {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.lb-item:hover .lb-delete,
|
|
||||||
.lb-text:focus ~ .lb-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.lb-delete:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.lb-add {
|
|
||||||
background: none;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-add:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Narrow screen: sidebar collapses */
|
/* Narrow screen: sidebar collapses */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||||
|
|||||||
@@ -366,18 +366,6 @@ const notifySecurityAlerts = ref(true);
|
|||||||
const savingNotifications = ref(false);
|
const savingNotifications = ref(false);
|
||||||
const notificationsSaved = ref(false);
|
const notificationsSaved = ref(false);
|
||||||
|
|
||||||
// CalDAV settings (per-user)
|
|
||||||
const caldav = ref({
|
|
||||||
caldav_url: "",
|
|
||||||
caldav_username: "",
|
|
||||||
caldav_password: "",
|
|
||||||
caldav_calendar_name: "",
|
|
||||||
});
|
|
||||||
const savingCaldav = ref(false);
|
|
||||||
const caldavSaved = ref(false);
|
|
||||||
const testingCaldav = ref(false);
|
|
||||||
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);
|
|
||||||
|
|
||||||
// SMTP settings (admin only)
|
// SMTP settings (admin only)
|
||||||
const smtp = ref({
|
const smtp = ref({
|
||||||
smtp_host: "",
|
smtp_host: "",
|
||||||
@@ -483,14 +471,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
|
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
|
||||||
|
|
||||||
// Load CalDAV settings
|
|
||||||
try {
|
|
||||||
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
|
|
||||||
caldav.value = { ...caldav.value, ...caldavConfig };
|
|
||||||
} catch {
|
|
||||||
// CalDAV not configured yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check SearXNG status
|
// Check SearXNG status
|
||||||
try {
|
try {
|
||||||
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
||||||
@@ -672,38 +652,6 @@ async function saveNotifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCaldav() {
|
|
||||||
savingCaldav.value = true;
|
|
||||||
caldavSaved.value = false;
|
|
||||||
try {
|
|
||||||
await apiPut("/api/settings/caldav", caldav.value);
|
|
||||||
caldavSaved.value = true;
|
|
||||||
setTimeout(() => (caldavSaved.value = false), 2000);
|
|
||||||
} catch {
|
|
||||||
toastStore.show("Failed to save CalDAV settings", "error");
|
|
||||||
} finally {
|
|
||||||
savingCaldav.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testCaldav() {
|
|
||||||
testingCaldav.value = true;
|
|
||||||
caldavTestResult.value = null;
|
|
||||||
try {
|
|
||||||
const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
|
|
||||||
caldavTestResult.value = result;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
|
|
||||||
} else {
|
|
||||||
caldavTestResult.value = { success: false, error: "Connection test failed" };
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
testingCaldav.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSmtp() {
|
async function saveSmtp() {
|
||||||
savingSmtp.value = true;
|
savingSmtp.value = true;
|
||||||
smtpSaved.value = false;
|
smtpSaved.value = false;
|
||||||
@@ -1507,50 +1455,6 @@ function formatUserDate(iso: string): string {
|
|||||||
<!-- ── Integrations ── -->
|
<!-- ── Integrations ── -->
|
||||||
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
|
||||||
<h2>Calendar (CalDAV)</h2>
|
|
||||||
<p class="section-desc">
|
|
||||||
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
|
|
||||||
</p>
|
|
||||||
<div class="caldav-grid">
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-url">CalDAV URL</label>
|
|
||||||
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-username">Username</label>
|
|
||||||
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-password">Password</label>
|
|
||||||
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-calendar-name">Calendar Name</label>
|
|
||||||
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
|
|
||||||
<p class="field-hint">Optional. Exact calendar name to use.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions" style="margin-bottom: 1rem;">
|
|
||||||
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
|
|
||||||
{{ savingCaldav ? "Saving..." : "Save" }}
|
|
||||||
</button>
|
|
||||||
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
|
|
||||||
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
|
|
||||||
{{ testingCaldav ? "Testing..." : "Test Connection" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
|
|
||||||
<template v-if="caldavTestResult.success">
|
|
||||||
{{ caldavTestResult.message }}
|
|
||||||
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
|
|
||||||
Calendars: {{ caldavTestResult.calendars.join(", ") }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ caldavTestResult.error }}</template>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Web Search (SearXNG)</h2>
|
<h2>Web Search (SearXNG)</h2>
|
||||||
<template v-if="searxngConfigured">
|
<template v-if="searxngConfigured">
|
||||||
@@ -2712,41 +2616,6 @@ function formatUserDate(iso: string): string {
|
|||||||
accent-color: var(--color-primary);
|
accent-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CalDAV grid */
|
|
||||||
.caldav-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
||||||
gap: 0 1rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.caldav-grid { grid-template-columns: 1fr 1fr; }
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.caldav-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result {
|
|
||||||
padding: 0.65rem 0.9rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.test-result.success {
|
|
||||||
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
|
|
||||||
border: 1px solid var(--color-success);
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
.test-result.error {
|
|
||||||
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
|
|
||||||
border: 1px solid var(--color-danger);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.test-calendars {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search test */
|
/* Search test */
|
||||||
.url-chip {
|
.url-chip {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -2931,9 +2800,6 @@ function formatUserDate(iso: string): string {
|
|||||||
.smtp-grid {
|
.smtp-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.caldav-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Users panel */
|
/* Users panel */
|
||||||
|
|||||||
Reference in New Issue
Block a user