Files
platform/frontend-v2/src/lib/components/trips/ImageUpload.svelte
Yusuf Suleman 2072c359aa 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>
2026-04-01 16:32:53 -05:00

316 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
import PdfInlinePreview from './PdfInlinePreview.svelte';
let {
entityType,
entityId,
images = [],
documents = [],
onUpload
}: {
entityType: string;
entityId: string;
images: any[];
documents?: any[];
onUpload: () => void;
} = $props();
let uploading = $state(false);
let deletingId = $state('');
let uploadingDoc = $state(false);
let deletingDocId = $state('');
let showImmich = $state(false);
// Image search
let showSearch = $state(false);
let searchQuery = $state('');
let searchResults = $state<{ url: string; thumbnail: string; title: string }[]>([]);
let searching = $state(false);
let savingUrl = $state('');
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !entityId) return;
uploading = true;
try {
for (const file of input.files) {
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
await fetch('/api/trips/image/upload', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, image_data: base64, filename: file.name })
});
}
onUpload();
} catch (e) { console.error('Upload failed:', e); }
finally { uploading = false; input.value = ''; }
}
async function deleteImage(imageId: string) {
deletingId = imageId;
try {
await fetch('/api/trips/image/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: imageId })
});
onUpload();
} catch { /* silent */ }
finally { deletingId = ''; }
}
async function searchImages() {
if (!searchQuery.trim()) return;
searching = true;
try {
const res = await fetch('/api/trips/image/search', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: searchQuery })
});
if (res.ok) {
const data = await res.json();
searchResults = data.images || [];
}
} catch { searchResults = []; }
finally { searching = false; }
}
async function saveFromUrl(url: string) {
savingUrl = url;
try {
await fetch('/api/trips/image/upload-from-url', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, url })
});
onUpload();
showSearch = false; searchResults = [];
} catch { /* silent */ }
finally { savingUrl = ''; }
}
async function handleDocSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !entityId) return;
uploadingDoc = true;
try {
for (const file of input.files) {
const formData = new FormData();
formData.append('entity_type', entityType);
formData.append('entity_id', entityId);
formData.append('file', file);
await fetch('/api/trips/document/upload', {
method: 'POST', credentials: 'include', body: formData
});
}
onUpload();
} catch { /* silent */ }
finally { uploadingDoc = false; input.value = ''; }
}
async function deleteDoc(docId: string) {
deletingDocId = docId;
try {
await fetch('/api/trips/document/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_id: docId })
});
onUpload();
} catch { /* silent */ }
finally { deletingDocId = ''; }
}
async function handleImmichSelect(assetIds: string[]) {
// Download from Immich and upload to trips
for (const assetId of assetIds) {
try {
const imgRes = await fetch(`/api/immich/assets/${assetId}/thumbnail`);
if (!imgRes.ok) continue;
const blob = await imgRes.blob();
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
await fetch('/api/trips/image/upload', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, image_data: base64, filename: `immich-${assetId}.webp` })
});
} catch { /* silent */ }
}
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">
<!-- Existing images -->
{#if images.length > 0}
<div class="image-strip">
{#each images as img}
<div class="image-thumb-wrap">
<img src={img.url || `/images/${img.file_path}`} alt="" class="image-thumb" />
<button class="image-delete" onclick={() => deleteImage(img.id)} disabled={deletingId === img.id}>
{#if deletingId === img.id}...{:else}×{/if}
</button>
</div>
{/each}
</div>
{/if}
<!-- Existing documents -->
{#if documents && documents.length > 0}
<div class="doc-list">
{#each documents as doc}
<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>
{/if}
<!-- Action buttons -->
{#if entityId}
<div class="upload-actions">
<label class="upload-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
{uploading ? 'Uploading...' : 'Photo'}
<input type="file" accept="image/*" multiple class="hidden-input" onchange={handleFileSelect} disabled={uploading} />
</label>
<button class="upload-btn" onclick={() => { showSearch = !showSearch; if (showSearch) searchImages(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
Search
</button>
<button class="upload-btn" onclick={() => showImmich = true}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
Immich
</button>
<label class="upload-btn">
<svg 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>
{uploadingDoc ? 'Uploading...' : 'Document'}
<input type="file" accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png" multiple class="hidden-input" onchange={handleDocSelect} disabled={uploadingDoc} />
</label>
</div>
{:else}
<div class="upload-hint">Save first to add photos and documents</div>
{/if}
<!-- Search panel -->
{#if showSearch}
<div class="search-panel">
<div class="search-row">
<input class="search-input" type="text" placeholder="Search images..." bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); searchImages(); } }} />
<button class="search-btn" onclick={searchImages} disabled={searching}>{searching ? '...' : 'Search'}</button>
<button class="search-close" onclick={() => { showSearch = false; searchResults = []; }}>×</button>
</div>
{#if searchResults.length > 0}
<div class="search-grid">
{#each searchResults as result}
<button class="search-thumb" onclick={() => saveFromUrl(result.url)} disabled={savingUrl === result.url}>
<img src={result.thumbnail} alt={result.title} />
{#if savingUrl === result.url}<div class="search-saving">Saving...</div>{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if showImmich}
<ImmichPicker bind:open={showImmich} onselect={handleImmichSelect} />
{/if}
<style>
.upload-section { display: flex; flex-direction: column; gap: 10px; }
.image-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.image-thumb-wrap { position: relative; flex-shrink: 0; }
.image-thumb { width: 96px; height: 72px; object-fit: cover; border-radius: 8px; }
.image-delete {
position: absolute; top: 3px; right: 3px; width: 20px; height: 20px;
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: 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 {
display: flex; align-items: center; gap: 4px; padding: 6px 10px;
border-radius: 6px; background: none; border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer;
font-family: var(--font); transition: all var(--transition);
}
.upload-btn:hover { color: var(--text-1); border-color: var(--text-4); }
.upload-btn svg { width: 14px; height: 14px; }
.hidden-input { display: none; }
.upload-hint { font-size: var(--text-sm); color: var(--text-4); text-align: center; padding: 8px; }
.search-panel { border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: var(--surface-secondary); }
.search-row { display: flex; gap: 6px; margin-bottom: 8px; }
.search-input { flex: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-family: var(--font); }
.search-input:focus { outline: none; border-color: var(--accent); }
.search-btn { padding: 6px 12px; border-radius: 6px; background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.search-close { background: none; border: none; color: var(--text-3); font-size: var(--text-md); cursor: pointer; padding: 4px 8px; }
.search-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; max-height: 160px; overflow-y: auto; }
.search-thumb { position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden; border: none; cursor: pointer; padding: 0; background: var(--card-hover); }
.search-thumb img { width: 100%; height: 100%; object-fit: cover; }
.search-saving { position: absolute; inset: 0; background: rgba(0,0,0,0.5); color: white; display: flex; align-items: center; justify-content: center; font-size: var(--text-xs); }
</style>