feat: wire brain service to platform gateway

- Gateway proxies /api/brain/* to brain-api:8200/api/* via pangolin network
- User identity injected via X-Gateway-User-Id header
- Brain app registered in gateway database (sort_order 9)
- Added to GATEWAY_KEY_SERVICES for dashboard integration
- Tested: health, config, list, create all working through gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 16:32:53 -05:00
parent c9e776df59
commit 2072c359aa
34 changed files with 16745 additions and 1379 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
import PdfInlinePreview from './PdfInlinePreview.svelte';
let {
entityType,
@@ -148,6 +149,12 @@
showImmich = false;
onUpload();
}
function isPdfDocument(doc: any) {
const fileName = String(doc.file_name || doc.original_name || '').toLowerCase();
const mimeType = String(doc.mime_type || '').toLowerCase();
return mimeType.includes('pdf') || fileName.endsWith('.pdf');
}
</script>
<div class="upload-section">
@@ -169,10 +176,21 @@
{#if documents && documents.length > 0}
<div class="doc-list">
{#each documents as doc}
<div class="doc-row">
<svg class="doc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<a href={doc.url || `/api/trips/documents/${doc.file_path}`} target="_blank" class="doc-name">{doc.file_name || doc.original_name || 'Document'}</a>
<button class="doc-delete" onclick={() => deleteDoc(doc.id)} disabled={deletingDocId === doc.id}>×</button>
<div class="doc-card">
{#if isPdfDocument(doc)}
<PdfInlinePreview
url={doc.url || `/api/trips/documents/${doc.file_path}`}
name={doc.file_name || doc.original_name || 'PDF document'}
onDelete={() => deleteDoc(doc.id)}
deleting={deletingDocId === doc.id}
/>
{:else}
<div class="doc-row">
<svg class="doc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<a href={doc.url || `/api/trips/documents/${doc.file_path}`} target="_blank" class="doc-name">{doc.file_name || doc.original_name || 'Document'}</a>
<button class="doc-delete" onclick={() => deleteDoc(doc.id)} disabled={deletingDocId === doc.id}>×</button>
</div>
{/if}
</div>
{/each}
</div>
@@ -241,13 +259,36 @@
border-radius: 50%; background: rgba(0,0,0,0.5); color: white; border: none;
font-size: var(--text-sm); cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.doc-list { display: flex; flex-direction: column; gap: 4px; }
.doc-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 6px; background: var(--surface-secondary); }
.doc-icon { width: 14px; height: 14px; color: var(--text-4); flex-shrink: 0; }
.doc-name { flex: 1; font-size: var(--text-sm); color: var(--text-2); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-name:hover { color: var(--accent); }
.doc-delete { background: none; border: none; color: var(--text-4); cursor: pointer; font-size: var(--text-base); padding: 2px 4px; }
.doc-delete:hover { color: var(--error); }
.doc-list { display: flex; flex-direction: column; gap: 12px; }
.doc-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0;
}
.doc-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.doc-icon { width: 16px; height: 16px; color: rgba(111, 88, 64, 0.7); flex-shrink: 0; }
.doc-name {
flex: 1;
min-width: 0;
font-size: 0.92rem;
font-weight: 600;
color: #3a291b;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-name:hover { color: #1f1510; }
.doc-delete {
background: none;
border: none;
color: rgba(112, 86, 62, 0.64);
cursor: pointer;
font-size: 1.1rem;
padding: 2px 4px;
}
.doc-delete:hover { color: #8f3928; }
.upload-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.upload-btn {

View File

@@ -20,6 +20,8 @@
let saving = $state(false);
let confirmDelete = $state(false);
let mediaImages = $state<any[]>([]);
let mediaDocuments = $state<any[]>([]);
// ── Form state ──
let name = $state('');
@@ -79,8 +81,12 @@
transportType = editItem.type || 'plane';
lodgingType = editItem.type || 'hotel';
content = editItem.content || '';
mediaImages = (editItem.images || []).map((i: any) => ({ ...i, url: `/images/${i.file_path}` }));
mediaDocuments = (editItem.documents || []).map((d: any) => ({ ...d, url: `/api/trips/documents/${d.file_path}` }));
} else if (open) {
resetForm();
mediaImages = [];
mediaDocuments = [];
}
confirmDelete = false;
});
@@ -96,6 +102,40 @@
function close() { open = false; resetForm(); confirmDelete = false; }
async function refreshMedia() {
if (!editItem?.id) {
onSaved();
return;
}
try {
const res = await fetch(`/api/trips/trip/${tripId}`, { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const collection =
itemType === 'transportation' ? data.transportations :
itemType === 'lodging' ? data.lodging :
itemType === 'location' ? data.locations :
data.notes;
const nextItem = (collection || []).find((item: any) => item.id === editItem.id);
if (!nextItem) return;
mediaImages = (nextItem.images || []).map((image: any) => ({
...image,
url: `/images/${image.file_path}`
}));
mediaDocuments = (nextItem.documents || []).map((doc: any) => ({
...doc,
url: `/api/trips/documents/${doc.file_path}`
}));
onSaved();
} catch (e) {
console.error('Media refresh failed:', e);
}
}
function handlePlaceSelect(details: any) {
if (details.name) name = details.name;
address = details.address || '';
@@ -168,139 +208,168 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">{isEdit ? 'Edit' : 'Add'} {titles[itemType]}</div>
<div class="modal-head-copy">
<div class="modal-kicker">{isEdit ? 'Edit' : 'Add'}</div>
<div class="modal-title">{titles[itemType]}</div>
</div>
<button class="modal-close" onclick={close}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="modal-intro">
<div class="intro-line">{titles[itemType]} details</div>
<div class="intro-copy">
{#if itemType === 'transportation'}
Keep timing, route, and booking details in one clean travel leg.
{:else if itemType === 'lodging'}
Track where you are staying, when you arrive, and what confirms the stay.
{:else if itemType === 'location'}
Capture the stop, why it matters, and when it belongs in the trip.
{:else}
Save a reminder, handoff note, or journal line for this trip.
{/if}
</div>
</div>
<!-- Name (with Places search for location & lodging) -->
{#if itemType === 'location' || itemType === 'lodging'}
<div class="field">
<label class="field-label">Name</label>
<PlacesAutocomplete bind:value={name} placeholder={itemType === 'lodging' ? 'Search hotel or address...' : 'Search place or type name...'} onSelect={handlePlaceSelect} />
</div>
{:else}
<div class="field">
<label class="field-label">Name</label>
<input class="field-input" type="text" bind:value={name} placeholder={itemType === 'note' ? 'Note title' : 'Name'} />
</div>
{/if}
<!-- Type selectors -->
{#if itemType === 'transportation'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={transportType}>
{#each transportTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
{#if transportType === 'plane'}
<section class="form-section">
<div class="section-title">Primary info</div>
{#if itemType === 'location' || itemType === 'lodging'}
<div class="field">
<label class="field-label">Flight Number</label>
<input class="field-input" type="text" bind:value={flightNumber} placeholder="UA 2341" />
<label class="field-label">Name</label>
<PlacesAutocomplete bind:value={name} placeholder={itemType === 'lodging' ? 'Search hotel or address...' : 'Search place or type name...'} onSelect={handlePlaceSelect} />
</div>
{:else}
<div class="field">
<label class="field-label">Name</label>
<input class="field-input" type="text" bind:value={name} placeholder={itemType === 'note' ? 'Note title' : 'Name'} />
</div>
{/if}
<div class="field-row">
<div class="field"><label class="field-label">From</label><input class="field-input" type="text" bind:value={fromLocation} /></div>
<div class="field"><label class="field-label">To</label><input class="field-input" type="text" bind:value={toLocation} /></div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Departure</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">Arrival</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{:else if itemType === 'lodging'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={lodgingType}>
{#each lodgingTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Check-in</label><input class="field-input" type="date" bind:value={date} /></div>
<div class="field"><label class="field-label">Check-out</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
<div class="field">
<label class="field-label">Reservation #</label>
<input class="field-input" type="text" bind:value={reservationNumber} />
</div>
{:else if itemType === 'location'}
<div class="field">
<label class="field-label">Category</label>
<select class="field-input" bind:value={category}>
<option value="">Select...</option>
{#each locationCategories as c}<option value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>{/each}
</select>
</div>
<div class="field">
<label class="field-label">Visit Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Time</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">End Time</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{#if category === 'hike'}
<div class="field-row">
<div class="field"><label class="field-label">Distance</label><input class="field-input" type="text" bind:value={hikeDistance} placeholder="5.2 miles" /></div>
<div class="field">
<label class="field-label">Difficulty</label>
<select class="field-input" bind:value={hikeDifficulty}>
<option value="">Select...</option>
<option value="easy">Easy</option>
<option value="moderate">Moderate</option>
<option value="hard">Hard</option>
<option value="strenuous">Strenuous</option>
</select>
</div>
<div class="field"><label class="field-label">Duration</label><input class="field-input" type="text" bind:value={hikeTime} placeholder="3 hours" /></div>
</div>
{/if}
{:else if itemType === 'note'}
<div class="field">
<label class="field-label">Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
{/if}
<!-- Description / Content -->
{#if itemType === 'note'}
<div class="field">
<label class="field-label">Content</label>
<textarea class="field-input field-textarea" bind:value={content} rows="6" placeholder="Write your note..."></textarea>
</div>
{:else}
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3" placeholder="Details..."></textarea>
</div>
<!-- Type selectors -->
{#if itemType === 'transportation'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={transportType}>
{#each transportTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
{#if transportType === 'plane'}
<div class="field">
<label class="field-label">Flight Number</label>
<input class="field-input" type="text" bind:value={flightNumber} placeholder="UA 2341" />
</div>
{/if}
<div class="field-row">
<div class="field"><label class="field-label">From</label><input class="field-input" type="text" bind:value={fromLocation} /></div>
<div class="field"><label class="field-label">To</label><input class="field-input" type="text" bind:value={toLocation} /></div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Departure</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">Arrival</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{:else if itemType === 'lodging'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={lodgingType}>
{#each lodgingTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Check-in</label><input class="field-input" type="date" bind:value={date} /></div>
<div class="field"><label class="field-label">Check-out</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
<div class="field">
<label class="field-label">Reservation #</label>
<input class="field-input" type="text" bind:value={reservationNumber} />
</div>
{:else if itemType === 'location'}
<div class="field">
<label class="field-label">Category</label>
<select class="field-input" bind:value={category}>
<option value="">Select...</option>
{#each locationCategories as c}<option value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>{/each}
</select>
</div>
<div class="field">
<label class="field-label">Visit Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Time</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">End Time</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{#if category === 'hike'}
<div class="field-row">
<div class="field"><label class="field-label">Distance</label><input class="field-input" type="text" bind:value={hikeDistance} placeholder="5.2 miles" /></div>
<div class="field">
<label class="field-label">Difficulty</label>
<select class="field-input" bind:value={hikeDifficulty}>
<option value="">Select...</option>
<option value="easy">Easy</option>
<option value="moderate">Moderate</option>
<option value="hard">Hard</option>
<option value="strenuous">Strenuous</option>
</select>
</div>
<div class="field"><label class="field-label">Duration</label><input class="field-input" type="text" bind:value={hikeTime} placeholder="3 hours" /></div>
</div>
{/if}
{:else if itemType === 'note'}
<div class="field">
<label class="field-label">Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
{/if}
</section>
<section class="form-section">
<div class="section-title">{itemType === 'note' ? 'Content' : 'Details'}</div>
{#if itemType === 'note'}
<div class="field">
<label class="field-label">Content</label>
<textarea class="field-input field-textarea" bind:value={content} rows="6" placeholder="Write your note..."></textarea>
</div>
{:else}
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3" placeholder="Details..."></textarea>
</div>
{/if}
</section>
<!-- Link -->
{#if itemType !== 'note'}
<section class="form-section">
<div class="section-title">Booking + cost</div>
<div class="field">
<label class="field-label">Link</label>
<input class="field-input" type="url" bind:value={link} placeholder="https://..." />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Points</label><input class="field-input" type="number" bind:value={costPoints} /></div>
<div class="field"><label class="field-label">Cash ($)</label><input class="field-input" type="number" step="0.01" bind:value={costCash} /></div>
</div>
</section>
{/if}
<!-- Images & Documents (edit mode only) -->
{#if isEdit && itemType !== 'note'}
<div class="field">
<label class="field-label">Photos & Documents</label>
<ImageUpload
entityType={itemType}
entityId={editItem.id}
images={(editItem.images || []).map((i: any) => ({ ...i, url: `/images/${i.file_path}` }))}
documents={(editItem.documents || []).map((d: any) => ({ ...d, url: `/api/trips/documents/${d.file_path}` }))}
onUpload={onSaved}
/>
</div>
{/if}
<!-- Link -->
{#if itemType !== 'note'}
<div class="field">
<label class="field-label">Link</label>
<input class="field-input" type="url" bind:value={link} placeholder="https://..." />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Points</label><input class="field-input" type="number" bind:value={costPoints} /></div>
<div class="field"><label class="field-label">Cash ($)</label><input class="field-input" type="number" step="0.01" bind:value={costCash} /></div>
</div>
<section class="form-section">
<div class="section-title">Attachments</div>
<div class="field">
<label class="field-label">Photos & Documents</label>
<ImageUpload
entityType={itemType}
entityId={editItem.id}
images={mediaImages}
documents={mediaDocuments}
onUpload={refreshMedia}
/>
</div>
</section>
{/if}
</div>
@@ -312,15 +381,14 @@
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div>
{:else}
{/if}
{/if}
<div class="footer-right">
{#if isEdit && !confirmDelete}
<button class="btn-delete" onclick={() => confirmDelete = true}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
{/if}
{:else}
<div></div>
{/if}
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : isEdit ? 'Save' : 'Add'}
@@ -332,37 +400,372 @@
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; justify-content: flex-end; animation: modalFade 150ms ease; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 100%; height: 100%; background: var(--surface); display: flex; flex-direction: column; box-shadow: -8px 0 32px rgba(0,0,0,0.1); animation: modalSlide 200ms ease; }
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(0); } }
.modal-overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
justify-content: flex-end;
background:
linear-gradient(180deg, rgba(53, 40, 31, 0.1), rgba(24, 17, 12, 0.42)),
rgba(17, 11, 7, 0.28);
backdrop-filter: blur(10px);
animation: modalFade 180ms ease;
}
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
@keyframes modalFade {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-5); display: flex; flex-direction: column; gap: 14px; }
.modal-sheet {
width: min(560px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background:
linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(247, 239, 229, 0.98));
border-left: 1px solid rgba(122, 96, 70, 0.18);
box-shadow: -24px 0 64px rgba(39, 26, 16, 0.18);
animation: modalSlide 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 60px; }
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
@keyframes modalSlide {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; border-radius: var(--radius-md); background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
.btn-save { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.btn-save:disabled { opacity: 0.4; cursor: default; }
.btn-delete { width: 34px; height: 34px; border-radius: var(--radius-md); background: none; border: 1px solid var(--error); color: var(--error); display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-delete:hover { background: var(--error-bg); }
.btn-delete svg { width: 16px; height: 16px; }
.delete-confirm { display: flex; align-items: center; gap: var(--sp-2); }
.delete-msg { font-size: var(--text-sm); color: var(--error); font-weight: 500; }
.btn-danger { padding: 6px var(--sp-3); border-radius: var(--radius-sm); background: var(--error); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 28px 28px 20px;
border-bottom: 1px solid rgba(122, 96, 70, 0.14);
background:
linear-gradient(180deg, rgba(255, 248, 237, 0.92), rgba(255, 248, 237, 0.74));
}
@media (max-width: 768px) { .modal-sheet { width: 100%; } }
.modal-head-copy {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.modal-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(124, 94, 61, 0.7);
}
.modal-title {
font-size: clamp(1.35rem, 1.15rem + 0.55vw, 1.82rem);
line-height: 1.04;
font-weight: 600;
letter-spacing: -0.03em;
color: #27190f;
}
.modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid rgba(122, 96, 70, 0.16);
border-radius: 999px;
background: rgba(255, 252, 247, 0.82);
color: rgba(90, 69, 50, 0.72);
cursor: pointer;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.modal-close:hover {
background: rgba(255, 252, 247, 1);
color: #3d2b1d;
transform: translateY(-1px);
}
.modal-close svg {
width: 16px;
height: 16px;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 24px 28px 32px;
display: flex;
flex-direction: column;
gap: 18px;
}
.modal-intro {
padding: 18px 18px 16px;
border-radius: 24px;
background: rgba(252, 245, 236, 0.92);
border: 1px solid rgba(140, 112, 82, 0.13);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.intro-line {
font-size: 0.92rem;
font-weight: 600;
color: #3f2c1d;
letter-spacing: -0.02em;
}
.intro-copy {
margin-top: 8px;
max-width: 42ch;
font-size: 0.92rem;
line-height: 1.55;
color: rgba(92, 72, 53, 0.84);
}
.form-section {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 24px;
background: rgba(255, 252, 248, 0.78);
border: 1px solid rgba(140, 112, 82, 0.12);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 12px 28px rgba(75, 49, 27, 0.04);
}
.section-title {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(124, 94, 61, 0.72);
}
.field {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.field-label {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(110, 82, 57, 0.74);
}
.field-input {
width: 100%;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(128, 103, 79, 0.14);
background: rgba(255, 255, 255, 0.86);
color: #281a11;
font-size: 0.96rem;
line-height: 1.35;
font-family: var(--font);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.46);
transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
}
.field-input::placeholder {
color: rgba(123, 102, 85, 0.66);
}
.field-input:focus {
outline: none;
border-color: rgba(184, 134, 82, 0.58);
background: rgba(255, 255, 255, 0.96);
box-shadow:
0 0 0 4px rgba(184, 134, 82, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.field-textarea {
resize: vertical;
min-height: 104px;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 28px 22px;
border-top: 1px solid rgba(122, 96, 70, 0.14);
background:
linear-gradient(180deg, rgba(255, 250, 242, 0.68), rgba(253, 246, 238, 0.92));
}
.footer-right {
display: flex;
align-items: center;
gap: 10px;
}
.btn-cancel,
.btn-save,
.btn-danger {
font-family: var(--font);
font-size: 0.92rem;
font-weight: 600;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease, border-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
}
.btn-cancel {
padding: 10px 16px;
border-radius: 999px;
background: rgba(255, 251, 247, 0.8);
color: #5c4837;
border: 1px solid rgba(128, 103, 79, 0.18);
}
.btn-cancel:hover {
background: rgba(255, 251, 247, 1);
color: #352418;
}
.btn-save {
padding: 10px 18px;
border-radius: 999px;
background: linear-gradient(135deg, #2f2013, #59412c);
color: #fff8f1;
border: none;
box-shadow: 0 12px 24px rgba(55, 37, 23, 0.18);
}
.btn-save:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(55, 37, 23, 0.22);
}
.btn-save:disabled {
opacity: 0.45;
cursor: default;
box-shadow: none;
}
.btn-delete {
width: 40px;
height: 40px;
padding: 0;
border-radius: 999px;
background: rgba(137, 41, 27, 0.08);
border: 1px solid rgba(137, 41, 27, 0.18);
color: #8f3928;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease;
}
.btn-delete:hover {
transform: translateY(-1px);
background: rgba(137, 41, 27, 0.12);
}
.btn-delete svg {
width: 16px;
height: 16px;
}
.delete-confirm {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.delete-msg {
font-size: 0.88rem;
font-weight: 600;
color: #8f3928;
}
.btn-danger {
padding: 9px 14px;
border-radius: 999px;
background: #8f3928;
color: #fff7f3;
border: none;
}
.btn-danger:hover {
transform: translateY(-1px);
background: #7e2e1f;
}
@media (max-width: 768px) {
.modal-sheet {
width: 100%;
}
.modal-header {
padding: 24px 18px 18px;
}
.modal-body {
padding: 18px 18px 28px;
gap: 14px;
}
.modal-intro,
.form-section {
padding: 16px;
border-radius: 20px;
}
.field-row {
grid-template-columns: minmax(0, 1fr);
}
.modal-footer {
padding: 10px 14px 12px;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.delete-confirm {
justify-content: center;
}
.btn-delete {
width: 42px;
height: 42px;
align-self: flex-start;
}
.footer-right {
width: 100%;
display: grid;
grid-template-columns: auto 1fr 1fr;
gap: 8px;
}
.btn-cancel,
.btn-save {
justify-content: center;
text-align: center;
padding-top: 9px;
padding-bottom: 9px;
}
}
</style>

View File

@@ -0,0 +1,590 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
let {
url,
name = 'Document',
onDelete = () => {},
deleting = false
}: {
url: string;
name?: string;
onDelete?: () => void;
deleting?: boolean;
} = $props();
let inlinePagesHost = $state<HTMLDivElement | null>(null);
let pagesHost = $state<HTMLDivElement | null>(null);
let loading = $state(true);
let expandedLoading = $state(false);
let error = $state('');
let isExpanded = $state(false);
let isInlineOpen = $state(false);
let pageCount = $state(0);
let pdfModulePromise: Promise<any> | null = null;
let pdfDocumentPromise: Promise<any> | null = null;
async function getPdfModule() {
if (!pdfModulePromise) {
pdfModulePromise = import('pdfjs-dist').then((mod) => {
const pdfjs = mod.default ?? mod;
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
}
return pdfjs;
});
}
return pdfModulePromise;
}
async function getPdfDocument() {
if (!pdfDocumentPromise) {
pdfDocumentPromise = getPdfModule().then((pdfjs) =>
pdfjs.getDocument({
url,
withCredentials: true
}).promise
);
}
return pdfDocumentPromise;
}
async function renderPageToCanvas(canvas: HTMLCanvasElement, pageNumber: number, width: number) {
const pdf = await getPdfDocument();
const page = await pdf.getPage(pageNumber);
const baseViewport = page.getViewport({ scale: 1 });
const scale = width / baseViewport.width;
const viewport = page.getViewport({ scale });
const context = canvas.getContext('2d');
const pixelRatio = typeof window !== 'undefined' ? Math.max(window.devicePixelRatio || 1, 1) : 1;
if (!context) return;
canvas.width = Math.floor(viewport.width * pixelRatio);
canvas.height = Math.floor(viewport.height * pixelRatio);
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
await page.render({
canvasContext: context,
viewport
}).promise;
}
async function renderPages(host: HTMLDivElement, width: number, pageClass = 'expanded-page', canvasClass = 'expanded-canvas') {
host.replaceChildren();
const pdf = await getPdfDocument();
pageCount = pdf.numPages;
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
const wrapper = document.createElement('div');
wrapper.className = pageClass;
const label = document.createElement('div');
label.className = 'expanded-page-label';
label.textContent = `Page ${pageNumber}`;
const canvas = document.createElement('canvas');
canvas.className = canvasClass;
wrapper.append(label, canvas);
host.appendChild(wrapper);
await renderPageToCanvas(canvas, pageNumber, width);
}
}
async function renderInline() {
if (!inlinePagesHost) return;
loading = true;
error = '';
try {
await renderPages(inlinePagesHost, 420, 'inline-page', 'inline-canvas');
} catch (err) {
console.error('PDF preview failed', err);
error = 'Preview unavailable';
} finally {
loading = false;
}
}
async function openInline() {
isInlineOpen = true;
await tick();
await renderInline();
}
function closeInline() {
isInlineOpen = false;
}
async function openExpanded() {
isExpanded = true;
expandedLoading = true;
error = '';
await tick();
if (!pagesHost) {
expandedLoading = false;
return;
}
try {
const hostWidth = Math.max(280, Math.min(860, pagesHost.clientWidth - 8 || 860));
await renderPages(pagesHost, hostWidth);
} catch (err) {
console.error('Expanded PDF render failed', err);
error = 'Unable to load PDF';
} finally {
expandedLoading = false;
}
}
function closeExpanded() {
isExpanded = false;
}
onMount(() => {
renderInline();
});
</script>
<div class="pdf-preview-shell">
<div class="preview-frame">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="preview-head"
role="button"
tabindex="0"
onclick={isInlineOpen ? closeInline : openInline}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isInlineOpen) closeInline();
else openInline();
}
}}
>
<div class="preview-docline">
<svg class="preview-docicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<div class="preview-name">{name}</div>
</div>
<div class="preview-head-actions">
<button class="doc-delete" onclick={(e) => { e.stopPropagation(); onDelete(); }} disabled={deleting}>{deleting ? '…' : '×'}</button>
</div>
</div>
{#if isInlineOpen}
<div class="preview-topline">
<div class="preview-badge">PDF preview</div>
<div class="preview-actions">
<button class="preview-action" onclick={openExpanded}>Full view</button>
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
</div>
<div class="preview-card">
{#if error}
<div class="preview-state preview-error">{error}</div>
{:else}
<div class="preview-stage">
<div bind:this={inlinePagesHost} class="inline-pages"></div>
{#if loading}
<div class="preview-loading">Rendering pages…</div>
{/if}
</div>
<div class="preview-footer">
<div class="preview-pages">{pageCount} page{pageCount === 1 ? '' : 's'}</div>
</div>
{/if}
</div>
{:else}
<div class="preview-collapsed-meta">
<div class="preview-pages">{pageCount > 0 ? `${pageCount} page${pageCount === 1 ? '' : 's'}` : 'PDF document'}</div>
<a class="preview-inline-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
{/if}
</div>
</div>
{#if isExpanded}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-overlay" onclick={closeExpanded}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-sheet" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div class="expanded-head">
<div class="expanded-copy">
<div class="expanded-kicker">Inline document</div>
<div class="expanded-title">{name}</div>
</div>
<div class="expanded-head-actions">
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">Open</a>
<button class="expanded-close" onclick={closeExpanded}>Close</button>
</div>
</div>
<div class="expanded-body">
{#if expandedLoading}
<div class="expanded-state">Loading full document…</div>
{:else if error}
<div class="expanded-state preview-error">{error}</div>
{/if}
<div bind:this={pagesHost} class="expanded-pages"></div>
</div>
</div>
</div>
{/if}
<style>
.pdf-preview-shell {
display: block;
}
.preview-frame {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 6px;
border-top: 1px solid rgba(127, 101, 74, 0.12);
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.preview-docline {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.preview-docicon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: rgba(111, 88, 64, 0.7);
}
.preview-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.preview-badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.7);
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-action {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.preview-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
border-radius: 16px;
background: rgba(247, 239, 229, 0.78);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-stage {
position: relative;
border-radius: 16px;
overflow: hidden;
background: rgba(250, 243, 235, 0.72);
border: 1px solid rgba(127, 101, 74, 0.14);
}
.preview-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(247, 239, 229, 0.82);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-error {
color: #8c3c2d;
}
.inline-pages {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 520px;
overflow: auto;
padding: 10px;
}
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-name {
font-size: 0.92rem;
font-weight: 600;
color: #2f2116;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-pages {
font-size: 0.82rem;
color: rgba(93, 72, 55, 0.76);
}
.preview-collapsed-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-inline-link {
font-size: 0.82rem;
font-weight: 600;
color: #5d4737;
text-decoration: none;
}
.doc-delete {
background: none;
border: none;
color: rgba(112, 86, 62, 0.64);
cursor: pointer;
font-size: 1.1rem;
padding: 2px 4px;
}
.doc-delete:hover {
color: #8f3928;
}
.expanded-overlay {
position: fixed;
inset: 0;
z-index: 85;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(19, 13, 10, 0.46);
backdrop-filter: blur(10px);
}
.expanded-sheet {
width: min(980px, 100%);
max-height: min(92vh, 100%);
display: flex;
flex-direction: column;
border-radius: 28px;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(247, 239, 229, 0.98));
box-shadow: 0 28px 64px rgba(35, 24, 15, 0.28);
}
.expanded-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 22px 24px 18px;
border-bottom: 1px solid rgba(123, 97, 71, 0.12);
}
.expanded-copy {
display: flex;
flex-direction: column;
gap: 6px;
}
.expanded-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.72);
}
.expanded-title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.03em;
color: #27190f;
}
.expanded-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.expanded-close {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.expanded-body {
flex: 1;
overflow: auto;
padding: 18px 24px 24px;
}
.expanded-state {
padding: 24px;
border-radius: 18px;
background: rgba(247, 239, 229, 0.74);
color: rgba(90, 71, 54, 0.8);
text-align: center;
}
.expanded-pages {
display: flex;
flex-direction: column;
gap: 20px;
}
:global(.inline-page) {
display: flex;
flex-direction: column;
gap: 8px;
}
:global(.inline-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 14px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 10px 20px rgba(63, 42, 22, 0.05);
}
:global(.expanded-page) {
display: flex;
flex-direction: column;
gap: 10px;
}
:global(.expanded-page-label) {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.68);
}
:global(.expanded-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 18px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 14px 28px rgba(63, 42, 22, 0.06);
}
@media (max-width: 768px) {
.preview-topline {
align-items: flex-start;
flex-direction: column;
}
.preview-actions {
width: 100%;
}
.preview-action {
flex: 1;
text-align: center;
}
.expanded-overlay {
padding: 10px;
}
.expanded-sheet {
max-height: 96vh;
border-radius: 22px;
}
.expanded-head,
.expanded-body {
padding-left: 16px;
padding-right: 16px;
}
.expanded-head {
flex-direction: column;
}
}
</style>

View File

@@ -26,39 +26,57 @@
description = tripData.description || '';
startDate = tripData.start_date || '';
endDate = tripData.end_date || '';
shareUrl = tripData.share_token ? `${typeof window !== 'undefined' ? window.location.origin : ''}/trips/view/${tripData.share_token}` : '';
shareUrl = tripData.share_token
? `${typeof window !== 'undefined' ? window.location.origin : ''}/trips/view/${tripData.share_token}`
: '';
confirmDelete = false;
copied = false;
}
});
function close() { open = false; }
function close() {
open = false;
}
async function save() {
saving = true;
try {
await fetch('/api/trips/trip/update', {
method: 'POST', credentials: 'include',
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id, name, description, start_date: startDate, end_date: endDate })
body: JSON.stringify({
id: tripData.id,
name,
description,
start_date: startDate,
end_date: endDate
})
});
close();
onSaved();
} catch (e) { console.error('Save failed:', e); }
finally { saving = false; }
} catch (e) {
console.error('Save failed:', e);
} finally {
saving = false;
}
}
async function doDelete() {
saving = true;
try {
await fetch('/api/trips/trip/delete', {
method: 'POST', credentials: 'include',
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id })
});
window.location.href = '/trips';
} catch (e) { console.error('Delete failed:', e); }
finally { saving = false; }
} catch (e) {
console.error('Delete failed:', e);
} finally {
saving = false;
}
}
async function toggleShare() {
@@ -66,28 +84,34 @@
try {
if (shareUrl) {
await fetch('/api/trips/share/delete', {
method: 'POST', credentials: 'include',
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id })
});
shareUrl = '';
} else {
const res = await fetch('/api/trips/share/create', {
method: 'POST', credentials: 'include',
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id })
});
const data = await res.json();
shareUrl = `${window.location.origin}/trips/view/${data.share_token}`;
}
} catch { /* silent */ }
finally { sharing = false; }
} catch {
// keep modal stable
} finally {
sharing = false;
}
}
async function copyUrl() {
if (!shareUrl) return;
await navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => copied = false, 2000);
setTimeout(() => (copied = false), 1800);
}
</script>
@@ -97,97 +121,404 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">Edit Trip</div>
<button class="modal-close" onclick={close}>
<div class="modal-head-copy">
<div class="modal-kicker">Trip settings</div>
<div class="modal-title">Edit Trip</div>
</div>
<button class="modal-close" onclick={close} aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="field">
<label class="field-label">Trip Name</label>
<input class="field-input" type="text" bind:value={name} />
</div>
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3"></textarea>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Date</label><input class="field-input" type="date" bind:value={startDate} /></div>
<div class="field"><label class="field-label">End Date</label><input class="field-input" type="date" bind:value={endDate} /></div>
<div class="modal-intro">
<div class="intro-line">Trip profile</div>
<div class="intro-copy">Keep the trip name, date range, and sharing state in one calmer control surface.</div>
</div>
<!-- Sharing -->
<div class="share-section">
<div class="share-header">
<span class="field-label">Sharing</span>
<section class="form-section">
<div class="section-title">Primary info</div>
<div class="field">
<label class="field-label">Trip name</label>
<input class="field-input" type="text" bind:value={name} placeholder="Trip name" />
</div>
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="4" placeholder="What is this trip about?"></textarea>
</div>
</section>
<section class="form-section">
<div class="section-title">Dates</div>
<div class="field-row">
<div class="field">
<label class="field-label">Start date</label>
<input class="field-input" type="date" bind:value={startDate} />
</div>
<div class="field">
<label class="field-label">End date</label>
<input class="field-input" type="date" bind:value={endDate} />
</div>
</div>
</section>
<section class="form-section">
<div class="section-row">
<div class="share-copy">
<div class="section-title">Sharing</div>
<div class="section-copy">Create a viewer link for this trip, or revoke it when you want the itinerary private again.</div>
</div>
<button class="share-toggle" onclick={toggleShare} disabled={sharing}>
{shareUrl ? 'Revoke Link' : 'Create Share Link'}
{shareUrl ? 'Revoke link' : 'Create share link'}
</button>
</div>
{#if shareUrl}
<div class="share-link-row">
<input class="field-input share-url" type="text" readonly value={shareUrl} />
<button class="copy-btn" onclick={copyUrl}>{copied ? 'Copied!' : 'Copy'}</button>
<div class="share-stack">
<div class="share-pill">
<div class="share-label">Viewer link</div>
<div class="share-value">{shareUrl}</div>
</div>
<div class="share-actions">
<button class="copy-btn" onclick={copyUrl}>{copied ? 'Copied' : 'Copy link'}</button>
</div>
</div>
{:else}
<div class="share-empty">No public trip link yet.</div>
{/if}
</div>
</section>
</div>
<div class="modal-footer">
{#if confirmDelete}
<div class="delete-confirm">
<span class="delete-msg">Delete this trip permanently?</span>
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div>
<div class="footer-right">
<button class="btn-danger" onclick={doDelete} disabled={saving}>Delete trip</button>
<button class="btn-cancel" onclick={() => (confirmDelete = false)}>Keep trip</button>
</div>
{:else}
<button class="btn-delete-text" onclick={() => confirmDelete = true}>Delete Trip</button>
<button class="btn-delete-text" onclick={() => (confirmDelete = true)}>Delete Trip</button>
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
{/if}
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; justify-content: flex-end; animation: modalFade 150ms ease; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 100%; height: 100%; background: var(--surface); display: flex; flex-direction: column; box-shadow: -8px 0 32px rgba(0,0,0,0.1); animation: modalSlide 200ms ease; }
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(0); } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-5); display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 60px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.share-section { border-top: 1px solid var(--border); padding-top: 14px; margin-top: var(--sp-1); }
.share-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
.share-toggle { font-size: var(--text-sm); font-weight: 500; color: var(--accent); background: none; border: none; cursor: pointer; font-family: var(--font); }
.share-toggle:hover { opacity: 0.7; }
.share-link-row { display: flex; gap: var(--sp-2); }
.share-url { flex: 1; font-size: var(--text-sm); font-family: var(--mono); }
.copy-btn { padding: 8px 14px; border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); white-space: nowrap; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px var(--sp-5); border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; border-radius: var(--radius-md); background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
.btn-save { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.btn-save:disabled { opacity: 0.4; }
.btn-delete-text { font-size: var(--text-sm); color: var(--error); background: none; border: none; cursor: pointer; font-weight: 500; font-family: var(--font); }
.btn-delete-text:hover { opacity: 0.7; }
.delete-confirm { display: flex; align-items: center; gap: var(--sp-2); }
.delete-msg { font-size: var(--text-sm); color: var(--error); font-weight: 500; }
.btn-danger { padding: 6px var(--sp-3); border-radius: var(--radius-sm); background: var(--error); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
@media (max-width: 768px) { .modal-sheet { width: 100%; } }
.modal-overlay {
position: fixed;
inset: 0;
display: flex;
justify-content: flex-end;
background: rgba(26, 18, 11, 0.36);
backdrop-filter: blur(10px);
z-index: 70;
animation: modalFade 150ms ease;
}
@keyframes modalFade {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-sheet {
width: 520px;
max-width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(252, 248, 242, 0.98), rgba(245, 237, 228, 0.98));
box-shadow: -18px 0 54px rgba(34, 23, 13, 0.14);
animation: modalSlide 200ms ease;
}
@keyframes modalSlide {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 22px 24px 18px;
border-bottom: 1px solid rgba(150, 123, 95, 0.12);
}
.modal-head-copy {
display: grid;
gap: 6px;
}
.modal-kicker,
.intro-line,
.section-title,
.share-label,
.field-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #8f7861;
}
.modal-title {
font-size: 1.85rem;
font-weight: 650;
line-height: 0.98;
letter-spacing: -0.04em;
color: #24180f;
}
.modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 999px;
border: 1px solid rgba(150, 123, 95, 0.12);
background: rgba(255, 255, 255, 0.6);
color: #6a5644;
cursor: pointer;
}
.modal-close svg {
width: 18px;
height: 18px;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 22px 24px 28px;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-intro {
display: grid;
gap: 6px;
padding: 0 2px;
}
.intro-copy,
.section-copy {
color: #655444;
line-height: 1.55;
font-size: 0.96rem;
}
.form-section {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 24px;
background: rgba(255, 251, 246, 0.68);
border: 1px solid rgba(150, 123, 95, 0.12);
}
.section-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.share-copy {
display: grid;
gap: 6px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-weight: 600;
letter-spacing: 0.12em;
}
.field-input {
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(150, 123, 95, 0.14);
background: rgba(255, 251, 246, 0.96);
color: #24180f;
font-size: 0.98rem;
font-family: var(--font);
}
.field-input:focus {
outline: none;
border-color: rgba(110, 80, 48, 0.42);
box-shadow: 0 0 0 4px rgba(167, 127, 76, 0.12);
}
.field-textarea {
resize: vertical;
min-height: 88px;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.share-toggle,
.copy-btn,
.btn-cancel,
.btn-save,
.btn-danger {
min-height: 42px;
padding: 0 16px;
border-radius: 999px;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.share-toggle,
.btn-save {
background: #2f2218;
color: #fff7ef;
border: none;
}
.share-toggle:disabled,
.btn-save:disabled,
.btn-danger:disabled {
opacity: 0.55;
cursor: default;
}
.share-stack {
display: grid;
gap: 12px;
}
.share-pill {
display: grid;
gap: 6px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(246, 237, 226, 0.82);
border: 1px solid rgba(150, 123, 95, 0.12);
}
.share-value {
font-size: 0.92rem;
line-height: 1.45;
color: #3d2b1c;
word-break: break-all;
}
.share-actions {
display: flex;
justify-content: flex-start;
}
.copy-btn,
.btn-cancel {
background: rgba(255, 251, 246, 0.96);
color: #5e4d3d;
border: 1px solid rgba(150, 123, 95, 0.14);
}
.share-empty {
padding: 14px 16px;
border-radius: 18px;
background: rgba(246, 237, 226, 0.62);
border: 1px dashed rgba(150, 123, 95, 0.18);
color: #705e4f;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 24px 18px;
border-top: 1px solid rgba(150, 123, 95, 0.12);
background: rgba(251, 246, 240, 0.82);
}
.footer-right {
display: flex;
gap: 8px;
}
.btn-delete-text {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
color: #a23e26;
cursor: pointer;
}
.delete-confirm {
display: flex;
align-items: center;
gap: 10px;
}
.delete-msg {
font-size: 0.92rem;
font-weight: 600;
color: #a23e26;
}
.btn-danger {
background: #a23e26;
color: white;
border: none;
}
@media (max-width: 768px) {
.modal-sheet {
width: 100%;
}
.modal-header {
padding: 18px 16px 14px;
}
.modal-body {
padding: 16px 16px 22px;
}
.section-row,
.field-row {
grid-template-columns: 1fr;
display: grid;
}
.modal-footer {
padding: 14px 16px calc(12px + env(safe-area-inset-bottom, 0px));
flex-wrap: wrap;
}
.footer-right {
width: 100%;
}
.footer-right :global(button) {
flex: 1;
}
}
</style>