WIP on custom feed edit page

This commit is contained in:
Daniel J. Summers 2022-05-29 18:18:46 -04:00
parent 46d6c4f5f1
commit 7757bd8394
8 changed files with 379 additions and 18 deletions

View File

@ -278,13 +278,16 @@ type PodcastOptions =
iTunesCategory : string iTunesCategory : string
/// A further refinement of the categorization of this podcast (iTunes field / values) /// A further refinement of the categorization of this podcast (iTunes field / values)
iTunesSubcategory : string iTunesSubcategory : string option
/// The explictness rating (iTunes field) /// The explictness rating (iTunes field)
explicit : ExplicitRating explicit : ExplicitRating
/// The default media type for files in this podcast /// The default media type for files in this podcast
defaultMediaType : string option defaultMediaType : string option
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
mediaBaseUrl : string option
} }
@ -303,6 +306,17 @@ type CustomFeed =
podcast : PodcastOptions option podcast : PodcastOptions option
} }
/// Functions to support custom feeds
module CustomFeed =
/// An empty custom feed
let empty =
{ id = CustomFeedId ""
source = Category (CategoryId "")
path = Permalink ""
podcast = None
}
/// Really Simple Syndication (RSS) options for this web log /// Really Simple Syndication (RSS) options for this web log
type RssOptions = type RssOptions =

View File

@ -164,6 +164,110 @@ type EditCategoryModel =
} }
/// View model to edit a custom RSS feed
[<CLIMutable; NoComparison; NoEquality>]
type EditCustomFeedModel =
{ /// The ID of the feed being editing
id : string
/// The type of source for this feed ("category" or "tag")
sourceType : string
/// The category ID or tag on which this feed is based
sourceValue : string
/// The relative path at which this feed is served
path : string
/// Whether this feed defines a podcast
isPodcast : bool
/// The title of the podcast
title : string
/// A subtitle for the podcast
subtitle : string
/// The number of items in the podcast feed
itemsInFeed : int
/// A summary of the podcast (iTunes field)
summary : string
/// The display name of the podcast author (iTunes field)
displayedAuthor : string
/// The e-mail address of the user who registered the podcast at iTunes
email : string
/// The link to the image for the podcast
imageUrl : string
/// The category from iTunes under which this podcast is categorized
itunesCategory : string
/// A further refinement of the categorization of this podcast (iTunes field / values)
itunesSubcategory : string
/// The explictness rating (iTunes field)
explicit : string
/// The default media type for files in this podcast
defaultMediaType : string
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
mediaBaseUrl : string
}
/// An empty custom feed model
static member empty =
{ id = ""
sourceType = "category"
sourceValue = ""
path = ""
isPodcast = false
title = ""
subtitle = ""
itemsInFeed = 25
summary = ""
displayedAuthor = ""
email = ""
imageUrl = ""
itunesCategory = ""
itunesSubcategory = ""
explicit = "no"
defaultMediaType = "audio/mpeg"
mediaBaseUrl = ""
}
/// Create a model from a custom feed
static member fromFeed (feed : CustomFeed) =
let rss =
{ EditCustomFeedModel.empty with
id = CustomFeedId.toString feed.id
sourceType = match feed.source with Category _ -> "category" | Tag _ -> "tag"
sourceValue = match feed.source with Category (CategoryId catId) -> catId | Tag tag -> tag
path = Permalink.toString feed.path
}
match feed.podcast with
| Some p ->
{ rss with
isPodcast = true
title = p.title
subtitle = defaultArg p.subtitle ""
itemsInFeed = p.itemsInFeed
summary = p.summary
displayedAuthor = p.displayedAuthor
email = p.email
itunesCategory = p.iTunesCategory
itunesSubcategory = defaultArg p.iTunesSubcategory ""
explicit = ExplicitRating.toString p.explicit
defaultMediaType = defaultArg p.defaultMediaType ""
mediaBaseUrl = defaultArg p.mediaBaseUrl ""
}
| None -> rss
/// View model to edit a page /// View model to edit a page
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type EditPageModel = type EditPageModel =

View File

