Files
embyhomescreenedit/src/lib/CollectionBuilderModal.svelte
T
2026-04-25 22:57:08 +12:00

636 lines
16 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>
import { createEventDispatcher } from 'svelte';
export let open = false;
export let users = [];
export let selectedUserId = null;
const dispatch = createEventDispatcher();
let sourceUserId = null;
let targetUserIds = [];
let seedType = 'Movie';
let collectionName = '';
let seedSearchTerm = '';
let seedResults = [];
let selectedSeeds = [];
let recommendations = [];
let includeSeeds = true;
let recommendationLimit = 18;
let searchBusy = false;
let previewBusy = false;
let createBusy = false;
let searchError = '';
let actionError = '';
let actionMessage = '';
let existingCollection = null;
let wasOpen = false;
$: sourceUsers = users.filter((user) => user.embyGuid);
$: if (open && !wasOpen) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
targetUserIds = users.some((user) => user.id === selectedUserId) ? [selectedUserId] : [];
wasOpen = true;
} else if (!open && wasOpen) {
resetState();
wasOpen = false;
}
$: if (open && !sourceUsers.some((user) => user.id === sourceUserId)) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
}
$: if (open && targetUserIds.length === 0 && selectedUserId != null) {
targetUserIds = users.some((user) => user.id === selectedUserId) ? [selectedUserId] : [];
}
$: sourceUser = users.find((user) => user.id === sourceUserId);
$: lookupType = seedType === 'Movie' ? 'Movie' : 'Series';
$: canPreview = !!sourceUser?.embyGuid && selectedSeeds.length > 0;
$: canCreate = canPreview && !!collectionName.trim() && !createBusy && recommendations.length > 0;
function close() {
dispatch('close');
}
function resetState() {
sourceUserId = null;
targetUserIds = [];
seedType = 'Movie';
collectionName = '';
seedSearchTerm = '';
seedResults = [];
selectedSeeds = [];
recommendations = [];
existingCollection = null;
includeSeeds = true;
recommendationLimit = 18;
searchBusy = false;
previewBusy = false;
createBusy = false;
searchError = '';
actionError = '';
actionMessage = '';
}
function toggleTargetUser(userId) {
if (targetUserIds.includes(userId)) {
targetUserIds = targetUserIds.filter((id) => id !== userId);
} else {
targetUserIds = [...targetUserIds, userId];
}
}
function addSeed(item) {
if (selectedSeeds.some((seed) => seed.id === item.id)) return;
selectedSeeds = [...selectedSeeds, item];
recommendations = [];
existingCollection = null;
actionError = '';
actionMessage = '';
}
function removeSeed(seedId) {
selectedSeeds = selectedSeeds.filter((seed) => seed.id !== seedId);
recommendations = [];
existingCollection = null;
}
async function searchSeeds() {
if (!sourceUser?.embyGuid) {
searchError = 'Select a source user with a linked Emby account.';
return;
}
if (!seedSearchTerm.trim()) {
seedResults = [];
searchError = '';
return;
}
searchBusy = true;
searchError = '';
try {
const params = new URLSearchParams({
userId: sourceUser.embyGuid,
term: seedSearchTerm.trim(),
types: lookupType,
limit: '10'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
seedResults = body.items || [];
} catch (err) {
searchError = err.message;
seedResults = [];
} finally {
searchBusy = false;
}
}
async function previewRecommendations() {
if (!canPreview) return;
previewBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'preview',
userId: sourceUser.embyGuid,
seedIds: selectedSeeds.map((seed) => seed.id),
name: collectionName.trim(),
limit: recommendationLimit
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
recommendations = body.recommendations || [];
existingCollection = body.collection || null;
actionMessage = recommendations.length
? existingCollection?.updated
? `Generated ${recommendations.length} recommendations. Re-running create will update "${existingCollection.name}".`
: `Generated ${recommendations.length} recommendations.`
: 'No recommendations were returned for these seeds.';
} catch (err) {
actionError = err.message;
recommendations = [];
existingCollection = null;
} finally {
previewBusy = false;
}
}
async function createCollection() {
if (!canCreate) return;
createBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'create',
userId: sourceUser.embyGuid,
name: collectionName.trim(),
seedIds: selectedSeeds.map((seed) => seed.id),
limit: recommendationLimit,
includeSeeds
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
dispatch('created', {
collection: body.collection,
targetUserIds,
seeds: selectedSeeds,
recommendations: body.recommendations || []
});
existingCollection = body.collection || null;
actionMessage = body.collection?.updated
? `Updated collection "${body.collection.name}".`
: `Created collection "${body.collection?.name || collectionName.trim()}".`;
} catch (err) {
actionError = err.message;
} finally {
createBusy = false;
}
}
</script>
{#if open}
<div
class="overlay"
role="button"
tabindex="0"
aria-label="Close collection builder"
on:click|self={close}
on:keydown={(event) => (event.key === 'Escape' || event.key === 'Enter') && close()}
>
<div class="modal" role="dialog" aria-modal="true" aria-label="Recommendation collection builder">
<div class="modal-header">
<div>
<h3>Recommendation Collection Builder</h3>
<p>Pick seed movies or shows, generate similar items, then create a reusable Emby collection.</p>
</div>
<button class="close-btn" on:click={close}>×</button>
</div>
<div class="modal-body">
<div class="field-grid">
<label class="field">
<span class="field-label">Source user</span>
<select bind:value={sourceUserId}>
{#each sourceUsers as user}
<option value={user.id}>{user.name}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Seed type</span>
<select bind:value={seedType}>
<option value="Movie">Movies</option>
<option value="Series">Shows</option>
</select>
</label>
<label class="field span2">
<span class="field-label">Collection name</span>
<input
type="text"
bind:value={collectionName}
placeholder="Recommended for Family Movie Night"
/>
</label>
</div>
<div class="field-group">
<div class="field-label-row">
<span class="field-label">Seed lookup</span>
<label class="field-inline compact">
<input type="checkbox" bind:checked={includeSeeds} />
<span>Include seeds in final collection</span>
</label>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={seedSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchSeeds()}
placeholder={`Search ${seedType === 'Movie' ? 'movies' : 'shows'}...`}
/>
<button class="btn ghost" on:click={searchSeeds} disabled={searchBusy || !sourceUser?.embyGuid}>
{searchBusy ? 'Searching…' : 'Search'}
</button>
</div>
{#if searchError}
<div class="status error">{searchError}</div>
{/if}
{#if seedResults.length}
<div class="result-list">
{#each seedResults as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
{#if item.overview}
<div class="result-overview">{item.overview}</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<div class="field-group">
<div class="field-label-row">
<span class="field-label">Selected seeds</span>
<label class="field-inline compact">
<span>Recommendation count</span>
<select bind:value={recommendationLimit}>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
<option value={30}>30</option>
</select>
</label>
</div>
{#if selectedSeeds.length}
<div class="seed-list">
{#each selectedSeeds as item}
<div class="seed-chip">
<div>
<div class="seed-name">{item.name}</div>
<div class="seed-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
</div>
<button class="remove-btn" on:click={() => removeSeed(item.id)}>×</button>
</div>
{/each}
</div>
{:else}
<div class="empty-note">Add at least one seed item to generate recommendations.</div>
{/if}
</div>
<div class="field-group">
<span class="field-label">Apply linked section to users</span>
<div class="target-list">
{#each users.filter((user) => user.embyGuid) as user}
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
<input
type="checkbox"
checked={targetUserIds.includes(user.id)}
on:change={() => toggleTargetUser(user.id)}
/>
<span>{user.name}</span>
<span class="target-count">{user.sections?.length || 0} sections</span>
</label>
{/each}
</div>
</div>
{#if recommendations.length}
<div class="field-group">
<span class="field-label">Recommendation preview</span>
<div class="preview-list">
{#each recommendations as item}
<div class="preview-item">
<div>
<div class="result-name">{item.name}</div>
<div class="result-meta">
{item.type}{item.year ? ` · ${item.year}` : ''} · matched {item.matchCount} seed{item.matchCount === 1 ? '' : 's'}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if existingCollection?.id}
<div class="status">Collection target: {existingCollection.name} · existing box set will be updated</div>
{/if}
{#if actionError}
<div class="status error">{actionError}</div>
{:else if actionMessage}
<div class="status ok">{actionMessage}</div>
{/if}
</div>
<div class="modal-actions">
<button class="btn ghost" on:click={previewRecommendations} disabled={!canPreview || previewBusy || createBusy}>
{previewBusy ? 'Generating…' : 'Preview Recommendations'}
</button>
<button class="btn accent" on:click={createCollection} disabled={!canCreate || previewBusy || createBusy}>
{createBusy ? (existingCollection?.updated ? 'Updating…' : 'Creating…') : (existingCollection?.updated ? 'Update Collection' : 'Create Collection')}
</button>
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 120;
}
.modal {
width: min(980px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
border-radius: 20px;
background: var(--surface-strong);
border: 1px solid var(--border);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
}
.modal-header,
.modal-actions {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
}
.modal-actions {
border-bottom: none;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-header {
display: flex;
justify-content: space-between;
gap: 16px;
}
.modal-header h3 {
margin: 0 0 6px;
font-size: 18px;
color: var(--text);
}
.modal-header p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.close-btn,
.remove-btn {
all: unset;
cursor: pointer;
color: var(--text-muted);
font-size: 22px;
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field,
.field-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.field.span2 {
grid-column: span 2;
}
.field-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.field-label-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.field-inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
}
.field-inline.compact {
font-size: 12px;
}
input,
select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #0b0f14;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: var(--border-strong);
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.result-list,
.preview-list,
.target-list,
.seed-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-card,
.preview-item,
.seed-chip,
.target-option {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 12px 14px;
}
.result-card {
all: unset;
cursor: pointer;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 12px 14px;
transition: border-color 0.12s ease, background 0.12s ease;
}
.result-card:hover,
.target-option:hover {
border-color: var(--border-strong);
background: var(--surface-hover);
}
.result-name,
.seed-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.result-meta,
.seed-meta,
.target-count {
font-size: 12px;
color: var(--text-muted);
}
.result-overview {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
.seed-chip,
.target-option {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.target-option {
cursor: pointer;
}
.target-option.selected {
background: var(--surface-active);
border-color: var(--border-strong);
}
.empty-note {
padding: 14px;
border-radius: 14px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text-muted);
font-size: 13px;
}
.status {
padding: 10px 12px;
border-radius: 14px;
font-size: 12px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
}
.status.ok {
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.status.error {
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.btn {
all: unset;
cursor: pointer;
padding: 9px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
border: 1px solid transparent;
}
.btn.ghost {
color: #d9e7f8;
border-color: var(--border);
background: var(--surface);
}
.btn.accent {
background: var(--accent);
color: #fff;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
@media (max-width: 720px) {
.field-grid,
.lookup-row {
grid-template-columns: 1fr;
}
.modal-header,
.modal-body,
.modal-actions {
padding: 16px;
}
.modal-actions {
flex-direction: column;
}
.field.span2 {
grid-column: span 1;
}
}
</style>