feat(collections): import a whole trip's places into a list (#1903)

* feat(collections): preview which of a trip's places a list already holds

The bulk import endpoint has been there since the select-mode copy, but nothing
calls it and it only reports duplicates after the fact. A dialog needs the verdict
up front, so /:id/importable/:tripId returns the trip's places with the duplicate
check the import itself applies, plus whether any day holds the place.

* feat(collections): import a whole trip's places into a list

Two steps: pick a trip, then tick its places. Places already on the list are
greyed out and unpickable, and the ones no day holds start selected — those are
the plans a trip left behind, which is the reason to import at all. Sorted so a
mostly-saved trip reads as the two that are left rather than a wall of grey.

* feat(collections): reach the trip import from the filter row and an empty list

* i18n(collections): translate the trip import into all locales

* fix(collections): stop the label dialog's buttons wrapping mid-word

Three actions in a max-w-sm footer wrapped into two lines each. The assign
button also painted white text on the accent, which disappears on a light one.

* docs(collections): document the trip import
This commit is contained in:
Maurice
2026-08-12 11:54:36 +02:00
committed by GitHub
parent b0fd73681b
commit b842d74a5d
37 changed files with 1286 additions and 11 deletions
+150
View File
@@ -300,6 +300,18 @@
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
/* The count sits next to a larger label, so as bare text it rides the baseline and
reads as misaligned. A pill gives it its own box and centres it against the name. */
.trek-dash .col-labelchip .col-filter-count {
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
background: color-mix(in oklch, var(--ink) 8%, transparent);
color: var(--ink-2); font-size: 10.5px; line-height: 1;
}
.trek-dash .col-labelchip.on .col-filter-count {
background: color-mix(in oklch, var(--label, var(--accent)) 22%, transparent);
color: inherit;
}
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
.trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; }
@@ -391,6 +403,13 @@
}
.trek-dash .col-cta:hover { transform: translateY(-1px); box-shadow: var(--sh-lg); }
.trek-dash .col-cta:active { transform: translateY(0); }
/* Second action beside the primary CTA — same shape, quieter, so an empty list can
offer both "add one place" and "import a whole trip" without competing. */
.trek-dash .col-cta.col-cta-ghost {
background: var(--surface); color: var(--ink-2); border: 1px solid var(--line-2);
box-shadow: var(--sh-xs);
}
.trek-dash .col-cta.col-cta-ghost:hover { background: var(--surface-2); color: var(--ink); box-shadow: var(--sh-sm); }
/* ----------------- place detail bottom sheet ----------------- */
.trek-dash .col-detail {
@@ -541,3 +560,134 @@
.trek-dash .col-drawer-head button { width: 40px; height: 40px; display: grid; place-items: center; border-radius: var(--r-sm); color: var(--ink-2); }
.trek-dash .col-drawer-head button:hover { background: var(--surface-2); color: var(--ink); }
@media (max-width: 720px) { .trek-dash .col-drawer { padding-top: calc(16px + env(safe-area-inset-top, 0px)); } }
/* ----------------- import-from-trip dialog -----------------
Lives in the modal portal, i.e. OUTSIDE .trek-dash, so it reads the global
theme tokens directly instead of the dashboard's --ink/--surface aliases. */
.col-imp { display: flex; flex-direction: column; gap: 14px; }
.col-imp-lead { font-size: 13px; line-height: 1.5; color: var(--text-secondary); margin: -2px 0 2px; }
/* step 1 — trips */
.col-imp-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
@media (max-width: 640px) { .col-imp-grid { grid-template-columns: 1fr; } }
.col-imp-trip {
display: flex; align-items: center; gap: 11px; width: 100%; padding: 10px 12px 10px 10px;
border: 1px solid var(--border-primary); border-radius: 14px; background: var(--bg-card);
text-align: left; cursor: pointer;
transition: border-color .14s ease, background .14s ease, transform .14s ease, box-shadow .14s ease;
}
.col-imp-trip:hover {
border-color: var(--accent); background: var(--bg-hover);
transform: translateY(-1px); box-shadow: var(--shadow-md);
}
.col-imp-trip-cover {
width: 42px; height: 42px; flex: none; border-radius: 11px; overflow: hidden;
background: var(--bg-tertiary); color: var(--text-faint);
display: grid; place-items: center;
}
.col-imp-trip-cover img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .3s cubic-bezier(0.23, 1, 0.32, 1); }
.col-imp-trip:hover .col-imp-trip-cover img { transform: scale(1.06); }
.col-imp-trip-body { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 5px; }
.col-imp-trip-name { font-size: 13.5px; font-weight: 650; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-imp-trip-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; }
/* Each fact is its own pill so a narrow tile wraps between them instead of
clipping a word. The date pill yields first; the count never shrinks. */
.col-imp-pill {
display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%;
padding: 2px 7px; border-radius: 999px; background: var(--bg-tertiary);
font-size: 10.5px; font-weight: 600; color: var(--text-muted);
font-variant-numeric: tabular-nums; line-height: 1.45;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.col-imp-pill svg { flex: none; opacity: .75; }
.col-imp-pill.num { flex: none; }
.col-imp-trip-go { color: var(--text-faint); flex: none; transition: transform .14s ease, color .14s ease; }
.col-imp-trip:hover .col-imp-trip-go { transform: translateX(2px); color: var(--text-secondary); }
/* step 2 — toolbar */
.col-imp-bar { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.col-imp-back {
width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 9px;
border: 1px solid var(--border-primary); color: var(--text-secondary); background: var(--bg-card);
transition: background .14s ease, color .14s ease;
}
.col-imp-back:hover { background: var(--bg-hover); color: var(--text-primary); }
.col-imp-search {
position: relative; display: flex; align-items: center; gap: 7px; flex: 1; min-width: 150px;
padding: 0 10px; height: 30px; border-radius: 9px;
border: 1px solid var(--border-primary); background: var(--bg-input); color: var(--text-faint);
transition: border-color .14s ease;
}
.col-imp-search:focus-within { border-color: var(--accent); }
.col-imp-search input {
flex: 1; min-width: 0; background: none; border: 0; outline: none;
font-size: 12.5px; color: var(--text-primary);
}
.col-imp-chip {
display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 11px; flex: none;
border-radius: 999px; border: 1px solid var(--border-primary); background: var(--bg-card);
font-size: 11.5px; font-weight: 600; color: var(--text-secondary); cursor: pointer;
transition: background .14s ease, color .14s ease, border-color .14s ease;
}
.col-imp-chip:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
.col-imp-chip.on { background: var(--accent-subtle); border-color: var(--accent); color: var(--accent-on); }
.col-imp-chip:disabled { opacity: .45; cursor: default; }
/* step 2 — rows */
.col-imp-list { display: flex; flex-direction: column; gap: 5px; max-height: 46vh; overflow-y: auto; padding: 1px; margin: -1px; }
.col-imp-row {
display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px;
border: 1px solid var(--border-primary); border-radius: 12px; background: var(--bg-card);
text-align: left; cursor: pointer;
transition: border-color .14s ease, background .14s ease;
}
.col-imp-row:hover:not(.dup) { background: var(--bg-hover); }
.col-imp-row.on { border-color: var(--accent); background: var(--accent-subtle); }
.col-imp-row.dup { opacity: .5; cursor: default; }
.col-imp-box {
width: 18px; height: 18px; flex: none; border-radius: 6px; display: grid; place-items: center;
border: 1.5px solid var(--border-secondary); color: var(--accent-text); background: var(--bg-input);
transition: background .14s ease, border-color .14s ease;
}
.col-imp-row.on .col-imp-box { background: var(--accent); border-color: var(--accent); }
.col-imp-row.dup .col-imp-box { visibility: hidden; }
.col-imp-ico {
width: 28px; height: 28px; flex: none; border-radius: 9px; display: grid; place-items: center;
background: var(--bg-tertiary); color: var(--text-muted);
}
.col-imp-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
.col-imp-txt .nm { font-size: 13px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-imp-txt .ad { font-size: 11px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-imp-tag {
flex: none; padding: 3px 9px; border-radius: 999px; font-size: 10.5px; font-weight: 650;
background: var(--bg-tertiary); color: var(--text-muted); white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.col-imp-tag.fresh { background: var(--accent-subtle); color: var(--accent-on); }
.col-imp-tag.saved { background: transparent; color: var(--text-faint); }
/* empty + done */
.col-imp-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px;
padding: 34px 16px; border: 1px dashed var(--border-primary); border-radius: 14px;
color: var(--text-faint); font-size: 12.5px; text-align: center;
}
.col-imp-empty-good { color: var(--success); border-color: var(--success); background: var(--success-soft); }
.col-imp-done { display: flex; flex-direction: column; align-items: center; gap: 9px; padding: 26px 16px 20px; text-align: center; }
@keyframes col-imp-pop {
0% { opacity: 0; transform: scale(0.6); }
60% { opacity: 1; transform: scale(1.06); }
100% { opacity: 1; transform: scale(1); }
}
.col-imp-done-ring {
width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center;
background: var(--success-soft); color: var(--success);
animation: col-imp-pop 420ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.col-imp-done-t { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.col-imp-done-s { font-size: 12.5px; color: var(--text-faint); max-width: 320px; line-height: 1.5; }
@media (prefers-reduced-motion: reduce) {
.col-imp-done-ring { animation: none; }
.col-imp-trip:hover { transform: none; }
.col-imp-trip:hover .col-imp-trip-cover img { transform: none; }
}