Når redirect_feed er satt i podcast-trait, returnerer maskinrommet HTTP 301 Moved Permanently med Location-header i stedet for å serve feeden. iTunes new-feed-url-taggen bevares også i RSS-en for klienter som ikke følger 301. Admin-UI: erstatter det enkle tekstfeltet med tre tilstander: - Inaktiv: knapp "Flytt podcast til annen plattform..." - Bekreftelse: advarsel + URL-felt + rød "Aktiver redirect"-knapp - Aktiv: gul statusindikator med deaktiver-knapp Backend: sjekker redirect_feed tidlig i generate_feed() og returnerer 301 før noe annet arbeid gjøres (DB-oppslag for episodes osv).
264 lines
9.6 KiB
Svelte
264 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* Dedicated admin UI for podcast trait configuration.
|
|
* Provides proper form fields for iTunes/podcast metadata
|
|
* instead of generic key-value editing.
|
|
*/
|
|
|
|
interface Props {
|
|
config: Record<string, unknown>;
|
|
onchange: (config: Record<string, unknown>) => void;
|
|
}
|
|
|
|
let { config, onchange }: Props = $props();
|
|
|
|
// Local state initialized from config
|
|
let itunesAuthor = $state((config.itunes_author as string) ?? '');
|
|
let itunesCategory = $state((config.itunes_category as string) ?? '');
|
|
let itunesSubcategory = $state((config.itunes_subcategory as string) ?? '');
|
|
let explicit = $state((config.explicit as boolean) ?? false);
|
|
let language = $state((config.language as string) ?? 'no');
|
|
let redirectFeed = $state((config.redirect_feed as string) ?? '');
|
|
let redirectInput = $state('');
|
|
let showRedirectConfirm = $state(false);
|
|
|
|
// iTunes category tree (Apple Podcasts standard)
|
|
const itunesCategories: Record<string, string[]> = {
|
|
'Arts': ['Books', 'Design', 'Fashion & Beauty', 'Food', 'Performing Arts', 'Visual Arts'],
|
|
'Business': ['Careers', 'Entrepreneurship', 'Investing', 'Management', 'Marketing', 'Non-Profit'],
|
|
'Comedy': ['Comedy Interviews', 'Improv', 'Stand-Up'],
|
|
'Education': ['Courses', 'How To', 'Language Learning', 'Self-Improvement'],
|
|
'Fiction': ['Comedy Fiction', 'Drama', 'Science Fiction'],
|
|
'Government': [],
|
|
'Health & Fitness': ['Alternative Health', 'Fitness', 'Medicine', 'Mental Health', 'Nutrition', 'Sexuality'],
|
|
'History': [],
|
|
'Kids & Family': ['Education for Kids', 'Parenting', 'Pets & Animals', 'Stories for Kids'],
|
|
'Leisure': ['Animation & Manga', 'Automotive', 'Aviation', 'Crafts', 'Games', 'Hobbies', 'Home & Garden', 'Video Games'],
|
|
'Music': ['Music Commentary', 'Music History', 'Music Interviews'],
|
|
'News': ['Business News', 'Daily News', 'Entertainment News', 'News Commentary', 'Politics', 'Sports News', 'Tech News'],
|
|
'Religion & Spirituality': ['Buddhism', 'Christianity', 'Hinduism', 'Islam', 'Judaism', 'Religion', 'Spirituality'],
|
|
'Science': ['Astronomy', 'Chemistry', 'Earth Sciences', 'Life Sciences', 'Mathematics', 'Natural Sciences', 'Nature', 'Physics', 'Social Sciences'],
|
|
'Society & Culture': ['Documentary', 'Personal Journals', 'Philosophy', 'Places & Travel', 'Relationships'],
|
|
'Sports': ['Baseball', 'Basketball', 'Cricket', 'Fantasy Sports', 'Football', 'Golf', 'Hockey', 'Rugby', 'Running', 'Soccer', 'Swimming', 'Tennis', 'Volleyball', 'Wilderness', 'Wrestling'],
|
|
'Technology': [],
|
|
'True Crime': [],
|
|
'TV & Film': ['After Shows', 'Film History', 'Film Interviews', 'Film Reviews', 'TV Reviews'],
|
|
};
|
|
|
|
const categoryNames = Object.keys(itunesCategories).sort();
|
|
|
|
const subcategories = $derived(
|
|
itunesCategory ? (itunesCategories[itunesCategory] ?? []) : []
|
|
);
|
|
|
|
// Common podcast languages (ISO 639-1)
|
|
const languages = [
|
|
{ code: 'no', label: 'Norsk' },
|
|
{ code: 'nb', label: 'Norsk bokmal' },
|
|
{ code: 'nn', label: 'Norsk nynorsk' },
|
|
{ code: 'en', label: 'English' },
|
|
{ code: 'sv', label: 'Svenska' },
|
|
{ code: 'da', label: 'Dansk' },
|
|
{ code: 'fi', label: 'Suomi' },
|
|
{ code: 'de', label: 'Deutsch' },
|
|
{ code: 'fr', label: 'Francais' },
|
|
{ code: 'es', label: 'Espanol' },
|
|
{ code: 'pt', label: 'Portugues' },
|
|
{ code: 'it', label: 'Italiano' },
|
|
{ code: 'nl', label: 'Nederlands' },
|
|
{ code: 'ja', label: 'Japanese' },
|
|
{ code: 'zh', label: 'Chinese' },
|
|
{ code: 'ko', label: 'Korean' },
|
|
];
|
|
|
|
// Emit changes to parent whenever any field changes
|
|
function emitConfig() {
|
|
const updated: Record<string, unknown> = {};
|
|
if (itunesAuthor.trim()) updated.itunes_author = itunesAuthor.trim();
|
|
if (itunesCategory) updated.itunes_category = itunesCategory;
|
|
if (itunesSubcategory) updated.itunes_subcategory = itunesSubcategory;
|
|
if (explicit) updated.explicit = true;
|
|
if (language && language !== 'no') updated.language = language;
|
|
else if (language === 'no') updated.language = language;
|
|
if (redirectFeed.trim()) updated.redirect_feed = redirectFeed.trim();
|
|
onchange(updated);
|
|
}
|
|
|
|
// Reset subcategory when category changes
|
|
function handleCategoryChange(e: Event) {
|
|
const target = e.target as HTMLSelectElement;
|
|
itunesCategory = target.value;
|
|
// Clear subcategory if not valid for new category
|
|
if (!itunesCategories[itunesCategory]?.includes(itunesSubcategory)) {
|
|
itunesSubcategory = '';
|
|
}
|
|
emitConfig();
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-3">
|
|
<!-- iTunes Author -->
|
|
<div>
|
|
<label for="podcast-author" class="block text-xs font-medium text-gray-600 mb-1">
|
|
Forfatter / iTunes Author
|
|
</label>
|
|
<input
|
|
id="podcast-author"
|
|
type="text"
|
|
bind:value={itunesAuthor}
|
|
oninput={emitConfig}
|
|
placeholder="Navn pa podcasten eller personen bak"
|
|
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
|
/>
|
|
<p class="mt-0.5 text-[11px] text-gray-400">Vises som forfatter i Apple Podcasts og andre apper.</p>
|
|
</div>
|
|
|
|
<!-- iTunes Category -->
|
|
<div>
|
|
<label for="podcast-category" class="block text-xs font-medium text-gray-600 mb-1">
|
|
Kategori / iTunes Category
|
|
</label>
|
|
<select
|
|
id="podcast-category"
|
|
value={itunesCategory}
|
|
onchange={handleCategoryChange}
|
|
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
|
>
|
|
<option value="">Velg kategori...</option>
|
|
{#each categoryNames as cat (cat)}
|
|
<option value={cat}>{cat}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- iTunes Subcategory (if available) -->
|
|
{#if subcategories.length > 0}
|
|
<div>
|
|
<label for="podcast-subcategory" class="block text-xs font-medium text-gray-600 mb-1">
|
|
Underkategori
|
|
</label>
|
|
<select
|
|
id="podcast-subcategory"
|
|
bind:value={itunesSubcategory}
|
|
onchange={emitConfig}
|
|
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
|
>
|
|
<option value="">Ingen underkategori</option>
|
|
{#each subcategories as sub (sub)}
|
|
<option value={sub}>{sub}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Language -->
|
|
<div>
|
|
<label for="podcast-language" class="block text-xs font-medium text-gray-600 mb-1">
|
|
Sprak
|
|
</label>
|
|
<select
|
|
id="podcast-language"
|
|
bind:value={language}
|
|
onchange={emitConfig}
|
|
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
|
>
|
|
{#each languages as lang (lang.code)}
|
|
<option value={lang.code}>{lang.label} ({lang.code})</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Explicit -->
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
id="podcast-explicit"
|
|
type="checkbox"
|
|
bind:checked={explicit}
|
|
onchange={emitConfig}
|
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
/>
|
|
<label for="podcast-explicit" class="text-xs font-medium text-gray-600">
|
|
Eksplisitt innhold (iTunes Explicit)
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Redirect Feed -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
Feed-redirect (flytte podcast)
|
|
</label>
|
|
|
|
{#if redirectFeed}
|
|
<!-- Active redirect indicator -->
|
|
<div class="rounded border border-amber-300 bg-amber-50 p-3 space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<span class="inline-block h-2 w-2 rounded-full bg-amber-500"></span>
|
|
<span class="text-xs font-semibold text-amber-800">Redirect aktiv</span>
|
|
</div>
|
|
<p class="text-xs text-amber-700 break-all">
|
|
Feed-URL returnerer 301 til:<br/>
|
|
<a href={redirectFeed} target="_blank" rel="noopener" class="underline">{redirectFeed}</a>
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onclick={() => { redirectFeed = ''; emitConfig(); }}
|
|
class="rounded bg-white border border-amber-400 px-3 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100"
|
|
>
|
|
Deaktiver redirect
|
|
</button>
|
|
</div>
|
|
{:else if showRedirectConfirm}
|
|
<!-- Confirmation dialog -->
|
|
<div class="rounded border border-red-300 bg-red-50 p-3 space-y-2">
|
|
<p class="text-xs font-semibold text-red-800">
|
|
Advarsel: Dette aktiverer en permanent redirect (301)
|
|
</p>
|
|
<p class="text-xs text-red-700">
|
|
Alle som henter feeden vil bli sendt til den nye URL-en.
|
|
Podcastklienter (Apple, Spotify) oppdaterer automatisk.
|
|
Du kan deaktivere redirecten nar som helst.
|
|
</p>
|
|
<input
|
|
id="podcast-redirect"
|
|
type="url"
|
|
bind:value={redirectInput}
|
|
placeholder="https://ny-host.example.com/feed.xml"
|
|
class="w-full rounded border border-red-300 px-3 py-1.5 text-sm focus:border-red-500 focus:outline-none"
|
|
/>
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={!redirectInput.trim()}
|
|
onclick={() => {
|
|
redirectFeed = redirectInput.trim();
|
|
showRedirectConfirm = false;
|
|
emitConfig();
|
|
}}
|
|
class="rounded bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Aktiver redirect
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => { showRedirectConfirm = false; redirectInput = ''; }}
|
|
class="rounded bg-white border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Avbryt
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- Inactive state — one-click to start -->
|
|
<button
|
|
type="button"
|
|
onclick={() => { showRedirectConfirm = true; }}
|
|
class="rounded border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
|
>
|
|
Flytt podcast til annen plattform...
|
|
</button>
|
|
<p class="mt-0.5 text-[11px] text-gray-400">
|
|
Aktiverer 301-redirect pa feed-URL-en slik at podcastklienter automatisk oppdaterer.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|