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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
590
frontend-v2/src/lib/components/trips/PdfInlinePreview.svelte
Normal file
590
frontend-v2/src/lib/components/trips/PdfInlinePreview.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user