36cd08c236
Adds a "Repeat" select (None / Daily / Weekly / Monthly / Yearly) that reads/writes the existing Event.recurrence RRULE. CalDAV-imported rules with extra parts (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR) surface as a disabled "Custom" option with the raw rule shown read-only — visible but preserved unless the user explicitly picks a preset to replace it. EventUpdatePayload.recurrence is now string | null so we can clear via PATCH; backend service already treats null as "clear" (recurrence is in the nullable set in update_event). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
678 lines
22 KiB
Vue
678 lines
22 KiB
Vue
<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>
|