@ -180,9 +180,11 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) =
let categoryXml = XmlDocument () let categoryXml = XmlDocument ()
let catElt = categoryXml.CreateElement ("itunes", "category", "") let catElt = categoryXml.CreateElement ("itunes", "category", "")
catElt.SetAttribute ("text", podcast.iTunesCategory) catElt.SetAttribute ("text", podcast.iTunesCategory)
let subCat = categoryXml.CreateElement ("itunes", "category", "") podcast.iTunesSubcategory
subCat.SetAttribute ("text", podcast.iTunesSubcategory) |> Option.iter (fun subCat ->
catElt.AppendChild subCat |> ignore let subCatElt = categoryXml.CreateElement ("itunes", "category", "")
subCatElt.SetAttribute ("text", subCat)
catElt.AppendChild subCatElt |> ignore)
categoryXml.AppendChild catElt |> ignore categoryXml.AppendChild catElt |> ignore
[ "dc", "http://purl.org/dc/elements/1.1/" [ "dc", "http://purl.org/dc/elements/1.1/"
@ -287,13 +289,12 @@ let editSettings : HttpHandler = fun next ctx -> task {
webLog.rss.customFeeds webLog.rss.customFeeds
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx)) |> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|> Array.ofList |> Array.ofList
return! return! Hash.FromAnonymousObject
Hash.FromAnonymousObject {| csrf = csrfToken ctx
{| csrf = csrfToken ctx page_title = "RSS Settings"
page_title = "RSS Settings" model = EditRssModel.fromRssOptions webLog.rss
model = EditRssModel.fromRssOptions webLog.rss custom_feeds = feeds
custom_feeds = feeds |}
|}
|> viewForTheme "admin" "rss-settings" next ctx |> viewForTheme "admin" "rss-settings" next ctx
} }
@ -311,6 +312,30 @@ let saveSettings : HttpHandler = fun next ctx -> task {
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET: /admin/rss/{id}/edit
let editCustomFeed feedId : HttpHandler = fun next ctx -> task {
let customFeed =
match feedId with
| "new" -> Some CustomFeed.empty
| _ -> ctx.WebLog.rss.customFeeds |> List.tryFind (fun f -> f.id = CustomFeedId feedId)
match customFeed with
| Some f ->
return! Hash.FromAnonymousObject
{| csrf = csrfToken ctx
page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed"""
model = EditCustomFeedModel.fromFeed f
categories = CategoryCache.get ctx
|}
|> viewForTheme "admin" "custom-feed-edit" next ctx
| None -> return! Error.notFound next ctx
}
// POST: /admin/rss/save
let saveCustomFeed : HttpHandler = fun next ctx -> task {
// TODO: stub
return! Error.notFound next ctx
}
// POST /admin/rss/{id}/delete // POST /admin/rss/{id}/delete
let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task { let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task {
let conn = ctx.Conn let conn = ctx.Conn
@ -329,7 +354,7 @@ let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task {
WebLogCache.set webLog WebLogCache.set webLog
do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" } do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" }
else else
do! addMessage ctx { UserMessage.warning with message = "Post not found; nothing deleted" } do! addMessage ctx { UserMessage.warning with message = "Custom feed not found; no action taken" }
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -95,7 +95,10 @@ let router : HttpHandler = choose [
]) ])
subRoute "/settings" (choose [ subRoute "/settings" (choose [
route "" >=> Admin.settings route "" >=> Admin.settings
route "/rss" >=> Feed.editSettings subRoute "/rss" (choose [
route "" >=> Feed.editSettings
routef "/%s/edit" Feed.editCustomFeed
])
subRoute "/tag-mapping" (choose [ subRoute "/tag-mapping" (choose [
route "s" >=> Admin.tagMappings route "s" >=> Admin.tagMappings
routef "/%s/edit" Admin.editMapping routef "/%s/edit" Admin.editMapping
@ -122,6 +125,7 @@ let router : HttpHandler = choose [
route "" >=> Admin.saveSettings route "" >=> Admin.saveSettings
subRoute "/rss" (choose [ subRoute "/rss" (choose [
route "" >=> Feed.saveSettings route "" >=> Feed.saveSettings
route "/save" >=> Feed.saveCustomFeed
routef "/%s/delete" Feed.deleteCustomFeed routef "/%s/delete" Feed.deleteCustomFeed
]) ])
subRoute "/tag-mapping" (choose [ subRoute "/tag-mapping" (choose [

View File

@ -220,10 +220,11 @@ let main args =
[ // Domain types [ // Domain types
typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog> typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog>
// View models // View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage> typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
typeof<EditCategoryModel>; typeof<EditPageModel>; typeof<EditPostModel>; typeof<EditRssModel> typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditPageModel>; typeof<EditPostModel>
typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>; typeof<ManagePermalinksModel> typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage> typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>
typeof<UserMessage>
// Framework types // Framework types
typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list> typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list>
typeof<string list>; typeof<string option>; typeof<TagMap list> typeof<string list>; typeof<string option>; typeof<TagMap list>

View File

@ -0,0 +1,186 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<form action="{{ "admin/settings/rss/save" | relative_link }}" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
{%- assign is_cat = model.source_type == "category" -%}
<div class="container">
<div class="row pb-3">
<div class="col-12 col-xl-10 offset-xl-1">
<fieldset class="container pb-0">
<legend>Feed Source</legend>
<div class="row d-flex align-items-center">
<div class="col-1 d-flex justify-content-end pb-3">
<div class="form-check form-check-inline me-0">
<input type="radio" name="sourceType" id="sourceTypeCat" class="form-check-input" value="category"
{% if is_cat %}checked="checked"{% endif %} onclick="Admin.customFeedBy('category')">
<label for="sourceTypeCat" class="form-check-label d-none">Category</label>
</div>
</div>
<div class="col-11 col-lg-5 pb-3">
<div class="form-floating">
<select name="sourceValue" id="sourceValueCat" class="form-control" required
{% unless is_cat %}disabled="disabled"{% endunless %}>
<option value="">&ndash; Select Category &ndash;</option>
{% for cat in categories -%}
<option value="{{ cat.id }}"
{%- if is_cat and model.source_value == cat.id %} selected="selected"{% endif -%}>
{% for it in cat.parent_names %}{{ it }} &rang; {% endfor %}{{ cat.name }}
</option>
{%- endfor %}
</select>
<label for="sourceValueCat">Category</label>
</div>
</div>
<div class="col-1 d-flex justify-content-end pb-3">
<div class="form-check form-check-inline me-0">
<input type="radio" name="sourceType" id="sourceTypeTag" class="form-check-input" value="tag"
{%- unless is_cat %} checked="checked"{% endunless %} onclick="Admin.customFeedBy('tag')">
<label for="sourceTypeTag" class="form-check-label d-none">Tag</label>
</div>
</div>
<div class="col-11 col-lg-5 pb-3">
<div class="form-floating">
<input type="text" name="sourceValue" id="sourceValueTag" class="form-control" placeholder="Tag"
{%- if is_cat %} disabled="disabled"{% endif %} required
{%- unless is_cat %} value="{{ model.source_value }}"{% endunless %}>
<label for="sourceValueTag">Tag</label>
</div>
</div>
</div>
</fieldset>
</div>
</div>
<div class="row">
<div class="col-12 col-md-6 col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
<div class="form-floating">
<input type="text" name="path" id="path" class="form-control" placeholder="Relative Feed Path"
value="{{ model.path }}">
<label for="path">Relative Feed Path</label>
<span class="form-text fst-italic">Appended to {{ web_log.url_base }}/</span>
</div>
</div>
<div class="col-12 col-md-6 col-lg-5 col-xl-4 pb-3 d-flex align-self-center justify-content-center">
<div class="form-check form-switch">
<input type="checkbox" name="isPodcast" id="isPodcast" class="form-check-input" value="true"
{%- if model.is_podcast %} checked="checked"{% endif %} onclick="Admin.checkPodcast()">
<label for="isPodcast" class="form-check-label">Is Podcast Feed</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col">
<fieldset class="container" id="podcastFields"{% unless model.is_podcast %} disabled="disabled"{%endunless%}>
<legend>Podcast Settings</legend>
<div class="row">
<div class="col-12 col-lg-6 pb-3">
<div class="form-floating">
<input type="text" name="title" id="title" class="form-control" placeholder="Title" required
value="{{ model.title }}">
<label for="title">Title</label>
</div>
</div>
<div class="col-12 col-lg-6 pb-3">
<div class="form-floating">
<input type="text" name="subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
value="{{ model.subtitle }}">
<label for="subtitle">Podcast Subtitle</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col">
<div class="form-floating">
<input type="text" name="summary" id="summary" class="form-control" placeholder="Summary" required
value="{{ model.summary }}">
<label for="summary">Summary</label>
<span class="form-text fst-italic">Displayed in podcast directories</span>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-lg-5 pb-3">
<div class="form-floating">
<input type="text" name="displayedAuthor" id="displayedAuthor" class="form-control"
placeholder="Author" required value="{{ model.displayed_author }}">
<label for="displayedAuthor">Displayed Author</label>
</div>
</div>
<div class="col-12 col-lg-5 pb-3">
<div class="form-floating">
<input type="text" name="email" id="email" class="form-control" placeholder="Email" required
value="{{ model.email }}">
<label for="email">Author E-mail</label>
<span class="form-text fst-italic">For iTunes, must match registered e-mail</span>
</div>
</div>
<div class="col-12 col-lg-2 pb-3">
<div class="form-floating">
<input type="number" name="itemsInFeed" id="itemsInFeed" class="form-control" placeholder="Items"
required value="{{ model.items_in_feed }}">
<label for="itemsInFeed"># Episodes</label>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-lg-6 pb-3">
<div class="form-floating">
<input type="text" name="itunesCategory" id="itunesCategory" class="form-control"
placeholder="iTunes Category" required value="{{ model.itunes_category }}">
<label for="itunesCategory">iTunes Category</label>
<span class="form-text fst-italic">
<a href="https://www.thepodcasthost.com/planning/itunes-podcast-categories/" target="_blank"
rel="noopener">
iTunes Category / Subcategory List
</a>
</span>
</div>
</div>
<div class="col-12 col-lg-6 pb-3">
<div class="form-floating">
<input type="text" name="itunesSubcategory" id="itunesSubcategory" class="form-control"
placeholder="iTunes Subcategory" value="{{ model.itunes_subcategory }}">
<label for="itunesSubcategory">iTunes Subcategory</label>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-sm-6 col-lg-4">
<div class="form-floating">
<select name="explicit" id="explicit" class="form-control" required>
<option value="yes"{% if model.explicit == "yes" %} selected="selected"{% endif %}>Yes</option>
<option value="no"{% if model.explicit == "no" %} selected="selected"{% endif %}>No</option>
<option value="clean"{% if model.explicit == "clean" %} selected="selected"{% endif %}>
Clean
</option>
</select>
<label for="explicit">Explicit Rating</label>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-4">
<div class="form-floating">
<input type="text" name="defaultMediaType" id="defaultMediaType" class="form-control"
placeholder="Media Type" value="{{ model.default_media_type }}">
<label for="defaultMediaType">Default Media Type</label>
<span class="form-text fst-italic">Optional; blank for no default</span>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-4">
<div class="form-floating">
<input type="text" name="mediaBaseUrl" id="mediaBaseUrl" class="form-control"
placeholder="Media Base URL" value="{{ model.media_base_url }}">
<label for="mediaBaseUrl">Media Base URL</label>
<span class="form-text fst-italic">Optional; prepended to episode media file if present</span>
</div>
</div>
</div>
</fieldset>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</article>

View File

@ -64,7 +64,9 @@
</div> </div>
</form> </form>
<h3>Custom Feeds</h3> <h3>Custom Feeds</h3>
<a class="btn btn-sm btn-secondary" href="{{ 'admin/rss/new/edit' | relative_link }}">Add a New Custom Feed</a> <a class="btn btn-sm btn-secondary" href="{{ 'admin/settings/rss/new/edit' | relative_link }}">
Add a New Custom Feed
</a>
<table class="table table-sm table-hover"> <table class="table table-sm table-hover">
<thead> <thead>
<tr> <tr>

View File

@ -139,6 +139,31 @@
this.nextPermalink++ this.nextPermalink++
}, },
/**
* Check to enable or disable podcast fields
*/
checkPodcast() {
document.getElementById("podcastFields").disabled = !document.getElementById("isPodcast").checked
},
/**
* Toggle the source of a custom RSS feed
* @param source The source that was selected
*/
customFeedBy(source) {
const categoryInput = document.getElementById("sourceValueCat")
const tagInput = document.getElementById("sourceValueTag")
if (source === "category") {
tagInput.value = ""
tagInput.disabled = true
categoryInput.disabled = false
} else {
categoryInput.selectedIndex = -1
categoryInput.disabled = true
tagInput.disabled = false
}
},
/** /**
* Remove a metadata item * Remove a metadata item
* @param idx The index of the metadata item to remove * @param idx The index of the metadata item to remove