Add episode fields to UI (#9)

- Add chapter fields (#5)
- Add transcript fields (#8)
This commit is contained in:
Daniel J. Summers 2022-06-27 17:47:00 -04:00
parent 9fbb1bb14d
commit 80c65bcad6
5 changed files with 244 additions and 78 deletions

View File

@ -141,7 +141,7 @@ type SQLitePostData (conn : SqliteConnection) =
let updatePostEpisode (post : Post) = backgroundTask { let updatePostEpisode (post : Post) = backgroundTask {
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT COUNT(post_id) FROM post_episode WHERE post_id = @postId" cmd.CommandText <- "SELECT COUNT(post_id) FROM post_episode WHERE post_id = @postId"
cmd.Parameters.AddWithValue ("@postId", post.id) |> ignore cmd.Parameters.AddWithValue ("@postId", PostId.toString post.id) |> ignore
let! count = count cmd let! count = count cmd
if count = 1 then if count = 1 then
match post.episode with match post.episode with
@ -176,13 +176,13 @@ type SQLitePostData (conn : SqliteConnection) =
| Some ep -> | Some ep ->
cmd.CommandText <- cmd.CommandText <-
"""INSERT INTO post_episode ( """INSERT INTO post_episode (
post_id, media, length, duration, media_type, image_url, subtitle, explicit, chapter_file, post_id, media, length, duration, media_type, image_url, subtitle, explicit,
chapter_type, transcript_url, transcript_type, transcript_lang, transcript_captions, chapter_file, chapter_type, transcript_url, transcript_type, transcript_lang,
season_number, season_description, episode_number, episode_description transcript_captions, season_number, season_description, episode_number, episode_description
) VALUES ( ) VALUES (
@media, @length, @duration, @mediaType, @imageUrl, @subtitle, @explicit, @chapterFile, @postId, @media, @length, @duration, @mediaType, @imageUrl, @subtitle, @explicit,
@chapterType, @transcriptUrl, @transcriptType, @transcriptLang, @transcriptCaptions, @chapterFile, @chapterType, @transcriptUrl, @transcriptType, @transcriptLang,
@seasonNumber, @seasonDescription, @episodeNumber, @episodeDescription @transcriptCaptions, @seasonNumber, @seasonDescription, @episodeNumber, @episodeDescription
)""" )"""
addEpisodeParameters cmd ep addEpisodeParameters cmd ep
do! write cmd do! write cmd
@ -530,7 +530,7 @@ type SQLitePostData (conn : SqliteConnection) =
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
cmd.CommandText <- cmd.CommandText <-
"""UPDATE post """UPDATE post
SET author_id = @author_id, SET author_id = @authorId,
status = @status, status = @status,
title = @title, title = @title,
permalink = @permalink, permalink = @permalink,

View File

@ -9,7 +9,7 @@ module private Helpers =
/// Create a string option if a string is blank /// Create a string option if a string is blank
let noneIfBlank (it : string) = let noneIfBlank (it : string) =
match it.Trim () with "" -> None | trimmed -> Some trimmed match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed
/// Details about a category, used to display category lists /// Details about a category, used to display category lists
@ -448,10 +448,10 @@ type EditPostModel =
transcriptLang : string transcriptLang : string
/// Whether the provided transcript should be presented as captions /// Whether the provided transcript should be presented as captions
transcriptCaptions : bool option transcriptCaptions : bool
/// The season number (optional) /// The season number (optional)
seasonNumber : int option seasonNumber : int
/// A description of this season (optional, ignored if season number is not provided) /// A description of this season (optional, ignored if season number is not provided)
seasonDescription : string seasonDescription : string
@ -489,7 +489,7 @@ type EditPostModel =
isEpisode = Option.isSome post.episode isEpisode = Option.isSome post.episode
media = episode.media media = episode.media
length = episode.length length = episode.length
duration = defaultArg (episode.duration |> Option.map (fun it -> it.ToString "HH:mm:SS")) "" duration = defaultArg (episode.duration |> Option.map (fun it -> it.ToString """hh\:mm\:ss""")) ""
mediaType = defaultArg episode.mediaType "" mediaType = defaultArg episode.mediaType ""
imageUrl = defaultArg episode.imageUrl "" imageUrl = defaultArg episode.imageUrl ""
subtitle = defaultArg episode.subtitle "" subtitle = defaultArg episode.subtitle ""
@ -499,8 +499,8 @@ type EditPostModel =
transcriptUrl = defaultArg episode.transcriptUrl "" transcriptUrl = defaultArg episode.transcriptUrl ""
transcriptType = defaultArg episode.transcriptType "" transcriptType = defaultArg episode.transcriptType ""
transcriptLang = defaultArg episode.transcriptLang "" transcriptLang = defaultArg episode.transcriptLang ""
transcriptCaptions = episode.transcriptCaptions transcriptCaptions = defaultArg episode.transcriptCaptions false
seasonNumber = episode.seasonNumber seasonNumber = defaultArg episode.seasonNumber 0
seasonDescription = defaultArg episode.seasonDescription "" seasonDescription = defaultArg episode.seasonDescription ""
episodeNumber = defaultArg (episode.episodeNumber |> Option.map string) "" episodeNumber = defaultArg (episode.episodeNumber |> Option.map string) ""
episodeDescription = defaultArg episode.episodeDescription "" episodeDescription = defaultArg episode.episodeDescription ""
@ -536,27 +536,24 @@ type EditPostModel =
Some { Some {
media = this.media media = this.media
length = this.length length = this.length
duration = match this.duration.Trim () with duration = noneIfBlank this.duration |> Option.map TimeSpan.Parse
| "" -> None mediaType = noneIfBlank this.mediaType
| dur -> Some (TimeSpan.Parse dur) imageUrl = noneIfBlank this.imageUrl
mediaType = match this.mediaType.Trim () with "" -> None | mt -> Some mt subtitle = noneIfBlank this.subtitle
imageUrl = match this.imageUrl.Trim () with "" -> None | iu -> Some iu explicit = noneIfBlank this.explicit |> Option.map ExplicitRating.parse
subtitle = match this.subtitle.Trim () with "" -> None | sub -> Some sub chapterFile = noneIfBlank this.chapterFile
explicit = match this.explicit.Trim () with chapterType = noneIfBlank this.chapterType
| "" -> None transcriptUrl = noneIfBlank this.transcriptUrl
| exp -> Some (ExplicitRating.parse exp) transcriptType = noneIfBlank this.transcriptType
chapterFile = match this.chapterFile.Trim () with "" -> None | cf -> Some cf transcriptLang = noneIfBlank this.transcriptLang
chapterType = match this.chapterType.Trim () with "" -> None | ct -> Some ct transcriptCaptions = if this.transcriptCaptions then Some true else None
transcriptUrl = match this.transcriptUrl.Trim () with "" -> None | tu -> Some tu seasonNumber = if this.seasonNumber = 0 then None else Some this.seasonNumber
transcriptType = match this.transcriptType.Trim () with "" -> None | tt -> Some tt seasonDescription = noneIfBlank this.seasonDescription
transcriptLang = match this.transcriptLang.Trim () with "" -> None | tl -> Some tl episodeNumber = match noneIfBlank this.episodeNumber |> Option.map Double.Parse with
transcriptCaptions = this.transcriptCaptions | Some it when it = 0.0 -> None
seasonNumber = this.seasonNumber | Some it -> Some (double it)
seasonDescription = match this.seasonDescription.Trim () with "" -> None | sd -> Some sd | None -> None
episodeNumber = match this.episodeNumber.Trim () with episodeDescription = noneIfBlank this.episodeDescription
| "" -> None
| en -> Some (Double.Parse en)
episodeDescription = match this.episodeDescription.Trim () with "" -> None | ed -> Some ed
} }
else else
None None

View File

@ -2,6 +2,7 @@
module MyWebLog.Handlers.Post module MyWebLog.Handlers.Post
open System open System
open System.Collections.Generic
open MyWebLog open MyWebLog
/// Parse a slug and page number from an "everything else" URL /// Parse a slug and page number from an "everything else" URL
@ -244,6 +245,12 @@ let edit postId : HttpHandler = fun next ctx -> task {
page_title = title page_title = title
templates = templates templates = templates
categories = cats categories = cats
explicit_values = [|
KeyValuePair.Create ("", "&ndash; Default &ndash;")
KeyValuePair.Create (ExplicitRating.toString Yes, "Yes")
KeyValuePair.Create (ExplicitRating.toString No, "No")
KeyValuePair.Create (ExplicitRating.toString Clean, "Clean")
|]
|} |}
|> viewForTheme "admin" "post-edit" next ctx |> viewForTheme "admin" "post-edit" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx

View File

@ -22,6 +22,7 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label for="text">Text</label> &nbsp; &nbsp; <label for="text">Text</label> &nbsp; &nbsp;
<div class="btn-group btn-group-sm" role="group" aria-label="Text format button group">
<input type="radio" name="source" id="source_html" class="btn-check" value="HTML" <input type="radio" name="source" id="source_html" class="btn-check" value="HTML"
{%- if model.source == "HTML" %} checked="checked"{% endif %}> {%- if model.source == "HTML" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label> <label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
@ -29,6 +30,7 @@
{%- if model.source == "Markdown" %} checked="checked"{% endif %}> {%- if model.source == "Markdown" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label> <label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
</div> </div>
</div>
<div class="pb-3"> <div class="pb-3">
<textarea name="text" id="text" class="form-control" rows="20">{{ model.text }}</textarea> <textarea name="text" id="text" class="form-control" rows="20">{{ model.text }}</textarea>
</div> </div>
@ -39,13 +41,194 @@
<div class="form-text">comma-delimited</div> <div class="form-text">comma-delimited</div>
</div> </div>
{% if model.status == "Draft" %} {% if model.status == "Draft" %}
<div class="form-check pb-2"> <div class="form-check form-switch pb-2">
<input type="checkbox" name="doPublish" id="doPublish" class="form-check-input" value="true"> <input type="checkbox" name="doPublish" id="doPublish" class="form-check-input" value="true">
<label for="doPublish" class="form-check-label">Publish This Post</label> <label for="doPublish" class="form-check-label">Publish This Post</label>
</div> </div>
{% endif %} {% endif %}
<button type="submit" class="btn btn-primary pb-2">Save Changes</button> <button type="submit" class="btn btn-primary pb-2">Save Changes</button>
<hr class="mb-3"> <hr class="mb-3">
<fieldset class="mb-3">
<legend>
<span class="form-check form-switch">
<small>
<input type="checkbox" name="isEpisode" id="isEpisode" class="form-check-input" value="true"
data-bs-toggle="collapse" data-bs-target="#episodeItems" onclick="Admin.toggleEpisodeFields()"
{%- if model.is_episode %}checked="checked"{% endif %}>
</small>
<label for="isEpisode">Podcast Episode</label>
</span>
</legend>
<div id="episodeItems" class="container p-0 collapse{% if model.is_episode %} show{% endif %}">
<div class="row">
<div class="col-12 col-md-8 pb-3">
<div class="form-floating">
<input type="text" name="media" id="media" class="form-control" placeholder="Media" required
value="{{ model.media }}">
<label for="media">Media File</label>
<div class="form-text">Path to media; relative URL will be appended to base media path</div>
</div>
</div>
<div class="col-12 col-md-4 pb-3">
<div class="form-floating">
<input type="text" name="mediaType" id="mediaType" class="form-control" placeholder="Media Type"
value="{{ model.media_type }}">
<label for="mediaType">Media MIME Type</label>
<div class="form-text">Optional; overrides podcast default</div>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col">
<div class="form-floating">
<input type="number" name="length" id="length" class="form-control" placeholder="Length" required
value="{{ model.length }}">
<label for="length">Media Length (bytes)</label>
<div class="form-text">TODO: derive from above file name</div>
</div>
</div>
<div class="col">
<div class="form-floating">
<input type="text" name="duration" id="duration" class="form-control" placeholder="Duration"
value="{{ model.duration }}">
<label for="duration">Duration</label>
<div class="form-text">Recommended; enter in <code>HH:MM:SS</code> format</div>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col">
<div class="form-floating">
<input type="text" name="subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
value="{{ model.subtitle }}">
<label for="subtitle">Subtitle</label>
<div class="form-text">Optional; a subtitle for this episode</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-8 pb-3">
<div class="form-floating">
<input type="text" name="imageUrl" id="imageUrl" class="form-control" placeholder="Image URL"
value="{{ model.image_url }}">
<label for="imageUrl">Image URL</label>
<div class="form-text">
Optional; overrides podcast default; relative URL served from this web log
</div>
</div>
</div>
<div class="col-12 col-md-4 pb-3">
<div class="form-floating">
<select name="explicit" id="explicit" class="form-control">
{% for exp_value in explicit_values %}
<option value="{{ exp_value[0] }}"
{%- if model.explicit == exp_value[0] %} selected="selected"{% endif -%}>
{{ exp_value[1] }}
</option>
{% endfor %}
</select>
<label for="explicit">Explicit Rating</label>
<div class="form-text">Optional; overrides podcast default</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-8 pb-3">
<div class="form-floating">
<input type="text" name="chapterFile" id="chapterFile" class="form-control"
placeholder="Chapter File" value="{{ model.chapter_file }}">
<label for="chapterFile">Chapter File</label>
<div class="form-text">Optional; relative URL served from this web log</div>
</div>
</div>
<div class="col-12 col-md-4 pb-3">
<div class="form-floating">
<input type="text" name="chapterType" id="chapterType" class="form-control"
placeholder="Chapter Type" value="{{ model.chapter_type }}">
<label for="chapterType">Chapter MIME Type</label>
<div class="form-text">
Optional; <code>application/json+chapters</code> assumed if chapter file ends with
<code>.json</code>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-8 pb-3">
<div class="form-floating">
<input type="text" name="transcriptUrl" id="transcriptUrl" class="form-control"
placeholder="Transcript URL" value="{{ model.transcript_url }}">
<label for="transcriptUrl">Transcript URL</label>
<div class="form-text">Optional; relative URL served from this web log</div>
</div>
</div>
<div class="col-12 col-md-4 pb-3">
<div class="form-floating">
<input type="text" name="transcriptType" id="transcriptType" class="form-control"
placeholder="Transcript Type" value="{{ model.transcript_type }}">
<label for="transcriptType">Transcript MIME Type</label>
<div class="form-text">Recommended if transcript file provided</div>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col">
<div class="form-floating">
<input type="text" name="transcriptLang" id="transcriptLang" class="form-control"
placeholder="Transcript Language" value="{{ model.transcript_lang }}">
<label for="transcriptLang">Transcript Language</label>
<div class="form-text">Optional; overrides podcast default</div>
</div>
</div>
<div class="col d-flex justify-content-center">
<div class="form-check form-switch align-self-center pb-3">
<input type="checkbox" name="transcriptCaptions" id="transcriptCaptions" class="form-check-input"
value="true" {% if model.transcript_captions %} checked="checked"{% endif %}>
<label for="transcriptCaptions">This is a captions file</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col col-md-4">
<div class="form-floating">
<input type="number" name="seasonNumber" id="seasonNumber" class="form-control"
placeholder="Season Number" value="{{ model.season_number }}">
<label for="seasonNumber">Season Number</label>
<div class="form-text">Optional</div>
</div>
</div>
<div class="col col-md-8">
<div class="form-floating">
<input type="text" name="seasonDescription" id="seasonDescription" class="form-control"
placeholder="Season Description" maxlength="128" value="{{ model.season_description }}">
<label for="seasonDescription">Season Description</label>
<div class="form-text">Optional</div>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col col-md-4">
<div class="form-floating">
<input type="number" name="episodeNumber" id="episodeNumber" class="form-control" step="0.01"
placeholder="Episode Number" value="{{ model.episode_number }}">
<label for="episodeNumber">Episode Number</label>
<div class="form-text">Optional; up to 2 decimal points</div>
</div>
</div>
<div class="col col-md-8">
<div class="form-floating">
<input type="text" name="episodeDescription" id="episodeDescription" class="form-control"
placeholder="Episode Description" maxlength="128" value="{{ model.episode_description }}">
<label for="episodeDescription">Episode Description</label>
<div class="form-text">Optional</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => Admin.toggleEpisodeFields())
</script>
</fieldset>
<fieldset class="pb-3"> <fieldset class="pb-3">
<legend> <legend>
Metadata Metadata
@ -55,11 +238,6 @@
</button> </button>
</legend> </legend>
<div id="metaItemContainer" class="collapse"> <div id="metaItemContainer" class="collapse">
{%- unless model.meta_names contains "episode_media_file" %}
<button class="btn btn-sm btn-secondary" onclick="Admin.addPodcastFields()" id="addPodcastFieldButton">
Add Podcast Episode Fields
</button>
{%- endunless %}
<div id="metaItems" class="container"> <div id="metaItems" class="container">
{%- for meta in metadata %} {%- for meta in metadata %}
<div id="meta_{{ meta[0] }}" class="row mb-3"> <div id="meta_{{ meta[0] }}" class="row mb-3">

View File

@ -204,30 +204,14 @@
}, },
/** /**
* Add podcast episode metadata fields * Enable or disable podcast fields
*/ */
addPodcastFields() { toggleEpisodeFields() {
[ [ "episode_media_file", true, "required; relative URL will be appended to configured base media path" ], const disabled = !document.getElementById("isEpisode").checked
[ "episode_media_length", true, "required; file size in bytes" ], ;[ "media", "mediaType", "length", "duration", "subtitle", "imageUrl", "explicit", "chapterFile", "chapterType",
[ "episode_duration", false, "suggested; format is HH:MM:SS" ], "transcriptUrl", "transcriptType", "transcriptLang", "transcriptCaptions", "seasonNumber", "seasonDescription",
[ "episode_media_type", false, "optional; blank uses podcast default" ], "episodeNumber", "episodeDescription"
[ "episode_image", false, "optional; relative URLs are served from this web log" ], ].forEach(it => document.getElementById(it).disabled = disabled)
[ "episode_subtitle", false, "optional" ],
[ "episode_explicit", false, "optional; blank uses podcast default"]
].forEach(([fieldName, isRequired, hintText]) => {
const nameField = this.createMetaNameField()
nameField.value = fieldName
nameField.readOnly = true
const valueField = this.createMetaValueField()
valueField.required = isRequired
this.createMetaRow(
this.createMetaRemoveColumn(),
this.createMetaNameColumn(nameField),
this.createMetaValueColumn(valueField, hintText))
})
document.getElementById("addPodcastFieldButton").remove()
}, },
/** /**