- 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>
316 lines
12 KiB
Svelte
316 lines
12 KiB
Svelte
<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>
|