Add drag-drop grouping

This commit is contained in:
Timothy Farrell 2026-05-13 01:34:39 +00:00
parent 0fdcaf4ecc
commit 66ed3935a4
3 changed files with 298 additions and 63 deletions

213
dist/index.html vendored
View File

@ -5760,8 +5760,9 @@ var root_1$7 = /* @__PURE__ */ from_html(`<div class="group-row svelte-181dlmc">
var root_3$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
var root_4$5 = /* @__PURE__ */ from_html(`<button></button>`);
var root_2$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group"><label for="group-name">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal"/></div> <div class="form-group"><span class="field-label">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_5$4 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger">Yes, delete</button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full">+ New Group</button></div> <!> <!></div>`);
var root_5$4 = /* @__PURE__ */ from_html(`<div class="drop-indicator svelte-181dlmc">Drop here to move</div>`);
var root_6$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger">Yes, delete</button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><div class="group-wrapper svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button></div> <!></nav> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full">+ New Group</button></div> <!> <!> <!></div>`);
function Sidebar($$anchor, $$props) {
push($$props, true);
let groups = /* @__PURE__ */ state(proxy([]));
@ -5773,6 +5774,7 @@ function Sidebar($$anchor, $$props) {
let groupError = /* @__PURE__ */ state("");
let showDeleteGroupConfirm = /* @__PURE__ */ state(null);
let deletingGroup = /* @__PURE__ */ user_derived(() => get(groups).find((g) => g.id === get(showDeleteGroupConfirm)));
let dropTargetGroupId = /* @__PURE__ */ state(null);
const GROUP_COLORS = [
"#6c63ff",
"#e5484d",
@ -5842,65 +5844,96 @@ function Sidebar($$anchor, $$props) {
set(groupError, "Failed to delete group: " + e.message);
}
}
function onDragOverGroup(e, groupId) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
set(dropTargetGroupId, groupId, true);
}
function onDragLeaveGroup() {
set(dropTargetGroupId, null);
}
async function onDropGroup(groupId, e) {
set(dropTargetGroupId, null);
const entryId = e.dataTransfer.getData("text/plain");
if (!entryId) return;
try {
const entry = await getEntryById(entryId);
if (!entry) return;
const newGroupId = groupId === "all" ? "" : groupId;
await updateEntry({
...entry,
groupId: newGroupId,
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
});
await loadData();
} catch (err) {
console.error("Failed to move entry:", err);
}
}
var div = root$6();
var div_1 = sibling(child(div), 2);
var input = child(div_1);
remove_input_defaults(input);
reset(div_1);
var nav = sibling(div_1, 2);
var button = child(nav);
each(sibling(button, 2), 17, () => get(groups), index, ($$anchor, group) => {
var div_2 = root_1$7();
var button_1 = child(div_2);
var div_2 = child(nav);
var button = child(div_2);
reset(div_2);
each(sibling(div_2, 2), 17, () => get(groups), index, ($$anchor, group) => {
var div_3 = root_1$7();
var button_1 = child(div_3);
var span = child(button_1);
var span_1 = sibling(span, 2);
var text = child(span_1, true);
reset(span_1);
reset(button_1);
var div_3 = sibling(button_1, 2);
var button_2 = child(div_3);
var div_4 = sibling(button_1, 2);
var button_2 = child(div_4);
var button_3 = sibling(button_2, 2);
reset(div_4);
reset(div_3);
reset(div_2);
template_effect(() => {
set_class(button_1, 1, `group-item ${search.activeGroupId === get(group).id ? "active" : ""}`, "svelte-181dlmc");
set_class(button_1, 1, `group-item ${search.activeGroupId === get(group).id ? "active" : ""} ${get(dropTargetGroupId) === get(group).id ? "drop-target" : ""}`, "svelte-181dlmc");
set_style(span, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`);
set_text(text, get(group).name);
});
event("DragOver", div_3, (e) => onDragOverGroup(e, get(group).id));
event("DragLeave", div_3, onDragLeaveGroup);
event("Drop", div_3, (e) => onDropGroup(get(group).id, e));
delegated("click", button_1, () => search.activeGroupId = get(group).id);
delegated("click", button_2, () => openGroupForm(get(group)));
delegated("click", button_3, () => set(showDeleteGroupConfirm, get(group).id, true));
append($$anchor, div_2);
append($$anchor, div_3);
});
reset(nav);
var div_4 = sibling(nav, 2);
var button_4 = child(div_4);
reset(div_4);
var node_1 = sibling(div_4, 2);
var div_5 = sibling(nav, 2);
var button_4 = child(div_5);
reset(div_5);
var node_1 = sibling(div_5, 2);
var consequent_1 = ($$anchor) => {
var div_5 = root_2$5();
var div_6 = child(div_5);
var h3 = child(div_6);
var div_6 = root_2$5();
var div_7 = child(div_6);
var h3 = child(div_7);
var text_1 = child(h3, true);
reset(h3);
var node_2 = sibling(h3, 2);
var consequent = ($$anchor) => {
var div_7 = root_3$5();
var text_2 = child(div_7, true);
reset(div_7);
var div_8 = root_3$5();
var text_2 = child(div_8, true);
reset(div_8);
template_effect(() => set_text(text_2, get(groupError)));
append($$anchor, div_7);
append($$anchor, div_8);
};
if_block(node_2, ($$render) => {
if (get(groupError)) $$render(consequent);
});
var div_8 = sibling(node_2, 2);
var input_1 = sibling(child(div_8), 2);
var div_9 = sibling(node_2, 2);
var input_1 = sibling(child(div_9), 2);
remove_input_defaults(input_1);
reset(div_8);
var div_9 = sibling(div_8, 2);
var div_10 = sibling(child(div_9), 2);
each(div_10, 21, () => GROUP_COLORS, index, ($$anchor, color) => {
reset(div_9);
var div_10 = sibling(div_9, 2);
var div_11 = sibling(child(div_10), 2);
each(div_11, 21, () => GROUP_COLORS, index, ($$anchor, color) => {
var button_5 = root_4$5();
template_effect(() => {
set_class(button_5, 1, `color-swatch ${get(groupColor) === get(color) ? "selected" : ""}`, "svelte-181dlmc");
@ -5910,62 +5943,72 @@ function Sidebar($$anchor, $$props) {
delegated("click", button_5, () => set(groupColor, get(color), true));
append($$anchor, button_5);
});
reset(div_11);
reset(div_10);
reset(div_9);
var div_11 = sibling(div_9, 2);
var button_6 = child(div_11);
var div_12 = sibling(div_10, 2);
var button_6 = child(div_12);
var text_3 = child(button_6, true);
reset(button_6);
var button_7 = sibling(button_6, 2);
reset(div_11);
reset(div_12);
reset(div_7);
reset(div_6);
reset(div_5);
template_effect(() => {
set_text(text_1, get(editingGroupId) ? "Edit Group" : "New Group");
set_text(text_3, get(editingGroupId) ? "Update" : "Create");
});
delegated("click", div_5, () => set(showGroupForm, false));
delegated("click", div_6, (e) => e.stopPropagation());
delegated("click", div_6, () => set(showGroupForm, false));
delegated("click", div_7, (e) => e.stopPropagation());
bind_value(input_1, () => get(groupName), ($$value) => set(groupName, $$value));
delegated("click", button_6, saveGroup);
delegated("click", button_7, () => set(showGroupForm, false));
append($$anchor, div_5);
append($$anchor, div_6);
};
if_block(node_1, ($$render) => {
if (get(showGroupForm)) $$render(consequent_1);
});
var node_3 = sibling(node_1, 2);
var consequent_2 = ($$anchor) => {
var div_12 = root_5$4();
var div_13 = child(div_12);
var p = sibling(child(div_13), 2);
append($$anchor, root_5$4());
};
if_block(node_3, ($$render) => {
if (get(dropTargetGroupId)) $$render(consequent_2);
});
var node_4 = sibling(node_3, 2);
var consequent_3 = ($$anchor) => {
var div_14 = root_6$5();
var div_15 = child(div_14);
var p = sibling(child(div_15), 2);
var strong = sibling(child(p));
var text_4 = child(strong, true);
reset(strong);
next();
reset(p);
var div_14 = sibling(p, 2);
var button_8 = child(div_14);
var div_16 = sibling(p, 2);
var button_8 = child(div_16);
var button_9 = sibling(button_8, 2);
reset(div_16);
reset(div_15);
reset(div_14);
reset(div_13);
reset(div_12);
template_effect(() => set_text(text_4, get(deletingGroup).name));
delegated("click", div_12, () => set(showDeleteGroupConfirm, null));
delegated("click", div_13, (e) => e.stopPropagation());
delegated("click", div_14, () => set(showDeleteGroupConfirm, null));
delegated("click", div_15, (e) => e.stopPropagation());
delegated("click", button_8, () => confirmDeleteGroup(get(deletingGroup).id));
delegated("click", button_9, () => set(showDeleteGroupConfirm, null));
append($$anchor, div_12);
append($$anchor, div_14);
};
if_block(node_3, ($$render) => {
if (get(deletingGroup)) $$render(consequent_2);
if_block(node_4, ($$render) => {
if (get(deletingGroup)) $$render(consequent_3);
});
reset(div);
template_effect(() => {
set_value(input, search.query);
set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc");
set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""} ${get(dropTargetGroupId) === "all" ? "drop-target" : ""}`, "svelte-181dlmc");
});
event("Input", input, (e) => search.query = e.target.value);
event("DragOver", div_2, (e) => onDragOverGroup(e, "all"));
event("DragLeave", div_2, onDragLeaveGroup);
event("Drop", div_2, (e) => onDropGroup("all", e));
delegated("click", button, () => search.activeGroupId = "all");
delegated("click", button_4, () => openGroupForm(null));
append($$anchor, div);
@ -5979,7 +6022,7 @@ var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
var root_6$4 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
var root_7$2 = /* @__PURE__ */ from_html(`<tr class="entry-row svelte-13s7gu4"><td class="svelte-13s7gu4"><span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td></tr>`);
var root_7$2 = /* @__PURE__ */ from_html(`<tr draggable="true"><td class="svelte-13s7gu4"><span class="drag-handle svelte-13s7gu4" title="Drag to move to another group"></span> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td></tr>`);
var root_5$3 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th></tr></thead><tbody></tbody></table>`, 1);
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
function EntryList($$anchor, $$props) {
@ -5988,6 +6031,13 @@ function EntryList($$anchor, $$props) {
let loading = /* @__PURE__ */ state(true);
let error = /* @__PURE__ */ state("");
let resultCount = /* @__PURE__ */ state(0);
let draggedEntryId = /* @__PURE__ */ state(null);
function handleDragStart(entryId) {
set(draggedEntryId, entryId, true);
}
function handleDragEnd() {
set(draggedEntryId, null);
}
async function loadEntries() {
set(loading, true);
set(error, "");
@ -6076,7 +6126,7 @@ function EntryList($$anchor, $$props) {
each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => {
var tr = root_7$2();
var td = child(tr);
var span_1 = child(td);
var span_1 = sibling(child(td), 2);
var text_6 = child(span_1, true);
reset(span_1);
reset(td);
@ -6092,11 +6142,18 @@ function EntryList($$anchor, $$props) {
reset(td_2);
reset(tr);
template_effect(() => {
set_class(tr, 1, `entry-row ${get(draggedEntryId) === get(entry).id ? "dragging" : ""}`, "svelte-13s7gu4");
set_text(text_6, get(entry).title);
set_text(text_7, get(entry).username);
set_text(text_8, get(entry).url || "—");
});
delegated("click", tr, () => $$props.onSelect(get(entry).id));
event("DragStart", tr, (e) => {
e.dataTransfer.setData("text/plain", get(entry).id);
e.dataTransfer.effectAllowed = "move";
handleDragStart(get(entry).id);
});
event("DragEnd", tr, handleDragEnd);
append($$anchor, tr);
});
reset(tbody);
@ -7261,6 +7318,17 @@ label {
color: var(--color-primary);
}
.group-item.drop-target.svelte-181dlmc {
background: rgba(108, 99, 255, 0.25);
color: var(--color-primary);
border: 1px dashed var(--color-primary);
}
.group-wrapper.svelte-181dlmc {
border-radius: var(--radius-md);
transition: background-color 150ms;
}
.group-icon.svelte-181dlmc {
font-size: 1rem;
}
@ -7310,6 +7378,27 @@ label {
border-top: 1px solid var(--color-border);
}
.drop-indicator.svelte-181dlmc {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-md);
font-size: 0.8rem;
font-weight: 500;
z-index: 100;
pointer-events: none;
animation: svelte-181dlmc-fadeIn 150ms ease;
}
@keyframes svelte-181dlmc-fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Modal */
.modal-overlay.svelte-181dlmc {
position: fixed;
@ -7435,7 +7524,7 @@ label {
}
.entry-row.svelte-13s7gu4 {
cursor: pointer;
cursor: grab;
transition: background-color 150ms;
}
@ -7443,12 +7532,32 @@ label {
background: var(--color-surface-hover);
}
.entry-row.dragging.svelte-13s7gu4 {
opacity: 0.4;
}
.entry-row.svelte-13s7gu4:active {
cursor: grabbing;
}
.entry-row.svelte-13s7gu4 td:where(.svelte-13s7gu4) {
padding: 10px 12px;
font-size: 0.875rem;
border-bottom: 1px solid var(--color-border);
}
.drag-handle.svelte-13s7gu4 {
color: var(--color-text-muted);
margin-right: 6px;
user-select: none;
opacity: 0.5;
transition: opacity 150ms;
}
.entry-row.svelte-13s7gu4:hover .drag-handle:where(.svelte-13s7gu4) {
opacity: 1;
}
.entry-title.svelte-13s7gu4 {
font-weight: 500;
}

View File

@ -9,6 +9,16 @@
let { onSelect, onAdd } = $props()
let draggedEntryId = $state(null)
function handleDragStart(entryId) {
draggedEntryId = entryId
}
function handleDragEnd() {
draggedEntryId = null
}
async function loadEntries() {
loading = true
error = ''
@ -82,8 +92,19 @@
</thead>
<tbody>
{#each entries as entry (entry.id)}
<tr onclick={() => onSelect(entry.id)} class="entry-row">
<tr
onclick={() => onSelect(entry.id)}
class="entry-row {draggedEntryId === entry.id ? 'dragging' : ''}"
draggable="true"
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', entry.id)
e.dataTransfer.effectAllowed = 'move'
handleDragStart(entry.id)
}}
onDragEnd={handleDragEnd}
>
<td>
<span class="drag-handle" title="Drag to move to another group"></span>
<span class="entry-title">{entry.title}</span>
</td>
<td>
@ -153,7 +174,7 @@
}
.entry-row {
cursor: pointer;
cursor: grab;
transition: background-color 150ms;
}
@ -161,12 +182,32 @@
background: var(--color-surface-hover);
}
.entry-row.dragging {
opacity: 0.4;
}
.entry-row:active {
cursor: grabbing;
}
.entry-row td {
padding: 10px 12px;
font-size: 0.875rem;
border-bottom: 1px solid var(--color-border);
}
.drag-handle {
color: var(--color-text-muted);
margin-right: 6px;
user-select: none;
opacity: 0.5;
transition: opacity 150ms;
}
.entry-row:hover .drag-handle {
opacity: 1;
}
.entry-title {
font-weight: 500;
}

View File

@ -1,5 +1,5 @@
<script>
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup } from '../lib/storage/db.js'
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup, getEntryById, updateEntry as updateEntryDb } from '../lib/storage/db.js'
import { createGroup, validateGroup } from '../lib/models/schema.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
@ -14,6 +14,7 @@
let groupError = $state('')
let showDeleteGroupConfirm = $state(null) // groupId being confirmed for deletion
let deletingGroup = $derived(groups.find(g => g.id === showDeleteGroupConfirm))
let dropTargetGroupId = $state(null) // groupId currently being hovered during drag
const GROUP_COLORS = [
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
@ -83,6 +84,41 @@
groupError = 'Failed to delete group: ' + e.message
}
}
// Drag-and-drop: move entry to a group
function onDragOverGroup(e, groupId) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
dropTargetGroupId = groupId
}
function onDragLeaveGroup() {
dropTargetGroupId = null
}
async function onDropGroup(groupId, e) {
dropTargetGroupId = null
const entryId = e.dataTransfer.getData('text/plain')
if (!entryId) return
try {
const entry = await getEntryById(entryId)
if (!entry) return
// If dropping on "All Entries" (no group), clear the groupId
const newGroupId = groupId === 'all' ? '' : groupId
const updated = {
...entry,
groupId: newGroupId,
updatedAt: new Date().toISOString(),
}
await updateEntryDb(updated)
await loadData()
} catch (err) {
console.error('Failed to move entry:', err)
}
}
</script>
<div class="sidebar-content">
@ -100,18 +136,30 @@
</div>
<nav class="groups-nav">
<div
class="group-wrapper"
onDragOver={(e) => onDragOverGroup(e, 'all')}
onDragLeave={onDragLeaveGroup}
onDrop={(e) => onDropGroup('all', e)}
>
<button
class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''}"
class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''} {dropTargetGroupId === 'all' ? 'drop-target' : ''}"
onclick={() => searchStore.activeGroupId = 'all'}
>
<span class="group-icon">📋</span>
<span class="group-name">All Entries</span>
</button>
</div>
{#each groups as group}
<div class="group-row">
<div
class="group-row"
onDragOver={(e) => onDragOverGroup(e, group.id)}
onDragLeave={onDragLeaveGroup}
onDrop={(e) => onDropGroup(group.id, e)}
>
<button
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''}"
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''} {dropTargetGroupId === group.id ? 'drop-target' : ''}"
onclick={() => searchStore.activeGroupId = group.id}
>
<span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span>
@ -169,6 +217,11 @@
</div>
{/if}
<!-- Entry moved toast -->
{#if dropTargetGroupId}
<div class="drop-indicator">Drop here to move</div>
{/if}
<!-- Delete group confirmation -->
{#if deletingGroup}
<div class="modal-overlay" role="presentation" onclick={() => showDeleteGroupConfirm = null}>
@ -249,6 +302,17 @@
color: var(--color-primary);
}
.group-item.drop-target {
background: rgba(108, 99, 255, 0.25);
color: var(--color-primary);
border: 1px dashed var(--color-primary);
}
.group-wrapper {
border-radius: var(--radius-md);
transition: background-color 150ms;
}
.group-icon {
font-size: 1rem;
}
@ -298,6 +362,27 @@
border-top: 1px solid var(--color-border);
}
.drop-indicator {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-md);
font-size: 0.8rem;
font-weight: 500;
z-index: 100;
pointer-events: none;
animation: fadeIn 150ms ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Modal */
.modal-overlay {
position: fixed;