v1
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
|
.svelte-kit/
|
||||||
|
.vite/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
config.json
|
||||||
|
users.db
|
||||||
Generated
+1464
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "emby-homescreen-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||||
|
"svelte": "^4.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Emby Home Screen Editor</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
<script>
|
||||||
|
import {
|
||||||
|
SECTION_TYPES,
|
||||||
|
COLLECTION_TYPES,
|
||||||
|
ITEM_TYPES,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
IMAGE_TYPES,
|
||||||
|
GENRES,
|
||||||
|
getSectionTypeLabel,
|
||||||
|
getGenreNames
|
||||||
|
} from '$lib/constants.js';
|
||||||
|
|
||||||
|
export let section;
|
||||||
|
export let index;
|
||||||
|
export let total;
|
||||||
|
export let expanded = false;
|
||||||
|
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
$: genreNames = getGenreNames(section.Query?.GenreIds);
|
||||||
|
$: typeLabel = getSectionTypeLabel(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(', ');
|
||||||
|
|
||||||
|
// TagIds as a comma-separated string for editing
|
||||||
|
$: tagIdsStr = (section.Query?.TagIds || []).join(', ');
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{#if section.ParentItem}
|
||||||
|
<div class="boxset-info">
|
||||||
|
<span>{section.ParentItem.Name}</span>
|
||||||
|
<span class="text-muted">ID: {section.ParentItem.Id}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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}
|
||||||
|
</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}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.section-card.expanded {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
all: unset;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
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.5;
|
||||||
|
padding: 1px 2px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.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: 18px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.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: 2px;
|
||||||
|
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.5;
|
||||||
|
}
|
||||||
|
.remove-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.expand-indicator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor panel */
|
||||||
|
.section-editor {
|
||||||
|
padding: 4px 16px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.field-grid.checkboxes {
|
||||||
|
grid-template-columns: auto auto auto;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.field.span2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.hint-inline {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.field input[type='text'],
|
||||||
|
.field select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.chip-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.chip.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ids-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ids-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.id-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px 2px 8px;
|
||||||
|
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: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 5px;
|
||||||
|
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: 6px 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let config = { embyUrl: '', apiKey: '', dbPath: '' };
|
||||||
|
|
||||||
|
let localConfig = { ...config };
|
||||||
|
let status = '';
|
||||||
|
let statusType = ''; // 'ok' | 'error' | 'info'
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function setStatus(msg, type = 'info') {
|
||||||
|
status = msg;
|
||||||
|
statusType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
busy = true;
|
||||||
|
setStatus('Saving…');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(localConfig)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
config = { ...localConfig };
|
||||||
|
dispatch('configSaved', { ...localConfig });
|
||||||
|
setStatus('Settings saved.', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Save failed: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
if (!localConfig.embyUrl || !localConfig.apiKey) {
|
||||||
|
setStatus('Enter Emby URL and API key first.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
setStatus('Connecting to Emby…');
|
||||||
|
try {
|
||||||
|
// Save config first so the server endpoint can read it
|
||||||
|
await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(localConfig)
|
||||||
|
});
|
||||||
|
const res = await fetch('/api/emby-users');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||||
|
throw new Error(err.message || res.statusText);
|
||||||
|
}
|
||||||
|
const users = await res.json();
|
||||||
|
setStatus(`Connected — ${users.length} users found.`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Connection failed: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNames() {
|
||||||
|
busy = true;
|
||||||
|
setStatus('Fetching user names from Emby…');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/emby-users');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||||
|
throw new Error(err.message || res.statusText);
|
||||||
|
}
|
||||||
|
const embyUsers = await res.json();
|
||||||
|
dispatch('namesRefreshed', embyUsers);
|
||||||
|
setStatus(`Names refreshed — ${embyUsers.length} users from Emby.`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Failed: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromDb() {
|
||||||
|
if (!localConfig.dbPath) {
|
||||||
|
setStatus('Enter the DB path first.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
setStatus('Reading database…');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db-read', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dbPath: localConfig.dbPath })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||||
|
throw new Error(err.message || res.statusText);
|
||||||
|
}
|
||||||
|
const { users, validation } = await res.json();
|
||||||
|
dispatch('usersLoaded', { users, validation });
|
||||||
|
|
||||||
|
if (validation?.mismatchedUsers > 0 || validation?.missingSectionUserIds > 0) {
|
||||||
|
setStatus(
|
||||||
|
`Loaded ${users.length} users from ${validation.userSource}. ` +
|
||||||
|
`${validation.mismatchedUsers} user(s) had mismatched section UserIds and ` +
|
||||||
|
`${validation.normalizedUsers} user(s) were normalized on load.`,
|
||||||
|
'ok'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setStatus(
|
||||||
|
`Loaded ${users.length} users from ${validation?.userSource || 'database'}. ` +
|
||||||
|
'UserSettings IDs and section UserIds match.',
|
||||||
|
'ok'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Failed: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Settings</h3>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>Emby connection</h4>
|
||||||
|
<label>
|
||||||
|
<span>Server URL</span>
|
||||||
|
<input type="text" bind:value={localConfig.embyUrl} placeholder="http://localhost:8096" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>API key</span>
|
||||||
|
<input type="password" bind:value={localConfig.apiKey} placeholder="Paste your API key" />
|
||||||
|
</label>
|
||||||
|
<p class="hint">
|
||||||
|
Get your API key from Emby: Dashboard → Advanced → API Keys → New API Key.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save</button>
|
||||||
|
<button class="btn ghost" on:click={testConnection} disabled={busy}>Test connection</button>
|
||||||
|
<button class="btn ghost" on:click={refreshNames} disabled={busy || !config.apiKey}>
|
||||||
|
Refresh user names
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>Database file</h4>
|
||||||
|
<label>
|
||||||
|
<span>Path to users.db</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={localConfig.dbPath}
|
||||||
|
placeholder="C:\ProgramData\Emby-Server\data\users.db"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p class="hint">
|
||||||
|
Stop Emby before loading or writing to avoid corruption. Typical path:
|
||||||
|
<code>C:\ProgramData\Emby-Server\data\users.db</code>
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save path</button>
|
||||||
|
<button class="btn accent" on:click={loadFromDb} disabled={busy || !localConfig.dbPath}>
|
||||||
|
Load from DB
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if status}
|
||||||
|
<div class="status" class:ok={statusType === 'ok'} class:error={statusType === 'error'}>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
section:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
label span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn.accent {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn.accent:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
}
|
||||||
|
.status.ok {
|
||||||
|
border-left-color: #22c55e;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
export let sql = '';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let copied = false;
|
||||||
|
|
||||||
|
function copyToClipboard() {
|
||||||
|
navigator.clipboard.writeText(sql).then(() => {
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const blob = new Blob([sql], { type: 'text/sql' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `emby-homescreen-update-${new Date().toISOString().slice(0, 10)}.sql`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overlay" on:click|self={() => dispatch('close')} on:keydown={() => {}}>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Generated SQL</h3>
|
||||||
|
<button class="close-btn" on:click={() => dispatch('close')}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<pre class="sql-output">{sql}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="action-btn secondary" on:click={copyToClipboard}>
|
||||||
|
{copied ? '✓ Copied' : 'Copy to clipboard'}
|
||||||
|
</button>
|
||||||
|
<button class="action-btn primary" on:click={download}> Download .sql file </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.sql-output {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.action-btn.secondary {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.action-btn.secondary:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.action-btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.action-btn.primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { getSectionTypeLabel, getGenreNames } from '$lib/constants.js';
|
||||||
|
|
||||||
|
export let users = [];
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let sourceUserId = null;
|
||||||
|
let targetUserIds = [];
|
||||||
|
let selectedSectionIndices = [];
|
||||||
|
let syncMode = 'replace'; // 'replace' = overwrite all, 'append' = add to end, 'selected' = only selected sections
|
||||||
|
|
||||||
|
$: sourceUser = users.find((u) => u.id === sourceUserId);
|
||||||
|
$: sourceSections = sourceUser?.sections || [];
|
||||||
|
$: availableTargets = users.filter((u) => u.id !== sourceUserId && u.sections?.length >= 0);
|
||||||
|
|
||||||
|
function toggleTarget(id) {
|
||||||
|
if (targetUserIds.includes(id)) {
|
||||||
|
targetUserIds = targetUserIds.filter((t) => t !== id);
|
||||||
|
} else {
|
||||||
|
targetUserIds = [...targetUserIds, id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllTargets() {
|
||||||
|
targetUserIds = availableTargets.map((u) => u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAllTargets() {
|
||||||
|
targetUserIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSectionIndex(idx) {
|
||||||
|
if (selectedSectionIndices.includes(idx)) {
|
||||||
|
selectedSectionIndices = selectedSectionIndices.filter((i) => i !== idx);
|
||||||
|
} else {
|
||||||
|
selectedSectionIndices = [...selectedSectionIndices, idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSync() {
|
||||||
|
if (!sourceUser || targetUserIds.length === 0) return;
|
||||||
|
|
||||||
|
let sectionsToSync;
|
||||||
|
if (syncMode === 'selected') {
|
||||||
|
sectionsToSync = selectedSectionIndices.map((i) => sourceSections[i]).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
sectionsToSync = [...sourceSections];
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch('sync', {
|
||||||
|
sourceUserId,
|
||||||
|
targetUserIds,
|
||||||
|
sections: sectionsToSync,
|
||||||
|
mode: syncMode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sync-panel">
|
||||||
|
<h3>Sync sections between users</h3>
|
||||||
|
|
||||||
|
<div class="sync-step">
|
||||||
|
<span class="step-label">1. Source user</span>
|
||||||
|
<select bind:value={sourceUserId} class="select-input">
|
||||||
|
<option value={null}>Select source user...</option>
|
||||||
|
{#each users.filter((u) => u.sections?.length > 0) as user}
|
||||||
|
<option value={user.id}>{user.name} ({user.sections.length} sections)</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if sourceUser}
|
||||||
|
<div class="sync-step">
|
||||||
|
<span class="step-label">2. Sync mode</span>
|
||||||
|
<div class="mode-group">
|
||||||
|
<label class="mode-option" class:active={syncMode === 'replace'}>
|
||||||
|
<input type="radio" bind:group={syncMode} value="replace" />
|
||||||
|
<div>
|
||||||
|
<strong>Replace all</strong>
|
||||||
|
<span class="mode-desc">Overwrite target's sections entirely</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="mode-option" class:active={syncMode === 'append'}>
|
||||||
|
<input type="radio" bind:group={syncMode} value="append" />
|
||||||
|
<div>
|
||||||
|
<strong>Append all</strong>
|
||||||
|
<span class="mode-desc">Add source sections to end of target</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="mode-option" class:active={syncMode === 'selected'}>
|
||||||
|
<input type="radio" bind:group={syncMode} value="selected" />
|
||||||
|
<div>
|
||||||
|
<strong>Selected only</strong>
|
||||||
|
<span class="mode-desc">Pick specific sections to sync</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if syncMode === 'selected'}
|
||||||
|
<div class="sync-step">
|
||||||
|
<span class="step-label">Sections to sync</span>
|
||||||
|
<div class="section-pick-list">
|
||||||
|
{#each sourceSections as section, idx}
|
||||||
|
<label class="section-pick" class:selected={selectedSectionIndices.includes(idx)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSectionIndices.includes(idx)}
|
||||||
|
on:change={() => toggleSectionIndex(idx)}
|
||||||
|
/>
|
||||||
|
<span class="pick-name">{section.CustomName || section.Name || '(unnamed)'}</span>
|
||||||
|
<span class="pick-type">{getSectionTypeLabel(section.SectionType)}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sync-step">
|
||||||
|
<div class="step-label-row">
|
||||||
|
<span class="step-label">3. Target users</span>
|
||||||
|
<div class="select-btns">
|
||||||
|
<button class="link-btn" on:click={selectAllTargets}>All</button>
|
||||||
|
<button class="link-btn" on:click={deselectAllTargets}>None</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="target-list">
|
||||||
|
{#each availableTargets as user}
|
||||||
|
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={targetUserIds.includes(user.id)}
|
||||||
|
on:change={() => toggleTarget(user.id)}
|
||||||
|
/>
|
||||||
|
<span class="target-name">{user.name}</span>
|
||||||
|
<span class="target-count">{user.sections?.length || 0} sections</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sync-btn"
|
||||||
|
disabled={targetUserIds.length === 0 ||
|
||||||
|
(syncMode === 'selected' && selectedSectionIndices.length === 0)}
|
||||||
|
on:click={doSync}
|
||||||
|
>
|
||||||
|
Sync {syncMode === 'selected' ? `${selectedSectionIndices.length} section(s)` : 'all sections'}
|
||||||
|
to {targetUserIds.length} user{targetUserIds.length !== 1 ? 's' : ''}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sync-panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sync-step {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.step-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.step-label-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.select-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.mode-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.mode-option.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
.mode-option input {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.mode-option strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.mode-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-pick-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.section-pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.section-pick:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.section-pick.selected {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.pick-name {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.pick-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.target-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.target-option:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.target-option.selected {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.target-name {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.target-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.link-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.sync-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.sync-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
export const GENRES = {
|
||||||
|
'2042': 'Action',
|
||||||
|
'1212': 'Sci-Fi',
|
||||||
|
'4910': 'Crime',
|
||||||
|
'62': 'Drama',
|
||||||
|
'82': 'Comedy',
|
||||||
|
'293': 'Animation',
|
||||||
|
'36': 'Documentary',
|
||||||
|
'5024': 'Horror',
|
||||||
|
'4835': 'Romance',
|
||||||
|
'4428': 'Thriller',
|
||||||
|
'5709': 'War',
|
||||||
|
'14124': 'Western',
|
||||||
|
'218': 'Food',
|
||||||
|
'396654': 'Reality',
|
||||||
|
'19618': 'Travel',
|
||||||
|
'17565': 'Mini Series',
|
||||||
|
'2008': 'Mystery',
|
||||||
|
'235': 'Family',
|
||||||
|
'480': 'Fantasy'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SECTION_TYPES = [
|
||||||
|
{ value: 'resume', label: 'Resume / Next Up' },
|
||||||
|
{ value: 'items', label: 'Items (filtered)' },
|
||||||
|
{ value: 'userviews', label: 'Libraries' },
|
||||||
|
{ value: 'boxset', label: 'Box Set' },
|
||||||
|
{ value: 'collections', label: 'Collections' },
|
||||||
|
{ value: 'latestepisodereleases', label: 'Latest episode releases' },
|
||||||
|
{ value: 'latestmoviereleases', label: 'Latest movie releases' },
|
||||||
|
{ value: 'latestmediablock', label: 'Latest media' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COLLECTION_TYPES = [
|
||||||
|
{ value: '', label: '(none)' },
|
||||||
|
{ value: 'movies', label: 'Movies' },
|
||||||
|
{ value: 'tvshows', label: 'TV Shows' },
|
||||||
|
{ value: 'boxsets', label: 'Box Sets' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ITEM_TYPES = ['Movie', 'Series', 'Episode', 'BoxSet'];
|
||||||
|
|
||||||
|
export const SORT_OPTIONS = [
|
||||||
|
{ value: '', label: '(none)' },
|
||||||
|
{ value: 'default', label: 'Default (boxset)' },
|
||||||
|
{ value: 'DateLastContentAdded,SortName', label: 'Date added' },
|
||||||
|
{ value: 'ProductionYear,PremiereDate,SortName', label: 'Release year' },
|
||||||
|
{ value: 'CommunityRating', label: 'Community rating' },
|
||||||
|
{ value: 'CriticRating,SortName', label: 'Critic rating' },
|
||||||
|
{ value: 'DateCreated,SortName', label: 'Date created' },
|
||||||
|
{ value: 'Random', label: 'Random' },
|
||||||
|
{ value: 'SortName', label: 'Name' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const IMAGE_TYPES = [
|
||||||
|
{ value: '', label: 'Default' },
|
||||||
|
{ value: 'Thumb', label: 'Thumb' },
|
||||||
|
{ value: 'Primary', label: 'Primary / Poster' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function genId() {
|
||||||
|
return crypto.randomUUID().replace(/-/g, '').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptySection(userId) {
|
||||||
|
return {
|
||||||
|
UserId: userId,
|
||||||
|
Name: 'New Section',
|
||||||
|
CustomName: 'New Section',
|
||||||
|
Id: genId(),
|
||||||
|
SectionType: 'items',
|
||||||
|
ImageType: 'Thumb',
|
||||||
|
CollectionType: 'movies',
|
||||||
|
SortBy: 'DateLastContentAdded,SortName',
|
||||||
|
SortOrder: 'Descending',
|
||||||
|
Monitor: [],
|
||||||
|
ItemTypes: ['Movie'],
|
||||||
|
ExcludedFolders: [],
|
||||||
|
CardSizeOffset: 0,
|
||||||
|
IncludeNextUpInResume: true,
|
||||||
|
Query: {
|
||||||
|
StudioIds: [],
|
||||||
|
TagIds: [],
|
||||||
|
GenreIds: [],
|
||||||
|
CollectionTypes: [],
|
||||||
|
IsPlayed: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSectionTypeLabel(type) {
|
||||||
|
const found = SECTION_TYPES.find((t) => t.value === type);
|
||||||
|
return found ? found.label : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGenreNames(genreIds) {
|
||||||
|
if (!genreIds || genreIds.length === 0) return '';
|
||||||
|
return genreIds.map((id) => GENRES[id] || id).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractUserName(sections) {
|
||||||
|
for (const s of sections) {
|
||||||
|
const name = s.CustomName || s.Name || '';
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower.includes('watchlist')) {
|
||||||
|
const extracted = name
|
||||||
|
.replace(/['']s\s*Watchlist/i, '')
|
||||||
|
.replace(/\s*Watchlist/i, '')
|
||||||
|
.trim();
|
||||||
|
if (extracted && extracted !== name) return extracted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWatchlistSection(section) {
|
||||||
|
if (!section) return false;
|
||||||
|
|
||||||
|
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
|
||||||
|
return !!section.Query?.IsFavorite || name.includes('watchlist') || name.includes('watch list');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameWatchlistLabel(label, targetName) {
|
||||||
|
if (!label || !targetName) return label;
|
||||||
|
if (/^\s*watch\s*list\s*$/i.test(label)) return 'Watch List';
|
||||||
|
if (/^\s*watchlist\s*$/i.test(label)) return 'Watchlist';
|
||||||
|
|
||||||
|
if (/watch\s*list/i.test(label)) {
|
||||||
|
return label.replace(/^.*?watch\s*list/i, `${targetName}'s Watch List`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/watchlist/i.test(label)) {
|
||||||
|
return label.replace(/^.*?watchlist/i, `${targetName}'s Watchlist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWatchlistLabelsForTarget(sourceSection, targetUser) {
|
||||||
|
const existingTargetWatchlist = targetUser?.sections?.find((section) => isWatchlistSection(section));
|
||||||
|
|
||||||
|
if (existingTargetWatchlist) {
|
||||||
|
return {
|
||||||
|
Name: existingTargetWatchlist.Name || sourceSection.Name,
|
||||||
|
CustomName: existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || sourceSection.CustomName || sourceSection.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetName = targetUser?.name || '';
|
||||||
|
return {
|
||||||
|
Name: renameWatchlistLabel(sourceSection.Name, targetName),
|
||||||
|
CustomName: renameWatchlistLabel(sourceSection.CustomName, targetName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL UPDATE statements from modified user data.
|
||||||
|
* Each user's entire homescreensettings JSON is replaced.
|
||||||
|
*/
|
||||||
|
export function generateSQL(users, originalUsers) {
|
||||||
|
const statements = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (!user.sections || user.sections.length === 0) continue;
|
||||||
|
|
||||||
|
const original = originalUsers.find((u) => u.id === user.id);
|
||||||
|
if (!original) continue;
|
||||||
|
|
||||||
|
const origJSON = JSON.stringify({ Sections: original.sections });
|
||||||
|
const newJSON = JSON.stringify({ Sections: user.sections });
|
||||||
|
|
||||||
|
if (origJSON === newJSON) continue;
|
||||||
|
|
||||||
|
const escapedValue = newJSON.replace(/'/g, "''");
|
||||||
|
statements.push(
|
||||||
|
`-- User: ${user.name} (DB ID: ${user.id})`,
|
||||||
|
`UPDATE UserSettings SET Value = '${escapedValue}' WHERE UserId = ${user.id} AND UserSettingsKeyId = (SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings');`,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statements.length === 0) {
|
||||||
|
return '-- No changes detected';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'-- ===========================================',
|
||||||
|
'-- Emby Home Screen Settings Update',
|
||||||
|
`-- Generated: ${new Date().toISOString()}`,
|
||||||
|
'-- ===========================================',
|
||||||
|
'-- IMPORTANT: Stop Emby before running this!',
|
||||||
|
'-- sqlite3 /path/to/users.db < this_file.sql',
|
||||||
|
'-- Then restart Emby.',
|
||||||
|
'-- ===========================================',
|
||||||
|
'',
|
||||||
|
'BEGIN TRANSACTION;',
|
||||||
|
'',
|
||||||
|
...statements,
|
||||||
|
'COMMIT;'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
function parseJsonBlob(blob) {
|
||||||
|
if (!blob) return null;
|
||||||
|
try {
|
||||||
|
const text = typeof blob === 'string' ? blob : Buffer.from(blob).toString('utf8');
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emby stores GUIDs in SQLite as 16-byte blobs using Microsoft mixed-endian ordering.
|
||||||
|
* Components 1-3 are little-endian; components 4-5 are big-endian.
|
||||||
|
*/
|
||||||
|
export function blobToEmbyGuid(blob) {
|
||||||
|
if (!blob) return '';
|
||||||
|
const b = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
|
||||||
|
if (b.length !== 16) return Buffer.from(b).toString('hex').toLowerCase();
|
||||||
|
const out = new Uint8Array([
|
||||||
|
b[3], b[2], b[1], b[0],
|
||||||
|
b[5], b[4],
|
||||||
|
b[7], b[6],
|
||||||
|
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]
|
||||||
|
]);
|
||||||
|
return Buffer.from(out).toString('hex').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTable(db, tableName) {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||||
|
.get(tableName);
|
||||||
|
return !!row?.found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSectionUserId(sectionUserId) {
|
||||||
|
return typeof sectionUserId === 'string' ? sectionUserId.trim().toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSectionsForUser(sections, expectedEmbyGuid) {
|
||||||
|
if (!Array.isArray(sections)) return [];
|
||||||
|
if (!expectedEmbyGuid) return sections;
|
||||||
|
|
||||||
|
return sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
UserId: expectedEmbyGuid
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsersTableUsers(db) {
|
||||||
|
const cols = db.prepare('PRAGMA table_info(Users)').all().map((c) => c.name);
|
||||||
|
const nameCol = cols.find((c) => /^username$/i.test(c)) || cols.find((c) => /^name$/i.test(c));
|
||||||
|
const guidCol = cols.find((c) => /^guid$/i.test(c));
|
||||||
|
const idCol = cols.find((c) => /^id$/i.test(c)) || 'Id';
|
||||||
|
|
||||||
|
if (!nameCol) {
|
||||||
|
throw new Error(`Cannot find a name column in Users table. Columns found: ${cols.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCols = [idCol, nameCol, guidCol].filter(Boolean).join(', ');
|
||||||
|
const rows = db.prepare(`SELECT ${selectCols} FROM Users`).all();
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const rawId = row[idCol] ?? row.Id ?? row.id;
|
||||||
|
const rawGuid = guidCol ? row[guidCol] : null;
|
||||||
|
let embyGuid = '';
|
||||||
|
let guid = '';
|
||||||
|
|
||||||
|
if (rawGuid) {
|
||||||
|
if (rawGuid instanceof Uint8Array || rawGuid instanceof Buffer) {
|
||||||
|
const buf = Buffer.from(rawGuid);
|
||||||
|
guid = buf.toString('hex').toUpperCase();
|
||||||
|
embyGuid = blobToEmbyGuid(rawGuid);
|
||||||
|
} else if (typeof rawGuid === 'string') {
|
||||||
|
const clean = rawGuid.replace(/-/g, '').toLowerCase();
|
||||||
|
embyGuid = clean;
|
||||||
|
guid = clean.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rawId,
|
||||||
|
name: row[nameCol] || `User ${rawId}`,
|
||||||
|
guid,
|
||||||
|
embyGuid,
|
||||||
|
sourceTable: 'Users'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalUsers(db) {
|
||||||
|
const rows = db.prepare('SELECT Id, guid, data FROM LocalUsersv2').all();
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const parsed = parseJsonBlob(row.data);
|
||||||
|
const guid = row.guid ? Buffer.from(row.guid).toString('hex').toUpperCase() : '';
|
||||||
|
const embyGuidFromBlob = blobToEmbyGuid(row.guid);
|
||||||
|
const embyGuidFromJson = normalizeSectionUserId(parsed?.IdString);
|
||||||
|
const embyGuid = embyGuidFromJson || embyGuidFromBlob;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.Id,
|
||||||
|
name: parsed?.Name || `User ${row.Id}`,
|
||||||
|
guid,
|
||||||
|
embyGuid,
|
||||||
|
sourceTable: 'LocalUsersv2',
|
||||||
|
profile: parsed || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCanonicalUsers(db) {
|
||||||
|
if (hasTable(db, 'LocalUsersv2')) {
|
||||||
|
return loadLocalUsers(db);
|
||||||
|
}
|
||||||
|
if (hasTable(db, 'Users')) {
|
||||||
|
return loadUsersTableUsers(db);
|
||||||
|
}
|
||||||
|
throw new Error('No supported user table found. Expected LocalUsersv2 or Users.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHomeScreenUsers(db) {
|
||||||
|
const users = loadCanonicalUsers(db);
|
||||||
|
const settingsRows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT us.UserId, us.Value
|
||||||
|
FROM UserSettings us
|
||||||
|
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
|
||||||
|
WHERE usk.Name = 'homescreensettings'`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const settingsMap = new Map(settingsRows.map((row) => [String(row.UserId), row.Value]));
|
||||||
|
const userIds = new Set(users.map((user) => String(user.id)));
|
||||||
|
|
||||||
|
let matchedUsers = 0;
|
||||||
|
let mismatchedUsers = 0;
|
||||||
|
let normalizedUsers = 0;
|
||||||
|
let missingSectionUserIds = 0;
|
||||||
|
|
||||||
|
const hydratedUsers = users.map((user) => {
|
||||||
|
const rawValue = settingsMap.get(String(user.id));
|
||||||
|
let sections = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (rawValue) sections = JSON.parse(rawValue).Sections || [];
|
||||||
|
} catch {
|
||||||
|
sections = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualUserIds = [...new Set(sections.map((section) => normalizeSectionUserId(section?.UserId)).filter(Boolean))];
|
||||||
|
const mismatchedSectionUserIds = user.embyGuid
|
||||||
|
? actualUserIds.filter((id) => id !== user.embyGuid)
|
||||||
|
: actualUserIds;
|
||||||
|
const missingIdsForUser = sections.filter((section) => !normalizeSectionUserId(section?.UserId)).length;
|
||||||
|
const normalizedSections = normalizeSectionsForUser(sections, user.embyGuid);
|
||||||
|
const sectionsWereNormalized =
|
||||||
|
user.embyGuid &&
|
||||||
|
JSON.stringify(sections) !== JSON.stringify(normalizedSections);
|
||||||
|
|
||||||
|
if (mismatchedSectionUserIds.length === 0) matchedUsers++;
|
||||||
|
else mismatchedUsers++;
|
||||||
|
if (sectionsWereNormalized) normalizedUsers++;
|
||||||
|
missingSectionUserIds += missingIdsForUser;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
guid: user.guid,
|
||||||
|
embyGuid: user.embyGuid,
|
||||||
|
sections: normalizedSections,
|
||||||
|
match: {
|
||||||
|
sourceTable: user.sourceTable,
|
||||||
|
settingsUserId: user.id,
|
||||||
|
expectedSectionUserId: user.embyGuid,
|
||||||
|
actualSectionUserIds: actualUserIds,
|
||||||
|
mismatchedSectionUserIds,
|
||||||
|
missingSectionUserIds: missingIdsForUser,
|
||||||
|
ok: mismatchedSectionUserIds.length === 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const orphanedSettingsUserIds = settingsRows
|
||||||
|
.map((row) => String(row.UserId))
|
||||||
|
.filter((userId, index, all) => all.indexOf(userId) === index && !userIds.has(userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: hydratedUsers,
|
||||||
|
validation: {
|
||||||
|
userSource: users[0]?.sourceTable || null,
|
||||||
|
userCount: hydratedUsers.length,
|
||||||
|
settingsCount: settingsRows.length,
|
||||||
|
matchedUsers,
|
||||||
|
mismatchedUsers,
|
||||||
|
normalizedUsers,
|
||||||
|
missingSectionUserIds,
|
||||||
|
orphanedSettingsUserIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadUserLookup(db) {
|
||||||
|
return new Map(loadCanonicalUsers(db).map((user) => [String(user.id), user]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
/** @type {import('./$types').PageServerLoad} */
|
||||||
|
export async function load() {
|
||||||
|
const dataPath = resolve('static/db_export.json');
|
||||||
|
const raw = readFileSync(dataPath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
const configPath = resolve('config.json');
|
||||||
|
let config = { embyUrl: '', apiKey: '', dbPath: '' };
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
try {
|
||||||
|
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
} catch { /* use defaults */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: data.users,
|
||||||
|
genres: data.genres,
|
||||||
|
enums: data.enums,
|
||||||
|
config
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
<script>
|
||||||
|
import SectionCard from '$lib/SectionCard.svelte';
|
||||||
|
import SyncPanel from '$lib/SyncPanel.svelte';
|
||||||
|
import SqlModal from '$lib/SqlModal.svelte';
|
||||||
|
import SettingsPanel from '$lib/SettingsPanel.svelte';
|
||||||
|
import { createEmptySection, generateSQL, getWatchlistLabelsForTarget, isWatchlistSection } from '$lib/constants.js';
|
||||||
|
|
||||||
|
/** @type {import('./$types').PageData} */
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let users = JSON.parse(JSON.stringify(data.users));
|
||||||
|
let originalUsers = JSON.parse(JSON.stringify(data.users));
|
||||||
|
let config = { ...data.config };
|
||||||
|
|
||||||
|
let selectedUserId = users.find((u) => u.sections?.length > 0)?.id || null;
|
||||||
|
let expandedIndex = -1;
|
||||||
|
let activeTab = 'edit'; // 'edit' | 'sync' | 'settings'
|
||||||
|
let showSqlModal = false;
|
||||||
|
let generatedSql = '';
|
||||||
|
let changeCount = 0;
|
||||||
|
let writeStatus = ''; // '' | 'writing' | 'ok' | 'error'
|
||||||
|
let writeMessage = '';
|
||||||
|
let dbValidation = null;
|
||||||
|
|
||||||
|
$: selectedUser = users.find((u) => u.id === selectedUserId);
|
||||||
|
$: sections = selectedUser?.sections || [];
|
||||||
|
$: dbConfigured = !!config.dbPath;
|
||||||
|
|
||||||
|
function moveSection(index, dir) {
|
||||||
|
const newIndex = index + dir;
|
||||||
|
if (newIndex < 0 || newIndex >= sections.length) return;
|
||||||
|
const arr = [...sections];
|
||||||
|
[arr[index], arr[newIndex]] = [arr[newIndex], arr[index]];
|
||||||
|
selectedUser.sections = arr;
|
||||||
|
expandedIndex = newIndex;
|
||||||
|
users = users;
|
||||||
|
changeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSection(index) {
|
||||||
|
selectedUser.sections = sections.filter((_, i) => i !== index);
|
||||||
|
if (expandedIndex === index) expandedIndex = -1;
|
||||||
|
else if (expandedIndex > index) expandedIndex--;
|
||||||
|
users = users;
|
||||||
|
changeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSection() {
|
||||||
|
const embyGuid = selectedUser.embyGuid || '';
|
||||||
|
const newSection = createEmptySection(embyGuid);
|
||||||
|
selectedUser.sections = [...sections, newSection];
|
||||||
|
expandedIndex = sections.length - 1;
|
||||||
|
users = users;
|
||||||
|
changeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSync(event) {
|
||||||
|
const { sourceUserId, targetUserIds, sections: syncSections, mode } = event.detail;
|
||||||
|
const source = users.find((u) => u.id === sourceUserId);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
for (const targetId of targetUserIds) {
|
||||||
|
const target = users.find((u) => u.id === targetId);
|
||||||
|
if (!target) continue;
|
||||||
|
|
||||||
|
const cloned = JSON.parse(JSON.stringify(syncSections)).map((s) => {
|
||||||
|
s.UserId = target.embyGuid || '';
|
||||||
|
s.Id = crypto.randomUUID().replace(/-/g, '').slice(0, 32);
|
||||||
|
|
||||||
|
if (isWatchlistSection(s)) {
|
||||||
|
const labels = getWatchlistLabelsForTarget(s, target);
|
||||||
|
if (labels.Name) s.Name = labels.Name;
|
||||||
|
if (labels.CustomName) s.CustomName = labels.CustomName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'replace') {
|
||||||
|
target.sections = cloned;
|
||||||
|
} else {
|
||||||
|
target.sections = [...(target.sections || []), ...cloned];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
users = users;
|
||||||
|
changeCount += targetUserIds.length;
|
||||||
|
activeTab = 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSQL() {
|
||||||
|
generatedSql = generateSQL(users, originalUsers);
|
||||||
|
showSqlModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
if (confirm('Reset all changes? This cannot be undone.')) {
|
||||||
|
users = JSON.parse(JSON.stringify(originalUsers));
|
||||||
|
changeCount = 0;
|
||||||
|
expandedIndex = -1;
|
||||||
|
writeStatus = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionChange() {
|
||||||
|
users = users;
|
||||||
|
changeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the list of changed users for db-write
|
||||||
|
function buildChanges() {
|
||||||
|
const changes = [];
|
||||||
|
for (const user of users) {
|
||||||
|
if (!user.sections || user.sections.length === 0) continue;
|
||||||
|
const original = originalUsers.find((u) => u.id === user.id);
|
||||||
|
if (!original) continue;
|
||||||
|
const origJSON = JSON.stringify({ Sections: original.sections });
|
||||||
|
const newJSON = JSON.stringify({ Sections: user.sections });
|
||||||
|
if (origJSON !== newJSON) {
|
||||||
|
changes.push({ userId: user.id, sections: user.sections, name: user.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeToDb() {
|
||||||
|
const changes = buildChanges();
|
||||||
|
if (!changes.length) return;
|
||||||
|
|
||||||
|
const names = changes.map((c) => c.name).join(', ');
|
||||||
|
const confirmed = confirm(
|
||||||
|
`This will write changes for ${changes.length} user(s) directly to the database:\n\n${names}\n\nIMPORTANT: Emby must be stopped before continuing.\n\nProceed?`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
writeStatus = 'writing';
|
||||||
|
writeMessage = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db-write', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dbPath: config.dbPath, changes })
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) throw new Error(body.message || body.error || res.statusText);
|
||||||
|
writeStatus = 'ok';
|
||||||
|
writeMessage = `Wrote ${body.count} user(s) to database.${body.normalizedSections ? ` Normalized ${body.normalizedSections} section UserId values.` : ''}`;
|
||||||
|
// Sync originalUsers so change counter resets
|
||||||
|
originalUsers = JSON.parse(JSON.stringify(users));
|
||||||
|
changeCount = 0;
|
||||||
|
} catch (e) {
|
||||||
|
writeStatus = 'error';
|
||||||
|
writeMessage = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsPanel event handlers
|
||||||
|
function onConfigSaved(e) {
|
||||||
|
config = { ...e.detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNamesRefreshed(e) {
|
||||||
|
const embyUsers = e.detail; // [{ embyGuid, name }]
|
||||||
|
const nameMap = Object.fromEntries(embyUsers.map((u) => [u.embyGuid, u.name]));
|
||||||
|
users = users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
name: (u.embyGuid && nameMap[u.embyGuid]) ? nameMap[u.embyGuid] : u.name
|
||||||
|
}));
|
||||||
|
originalUsers = originalUsers.map((u) => ({
|
||||||
|
...u,
|
||||||
|
name: (u.embyGuid && nameMap[u.embyGuid]) ? nameMap[u.embyGuid] : u.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUsersLoaded(e) {
|
||||||
|
const payload = Array.isArray(e.detail) ? { users: e.detail, validation: null } : e.detail;
|
||||||
|
const freshUsers = payload.users || [];
|
||||||
|
users = JSON.parse(JSON.stringify(freshUsers));
|
||||||
|
originalUsers = JSON.parse(JSON.stringify(freshUsers));
|
||||||
|
dbValidation = payload.validation || null;
|
||||||
|
changeCount = 0;
|
||||||
|
expandedIndex = -1;
|
||||||
|
writeStatus = '';
|
||||||
|
selectedUserId = users.find((u) => u.sections?.length > 0)?.id || null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Emby home screen editor</h1>
|
||||||
|
<span class="subtitle">{users.length} users · {users.reduce((n, u) => n + (u.sections?.length || 0), 0)} total sections</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
{#if writeStatus === 'ok'}
|
||||||
|
<span class="write-status ok">{writeMessage}</span>
|
||||||
|
{:else if writeStatus === 'error'}
|
||||||
|
<span class="write-status error" title={writeMessage}>Write failed</span>
|
||||||
|
{/if}
|
||||||
|
{#if dbValidation && dbValidation.userSource}
|
||||||
|
<span class="db-status" title={`${dbValidation.matchedUsers} matched, ${dbValidation.mismatchedUsers} mismatched, ${dbValidation.normalizedUsers} normalized on load`}>
|
||||||
|
{dbValidation.userSource} linked
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if changeCount > 0}
|
||||||
|
<button class="btn ghost" on:click={resetAll}>Reset</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn ghost" on:click={showSQL} disabled={changeCount === 0}>
|
||||||
|
Generate SQL {changeCount > 0 ? `(${changeCount})` : ''}
|
||||||
|
</button>
|
||||||
|
{#if dbConfigured}
|
||||||
|
<button class="btn primary" on:click={writeToDb} disabled={changeCount === 0 || writeStatus === 'writing'}>
|
||||||
|
{writeStatus === 'writing' ? 'Writing…' : `Write to DB${changeCount > 0 ? ` (${changeCount})` : ''}`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn icon"
|
||||||
|
class:active={activeTab === 'settings'}
|
||||||
|
on:click={() => (activeTab = activeTab === 'settings' ? 'edit' : 'settings')}
|
||||||
|
title="Settings"
|
||||||
|
>⚙</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<!-- Left sidebar: user list + tabs -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab" class:active={activeTab === 'edit'} on:click={() => (activeTab = 'edit')}>Edit</button>
|
||||||
|
<button class="tab" class:active={activeTab === 'sync'} on:click={() => (activeTab = 'sync')}>Sync</button>
|
||||||
|
<button class="tab" class:active={activeTab === 'settings'} on:click={() => (activeTab = 'settings')}>Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === 'edit' || activeTab === 'sync'}
|
||||||
|
<div class="user-list">
|
||||||
|
{#each users.filter((u) => u.sections?.length > 0) as user}
|
||||||
|
<button
|
||||||
|
class="user-item"
|
||||||
|
class:active={selectedUserId === user.id}
|
||||||
|
on:click={() => {
|
||||||
|
selectedUserId = user.id;
|
||||||
|
expandedIndex = -1;
|
||||||
|
activeTab = 'edit';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="user-name">{user.name}</span>
|
||||||
|
<span class="user-count">{user.sections.length}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if users.some((u) => !u.sections || u.sections.length === 0)}
|
||||||
|
<div class="user-divider">No sections configured</div>
|
||||||
|
{#each users.filter((u) => !u.sections || u.sections.length === 0) as user}
|
||||||
|
<button
|
||||||
|
class="user-item dim"
|
||||||
|
class:active={selectedUserId === user.id}
|
||||||
|
on:click={() => {
|
||||||
|
selectedUserId = user.id;
|
||||||
|
expandedIndex = -1;
|
||||||
|
activeTab = 'edit';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="user-name">{user.name}</span>
|
||||||
|
<span class="user-count">0</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeTab === 'settings'}
|
||||||
|
<div class="settings-container">
|
||||||
|
<SettingsPanel
|
||||||
|
{config}
|
||||||
|
on:configSaved={onConfigSaved}
|
||||||
|
on:namesRefreshed={onNamesRefreshed}
|
||||||
|
on:usersLoaded={onUsersLoaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="main">
|
||||||
|
{#if activeTab === 'edit' && selectedUser}
|
||||||
|
<div class="main-header">
|
||||||
|
<h2>{selectedUser.name}'s home screen</h2>
|
||||||
|
<button class="btn small accent" on:click={addSection}>+ Add section</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if sections.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No sections configured for this user.</p>
|
||||||
|
<button class="btn accent" on:click={addSection}>Add first section</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="section-list">
|
||||||
|
{#each sections as section, i (section.Id + '-' + i)}
|
||||||
|
<SectionCard
|
||||||
|
{section}
|
||||||
|
index={i}
|
||||||
|
total={sections.length}
|
||||||
|
genres={data.genres}
|
||||||
|
expanded={expandedIndex === i}
|
||||||
|
on:toggle={() => (expandedIndex = expandedIndex === i ? -1 : i)}
|
||||||
|
on:move={(e) => moveSection(i, e.detail)}
|
||||||
|
on:remove={() => removeSection(i)}
|
||||||
|
on:change={onSectionChange}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if activeTab === 'sync'}
|
||||||
|
<div class="sync-info">
|
||||||
|
<h2>Section sync</h2>
|
||||||
|
<p>
|
||||||
|
Use the panel on the left to copy home screen sections from one user to others.
|
||||||
|
You can replace their entire layout, append sections, or pick specific ones.
|
||||||
|
</p>
|
||||||
|
<p>After syncing, switch to the Edit tab to review changes per user.</p>
|
||||||
|
<SyncPanel {users} on:sync={handleSync} />
|
||||||
|
</div>
|
||||||
|
{:else if activeTab === 'settings'}
|
||||||
|
<div class="settings-info">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<p>Configure your Emby server connection and database path in the panel on the left.</p>
|
||||||
|
<div class="settings-cards">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="card-title">Emby API</div>
|
||||||
|
<div class="card-body">
|
||||||
|
Connect to your Emby server to fetch real user names. Without this, users
|
||||||
|
without a recognisable name pattern will show as "User 11", "User 14" etc.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="card-title">Load from DB</div>
|
||||||
|
<div class="card-body">
|
||||||
|
Read the live <code>users.db</code> directly instead of using the static
|
||||||
|
<code>db_export.json</code> snapshot. Stop Emby first to avoid a locked database.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="card-title">Write to DB</div>
|
||||||
|
<div class="card-body">
|
||||||
|
Apply your changes directly to the database — only the
|
||||||
|
<code>homescreensettings</code> rows for users you modified are touched.
|
||||||
|
Stop Emby first. The "Generate SQL" button is still available as a fallback.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Select a user from the sidebar to edit their home screen.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSqlModal}
|
||||||
|
<SqlModal sql={generatedSql} on:close={() => (showSqlModal = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(*) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--bg: #0f1114;
|
||||||
|
--surface: #1a1d22;
|
||||||
|
--surface-hover: #22262d;
|
||||||
|
--border: #2a2e36;
|
||||||
|
--text: #e4e4e7;
|
||||||
|
--text-muted: #71717a;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--danger: #ef4444;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.write-status {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.db-status {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
.write-status.ok {
|
||||||
|
background: color-mix(in srgb, #22c55e 15%, transparent);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.write-status.error {
|
||||||
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||||
|
color: #fca5a5;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.btn.primary:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn.ghost:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.btn.accent {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn.accent:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.btn.small {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.btn.icon {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.btn.icon:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn.icon.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User list */
|
||||||
|
.user-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.user-item {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.user-item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.user-item.active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
.user-item.dim {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.user-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.user-divider {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 12px 12px 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings sidebar */
|
||||||
|
.settings-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main area */
|
||||||
|
.main-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.section-list {
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync */
|
||||||
|
.sync-info {
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
.sync-info > p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings info panel */
|
||||||
|
.settings-info {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.settings-info > p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.settings-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.info-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.card-body code {
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const CONFIG_PATH = resolve('config.json');
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
if (!existsSync(CONFIG_PATH)) return { embyUrl: '', apiKey: '', dbPath: '' };
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return { embyUrl: '', apiKey: '', dbPath: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return json(loadConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const body = await request.json();
|
||||||
|
const config = {
|
||||||
|
embyUrl: String(body.embyUrl || '').trim(),
|
||||||
|
apiKey: String(body.apiKey || '').trim(),
|
||||||
|
dbPath: String(body.dbPath || '').trim()
|
||||||
|
};
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { loadHomeScreenUsers } from '../../../lib/server/emby-user-db.js';
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const { dbPath } = await request.json();
|
||||||
|
if (!dbPath) throw error(400, 'No dbPath provided');
|
||||||
|
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
|
||||||
|
|
||||||
|
let db;
|
||||||
|
try {
|
||||||
|
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||||
|
const result = loadHomeScreenUsers(db);
|
||||||
|
db.close();
|
||||||
|
return json(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (db) try { db.close(); } catch { /* ignore */ }
|
||||||
|
throw error(500, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { loadUserLookup, normalizeSectionsForUser } from '../../../lib/server/emby-user-db.js';
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const { dbPath, changes } = await request.json();
|
||||||
|
if (!dbPath) throw error(400, 'No dbPath provided');
|
||||||
|
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
|
||||||
|
if (!changes?.length) return json({ ok: true, count: 0 });
|
||||||
|
|
||||||
|
let db;
|
||||||
|
try {
|
||||||
|
db = new DatabaseSync(dbPath);
|
||||||
|
const userLookup = loadUserLookup(db);
|
||||||
|
|
||||||
|
const keyRow = db
|
||||||
|
.prepare("SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'")
|
||||||
|
.get();
|
||||||
|
if (!keyRow) throw new Error("'homescreensettings' key not found in UserSettingsKeys table");
|
||||||
|
const keyId = keyRow.UserSettingsKeyId;
|
||||||
|
|
||||||
|
const checkStmt = db.prepare(
|
||||||
|
'SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?'
|
||||||
|
);
|
||||||
|
const updateStmt = db.prepare(
|
||||||
|
'UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?'
|
||||||
|
);
|
||||||
|
const insertStmt = db.prepare(
|
||||||
|
'INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let normalizedSections = 0;
|
||||||
|
|
||||||
|
// node:sqlite transactions: use db.exec('BEGIN') / db.exec('COMMIT') manually
|
||||||
|
// or wrap in a function with db.transaction() if supported
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const { userId, sections } of changes) {
|
||||||
|
const user = userLookup.get(String(userId));
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`UserId ${userId} does not exist in ${dbPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSections = normalizeSectionsForUser(sections, user.embyGuid);
|
||||||
|
if (JSON.stringify(nextSections) !== JSON.stringify(sections)) {
|
||||||
|
normalizedSections += nextSections.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = JSON.stringify({ Sections: nextSections });
|
||||||
|
const exists = checkStmt.get(userId, keyId);
|
||||||
|
if (exists) {
|
||||||
|
updateStmt.run(value, userId, keyId);
|
||||||
|
} else {
|
||||||
|
insertStmt.run(userId, keyId, value);
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
return json({ ok: true, count, normalizedSections });
|
||||||
|
} catch (err) {
|
||||||
|
if (db) try { db.close(); } catch { /* ignore */ }
|
||||||
|
throw error(500, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const CONFIG_PATH = resolve('config.json');
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
if (!existsSync(CONFIG_PATH)) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { embyUrl, apiKey } = loadConfig();
|
||||||
|
if (!embyUrl || !apiKey) {
|
||||||
|
throw error(400, 'Emby URL and API key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = embyUrl.replace(/\/+$/, '');
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${base}/Users?api_key=${encodeURIComponent(apiKey)}`);
|
||||||
|
} catch (e) {
|
||||||
|
throw error(502, `Could not reach Emby server: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw error(res.status, `Emby API error: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await res.json();
|
||||||
|
// Return embyGuid (no dashes, lowercase) -> name mapping
|
||||||
|
return json(
|
||||||
|
users.map((u) => ({
|
||||||
|
embyGuid: u.Id.replace(/-/g, '').toLowerCase(),
|
||||||
|
name: u.Name
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user