Files
2026-06-08 21:58:16 +12:00

849 lines
21 KiB
Svelte
Raw Permalink 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 {
SECTION_TYPES,
COLLECTION_TYPES,
ITEM_TYPES,
SORT_OPTIONS,
IMAGE_TYPES,
GENRES,
getSectionIconName,
getSectionTypeLabel,
getGenreNames
} from '$lib/constants.js';
import Icon from '$lib/Icon.svelte';
export let section;
export let index;
export let total;
export let expanded = false;
export let excludedFolderLookup = {};
export let lookupUserId = '';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
$: genreNames = getGenreNames(section.Query?.GenreIds);
$: typeLabel = getSectionTypeLabel(section.SectionType);
$: sectionIcon = getSectionIconName(section.SectionType);
$: showFilters =
section.SectionType === 'items' ||
section.SectionType === 'collections' ||
section.SectionType === 'latestepisodereleases' ||
section.SectionType === 'latestmoviereleases';
// ExcludedFolders as a comma-separated string for editing
$: excludedFoldersStr = (section.ExcludedFolders || []).join(', ');
$: excludedFolderDetails = (section.ExcludedFolders || []).map((id) => ({
id,
label: excludedFolderLookup?.[id]?.name || null,
type: excludedFolderLookup?.[id]?.type || null
}));
// TagIds as a comma-separated string for editing
$: tagIdsStr = (section.Query?.TagIds || []).join(', ');
let collectionSearchTerm = '';
let collectionResults = [];
let collectionLookupBusy = false;
let collectionLookupError = '';
function toggleGenre(id) {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
const idx = section.Query.GenreIds.indexOf(id);
if (idx >= 0) {
section.Query.GenreIds = section.Query.GenreIds.filter((g) => g !== id);
} else {
section.Query.GenreIds = [...section.Query.GenreIds, id];
}
dispatch('change');
}
function toggleItemType(type) {
const idx = section.ItemTypes.indexOf(type);
if (idx >= 0) {
section.ItemTypes = section.ItemTypes.filter((t) => t !== type);
} else {
section.ItemTypes = [...section.ItemTypes, type];
}
dispatch('change');
}
function handleChange() {
dispatch('change');
}
function handleExcludedFoldersChange(e) {
const raw = e.target.value;
section.ExcludedFolders = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
dispatch('change');
}
function handleTagIdsChange(e) {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
const raw = e.target.value;
section.Query.TagIds = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
dispatch('change');
}
async function searchCollections() {
if (!lookupUserId) {
collectionLookupError = 'Select a user with a linked Emby account to search collections.';
return;
}
if (!collectionSearchTerm.trim()) {
collectionResults = [];
collectionLookupError = '';
return;
}
collectionLookupBusy = true;
collectionLookupError = '';
try {
const params = new URLSearchParams({
userId: lookupUserId,
term: collectionSearchTerm.trim(),
types: 'BoxSet',
limit: '8'
});
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);
collectionResults = body.items || [];
} catch (err) {
collectionLookupError = err.message;
collectionResults = [];
} finally {
collectionLookupBusy = false;
}
}
function applyCollection(item) {
const previousName = section.ParentItem?.Name || section.Name || section.CustomName || '';
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Name = item.name;
section.ParentItem.Id = item.id;
section.ParentId = item.id;
if (!section.CustomName || section.CustomName === previousName) {
section.CustomName = item.name;
}
if (!section.Name || section.Name === previousName) {
section.Name = item.name;
}
collectionSearchTerm = item.name;
collectionResults = [];
dispatch('change');
}
</script>
<div class="section-card" class:expanded>
<!-- Header row - always visible -->
<button class="section-header" on:click={() => dispatch('toggle')}>
<div class="reorder-btns">
<button
class="move-btn"
disabled={index === 0}
on:click|stopPropagation={() => dispatch('move', -1)}
title="Move up"></button
>
<button
class="move-btn"
disabled={index === total - 1}
on:click|stopPropagation={() => dispatch('move', 1)}
title="Move down"></button
>
</div>
<span class="section-index">{index + 1}</span>
<span class="section-icon">
<Icon name={sectionIcon} size={16} />
</span>
<div class="section-info">
<div class="section-name">{section.CustomName || section.Name || '(unnamed)'}</div>
<div class="section-meta">
{typeLabel}
{#if genreNames} · {genreNames}{/if}
{#if section.CollectionType} · {section.CollectionType}{/if}
{#if section.SortBy} · {SORT_OPTIONS.find((s) => s.value === section.SortBy)?.label || section.SortBy}{/if}
{#if section.ExcludedFolders?.length} · {section.ExcludedFolders.length} excluded{/if}
</div>
</div>
<button
class="remove-btn"
on:click|stopPropagation={() => dispatch('remove')}
title="Remove section">×</button
>
<span class="expand-indicator">{expanded ? '▾' : '▸'}</span>
</button>
<!-- Expanded editor -->
{#if expanded}
<div class="section-editor">
<!-- Core fields -->
<div class="field-grid">
<label class="field span2">
<span class="field-label">Display name</span>
<input
type="text"
bind:value={section.CustomName}
on:input={() => {
section.Name = section.CustomName;
handleChange();
}}
placeholder="Section name"
/>
</label>
<label class="field">
<span class="field-label">Section type</span>
<select bind:value={section.SectionType} on:change={handleChange}>
{#each SECTION_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Image type</span>
<select bind:value={section.ImageType} on:change={handleChange}>
{#each IMAGE_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Sort by</span>
<select bind:value={section.SortBy} on:change={handleChange}>
{#each SORT_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Sort order</span>
<select bind:value={section.SortOrder} on:change={handleChange}>
<option value={undefined}>(none)</option>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</label>
{#if showFilters}
<label class="field">
<span class="field-label">Collection type</span>
<select bind:value={section.CollectionType} on:change={handleChange}>
{#each COLLECTION_TYPES as c}
<option value={c.value}>{c.label}</option>
{/each}
</select>
</label>
{/if}
{#if section.SectionType === 'userviews'}
<label class="field">
<span class="field-label">View type</span>
<select bind:value={section.ViewType} on:change={handleChange}>
<option value={undefined}>(default)</option>
<option value="buttons">Buttons</option>
<option value="list">List</option>
</select>
</label>
{/if}
<label class="field">
<span class="field-label">Card size offset</span>
<select
value={section.CardSizeOffset ?? 0}
on:change={(e) => { section.CardSizeOffset = Number(e.target.value); handleChange(); }}
>
<option value={-2}>-2 (Smaller)</option>
<option value={-1}>-1 (Small)</option>
<option value={0}>0 (Default)</option>
<option value={1}>+1 (Large)</option>
<option value={2}>+2 (Larger)</option>
</select>
</label>
</div>
<!-- Checkboxes -->
<div class="field-grid checkboxes">
{#if section.SectionType === 'resume'}
<label class="field-inline">
<input
type="checkbox"
checked={section.IncludeNextUpInResume ?? true}
on:change={(e) => { section.IncludeNextUpInResume = e.target.checked; handleChange(); }}
/>
<span>Include Next Up in Resume</span>
</label>
{/if}
{#if showFilters}
<label class="field-inline">
<input
type="checkbox"
checked={section.Query?.IsFavorite || false}
on:change={(e) => {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
section.Query.IsFavorite = e.target.checked || undefined;
handleChange();
}}
/>
<span>Favourites only</span>
</label>
<label class="field-inline">
<input
type="checkbox"
checked={section.Query?.IsPlayed === false}
on:change={(e) => {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
section.Query.IsPlayed = e.target.checked ? false : undefined;
handleChange();
}}
/>
<span>Unplayed only</span>
</label>
{/if}
</div>
{#if showFilters}
<!-- Item types -->
<div class="field-group">
<span class="field-label">Item types</span>
<div class="chip-group">
{#each ITEM_TYPES as type}
<button
class="chip"
class:active={section.ItemTypes?.includes(type)}
on:click={() => toggleItemType(type)}
>
{type}
</button>
{/each}
</div>
</div>
<!-- Genres -->
<div class="field-group">
<span class="field-label">Genres</span>
<div class="chip-group">
{#each Object.entries(GENRES).sort((a, b) => a[1].localeCompare(b[1])) as [id, name]}
<button
class="chip"
class:active={section.Query?.GenreIds?.includes(id)}
on:click={() => toggleGenre(id)}
>
{name}
</button>
{/each}
</div>
</div>
<!-- Tag IDs -->
<div class="field-group">
<span class="field-label">Tag IDs <span class="hint-inline">(comma-separated Emby tag IDs)</span></span>
<input
type="text"
class="ids-input"
value={tagIdsStr}
on:change={handleTagIdsChange}
placeholder="e.g. 1317339, 1336097"
/>
{#if section.Query?.TagIds?.length}
<div class="ids-preview">
{#each section.Query.TagIds as id}
<span class="id-chip">
{id}
<button class="id-remove" on:click={() => {
section.Query.TagIds = section.Query.TagIds.filter(t => t !== id);
dispatch('change');
}}>×</button>
</span>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Boxset parent -->
{#if section.SectionType === 'boxset'}
<div class="field-group">
<span class="field-label">Linked box set</span>
<div class="lookup-row">
<input
type="text"
class="ids-input"
bind:value={collectionSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchCollections()}
placeholder="Search existing collections..."
/>
<button class="chip lookup-btn" on:click={searchCollections} disabled={collectionLookupBusy}>
{collectionLookupBusy ? 'Searching…' : 'Lookup'}
</button>
</div>
{#if collectionLookupError}
<div class="inline-status error">{collectionLookupError}</div>
{/if}
{#if collectionResults.length}
<div class="lookup-results">
{#each collectionResults as item}
<button class="resolved-item lookup-result" on:click={() => applyCollection(item)}>
<span class="resolved-name">{item.name}</span>
<span class="resolved-meta">{item.id}</span>
</button>
{/each}
</div>
{/if}
<div class="field-grid">
<label class="field">
<span class="field-label">Box set name</span>
<input
type="text"
value={section.ParentItem?.Name || ''}
on:input={(e) => {
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Name = e.target.value;
section.Name = e.target.value;
handleChange();
}}
placeholder="Display name"
/>
</label>
<label class="field">
<span class="field-label">Box set ID</span>
<input
type="text"
value={section.ParentItem?.Id || section.ParentId || ''}
on:input={(e) => {
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Id = e.target.value;
section.ParentId = e.target.value;
handleChange();
}}
placeholder="Emby item ID"
/>
</label>
</div>
{#if section.ParentItem?.Id}
<div class="boxset-info">
<span>{section.ParentItem.Name}</span>
<span class="text-muted">ID: {section.ParentItem.Id}</span>
</div>
{/if}
</div>
{/if}
<!-- Excluded folders -->
<div class="field-group">
<span class="field-label">
Excluded folders
{#if section.ExcludedFolders?.length}
<span class="badge">{section.ExcludedFolders.length}</span>
{/if}
<span class="hint-inline">(comma-separated folder IDs)</span>
</span>
<input
type="text"
class="ids-input"
value={excludedFoldersStr}
on:change={handleExcludedFoldersChange}
placeholder="e.g. 4309, 39975, 462878"
/>
{#if section.ExcludedFolders?.length}
<div class="ids-preview">
{#each section.ExcludedFolders as id}
<span class="id-chip">
{id}
<button class="id-remove" on:click={() => {
section.ExcludedFolders = section.ExcludedFolders.filter(f => f !== id);
dispatch('change');
}}>×</button>
</span>
{/each}
</div>
{/if}
{#if excludedFolderDetails.some((folder) => folder.label)}
<div class="resolved-list">
{#each excludedFolderDetails.filter((folder) => folder.label) as folder}
<div class="resolved-item">
<span class="resolved-name">{folder.label}</span>
<span class="resolved-meta">{folder.id}{folder.type ? ` · ${folder.type}` : ''}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
.section-card {
background: #101318;
border-radius: 12px;
border: 1px solid var(--border);
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.15s ease, background 0.15s ease;
}
.section-card:hover {
background: #12161d;
}
.section-card.expanded {
border-color: var(--border-strong);
}
.section-header {
all: unset;
display: flex;
align-items: center;
padding: 14px 16px;
gap: 10px;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
.section-header:hover {
background: var(--surface-hover);
}
.reorder-btns {
display: flex;
flex-direction: column;
gap: 1px;
}
.move-btn {
all: unset;
cursor: pointer;
font-size: 10px;
line-height: 1;
opacity: 0.7;
padding: 3px 4px;
color: var(--text-muted);
}
.move-btn:disabled {
opacity: 0.15;
cursor: default;
}
.move-btn:not(:disabled):hover {
opacity: 1;
}
.section-index {
font-size: 11px;
color: var(--text-muted);
min-width: 24px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.section-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: rgba(40, 193, 220, 0.1);
border: 1px solid var(--border);
color: var(--accent);
flex-shrink: 0;
}
.section-info {
flex: 1;
min-width: 0;
}
.section-name {
font-weight: 600;
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.section-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
cursor: pointer;
color: var(--danger);
font-size: 20px;
line-height: 1;
padding: 0 4px;
opacity: 0.55;
}
.remove-btn:hover {
opacity: 1;
}
.expand-indicator {
font-size: 12px;
color: var(--text-muted);
min-width: 14px;
}
/* Editor panel */
.section-editor {
padding: 10px 16px 18px;
border-top: 1px solid var(--border);
background: #0d1015;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.field-grid.checkboxes {
grid-template-columns: auto auto auto;
justify-content: start;
gap: 16px;
margin-bottom: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field.span2 {
grid-column: span 2;
}
.field-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hint-inline {
font-size: 10px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.7;
}
.field input[type='text'],
.field select {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--border-strong);
}
.field-group {
margin-bottom: 12px;
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.lookup-btn {
padding: 10px 12px;
border-radius: 10px;
text-align: center;
color: var(--text);
background: #15181e;
}
.lookup-btn:disabled {
opacity: 0.45;
cursor: default;
}
.lookup-results {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.lookup-result {
all: unset;
cursor: pointer;
}
.inline-status {
margin-top: 8px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
font-size: 12px;
color: var(--text-muted);
}
.inline-status.error {
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.24);
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
}
.chip {
all: unset;
cursor: pointer;
padding: 6px 11px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
background: #12151b;
transition: all 0.12s;
}
.chip:hover {
border-color: var(--accent);
color: var(--text);
}
.chip.active {
background: var(--accent);
border-color: rgba(42, 215, 239, 0.3);
color: #031014;
}
.field-inline {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
/* IDs input (excluded folders, tag IDs) */
.ids-input {
width: 100%;
margin-top: 6px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
color: var(--text);
font-size: 12px;
font-family: monospace;
box-sizing: border-box;
}
.ids-input:focus {
outline: none;
border-color: var(--border-strong);
}
.ids-preview {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.id-chip {
display: inline-flex;
align-items: center;
gap: 3px;
background: #12151b;
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 8px 4px 10px;
font-size: 11px;
font-family: monospace;
color: var(--text-muted);
}
.id-remove {
all: unset;
cursor: pointer;
color: var(--danger);
font-size: 14px;
line-height: 1;
opacity: 0.6;
}
.id-remove:hover {
opacity: 1;
}
.badge {
display: inline-block;
background: var(--accent);
color: #031014;
border-radius: 999px;
padding: 2px 6px;
font-size: 10px;
font-weight: 700;
margin-left: 4px;
vertical-align: middle;
text-transform: none;
letter-spacing: 0;
}
.boxset-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text);
margin-top: 6px;
padding: 10px 12px;
background: #12151b;
border-radius: 10px;
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
.resolved-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.resolved-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 10px;
background: #12151b;
border: 1px solid var(--border);
}
.lookup-result:hover {
border-color: var(--border-strong);
background: var(--surface-hover);
}
.resolved-name {
color: var(--text);
font-size: 12px;
font-weight: 600;
}
.resolved-meta {
color: var(--text-muted);
font-size: 11px;
font-family: monospace;
text-align: right;
}
@media (max-width: 640px) {
.lookup-row {
grid-template-columns: 1fr;
}
}
</style>