Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make featured notebook sources a frontend launch param #2412

Merged
merged 9 commits into from
Dec 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/common/URLTools.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const with_query_params = (/** @type {String | URL} */ url_str, /** @type {Record<string,string?>} */ params) => {
export const with_query_params = (/** @type {String | URL} */ url_str, /** @type {Record<string,string | null | undefined>} */ params) => {
const fake_base = "http://delete-me.com/"
const url = new URL(url_str, fake_base)
Object.entries(params).forEach(([key, val]) => {
Expand Down
112 changes: 67 additions & 45 deletions frontend/components/welcome/Featured.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,47 @@
import featured_sources from "../../featured_sources.js"
import _ from "../../imports/lodash.js"
import { html, useEffect, useState } from "../../imports/Preact.js"
import register from "../../imports/PreactCustomElement.js"
import { FeaturedCard } from "./FeaturedCard.js"

const run = (f) => f()

/**
* @typedef SourceManifestNotebookEntry
* @type {{
* id: String,
* hash: String,
* html_path: String,
* statefile_path: String,
* notebookfile_path: String,
* frontmatter: Record<string,any>,
* html_path?: String,
* statefile_path?: String,
* notebookfile_path?: String,
* frontmatter?: Record<string,any>,
* }}
*/

/**
* @typedef SourceManifestCollectionEntry
* @type {{
* title: String?,
* description: String?,
* tags: Array<String>?,
* title?: String,
* description?: String,
* tags?: Array<String> | "everything",
* }}
*/

/**
* @typedef SourceManifest
* @type {{
* notebooks: Record<string,SourceManifestNotebookEntry>,
* collections: Array<SourceManifestCollectionEntry>?,
* pluto_version: String,
* julia_version: String,
* format_version: String,
* source_url: String,
* title: String?,
* description: String?,
* collections?: Array<SourceManifestCollectionEntry>,
* pluto_version?: String,
* julia_version?: String,
* format_version?: String,
* source_url?: String,
* title?: String,
* description?: String,
* }}
*/

/**
* This data is used as placeholder while the real data is loading from the network.
* @type {SourceManifest[]}
*/
const placeholder_data = [
{
title: "Featured Notebooks",
Expand All @@ -50,10 +52,11 @@ const placeholder_data = [
tags: [],
},
],
notebooks: [],
notebooks: {},
},
]

/** This HTML is shown instead of the featured notebooks if the user is offline. */
const offline_html = html`
<div class="featured-source">
<h1>${placeholder_data[0].title}</h1>
Expand All @@ -77,38 +80,50 @@ const offline_html = html`
</div>
`

export const Featured = () => {
// Option 1: Dynamically load source list from a json:
// const [sources, set_sources] = useState(/** @type{Array<{url: String, integrity: String?}>?} */ (null))
// useEffect(() => {
// run(async () => {
// const data = await (await fetch("featured_sources.json")).json()

// set_sources(data.sources)
// })
// }, [])
/**
* If no collections are defined, then this special collection will just show all notebooks under the "Notebooks" category.
* No collections are defined if no `pluto_export_configuration.json` file was provided to PlutoSliderServer.jl.
* @type {SourceManifestCollectionEntry[]}
*/
const fallback_collections = [
{
title: "Notebooks",
tags: "everything",
},
]

// Option 2: From a JS file. This means that the source list can be bundled together.
const sources = featured_sources.sources
/**
* @typedef FeaturedSource
* @type {{url: String, integrity?: String}}
*/

const [source_data, set_source_data] = useState(/** @type{Array<SourceManifest>} */ ([]))
/**
* @param {{
* sources: FeaturedSource[]?,
* direct_html_links: boolean,
* }} props
*/
export const Featured = ({ sources, direct_html_links }) => {
// source_data will be a mapping from [source URL] => [data from that source]
const [source_data, set_source_data] = useState(/** @type {Record<String,SourceManifest>} */ ({}))

useEffect(() => {
if (sources != null) {
// Start downloading the sources
const promises = sources.map(async ({ url, integrity }) => {
const data = await (await fetch(new Request(url, { integrity: integrity ?? undefined }))).json()

if (data.format_version !== "1") {
throw new Error(`Invalid format version: ${data.format_version}`)
}

set_source_data((old) => [
set_source_data((old) => ({
...old,
{
[url]: {
...data,
source_url: url,
},
])
}))
})

Promise.any(promises).catch((e) => {
Expand All @@ -119,7 +134,7 @@ export const Featured = () => {
}, [sources])

useEffect(() => {
if (source_data?.length > 0) {
if (Object.entries(source_data).length > 0) {
console.log("Sources:", source_data)
}
}, [source_data])
Expand All @@ -131,37 +146,44 @@ export const Featured = () => {
}, 8 * 1000)
}, [])

const no_data = !(source_data?.length > 0)
const no_data = Object.entries(source_data).length === 0

return no_data && waited_too_long
? offline_html
: html`
${(no_data ? placeholder_data : source_data).map(
(data) => html`
${(no_data ? placeholder_data : Object.values(source_data)).map((/** @type {SourceManifest} */ data) => {
let collections = data?.collections ?? fallback_collections

return html`
<div class="featured-source">
<h1>${data.title}</h1>
<p>${data.description}</p>
${data.collections.map((coll) => {
${collections.map((coll) => {
return html`
<div class="collection">
<h2>${coll.title}</h2>
<p>${coll.description}</p>
<div class="card-list">
${collection(Object.values(data.notebooks), coll.tags).map(
(entry) => html`<${FeaturedCard} entry=${entry} source_url=${data.source_url} />`
${collection(Object.values(data.notebooks), coll.tags ?? []).map(
(entry) =>
html`<${FeaturedCard} entry=${entry} source_url=${data.source_url} direct_html_links=${direct_html_links} />`
)}
</div>
</div>
`
})}
</div>
`
)}
})}
`
}

const collection = (/** @type {SourceManifestNotebookEntry[]} */ notebooks, /** @type {String[]} */ tags) => {
const nbs = notebooks.filter((notebook) => tags.some((t) => (notebook.frontmatter?.tags ?? []).includes(t)))
register(Featured, "pluto-featured", ["sources", "direct_html_links"])

/** Return all notebook entries that have at least one of the given `tags`. Notebooks are sorted on `notebook.frontmatter.order` or `notebook.id`. */
const collection = (/** @type {SourceManifestNotebookEntry[]} */ notebooks, /** @type {String[] | "everything"} */ tags) => {
const nbs = tags === "everything" ? notebooks : notebooks.filter((notebook) => tags.some((t) => (notebook.frontmatter?.tags ?? []).includes(t)))

return /** @type {SourceManifestNotebookEntry[]} */ (_.sortBy(nbs, [(nb) => Number(nb?.frontmatter?.order), "id"]))
let n = (s) => (isNaN(s) ? s : Number(s))
return /** @type {SourceManifestNotebookEntry[]} */ (_.sortBy(nbs, [(nb) => n(nb?.frontmatter?.order), "id"]))
}
43 changes: 29 additions & 14 deletions frontend/components/welcome/FeaturedCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,51 @@ const str_to_degree = (s) => ([...s].reduce((a, b) => a + b.charCodeAt(0), 0) *
/**
* @param {{
* entry: import("./Featured.js").SourceManifestNotebookEntry,
* source_url: String,
* source_url?: string,
* direct_html_links: boolean,
* }} props
*/
export const FeaturedCard = ({ entry, source_url }) => {
export const FeaturedCard = ({ entry, source_url, direct_html_links }) => {
const title = entry.frontmatter?.title
const u = (x) => (x == null ? null : new URL(x, source_url).href)

const href = with_query_params(`editor.html`, {
statefile: u(entry.statefile_path),
notebookfile: u(entry.notebookfile_path),
notebookfile_integrity: `sha256-${base64url_to_base64(entry.hash)}`,
disable_ui: `true`,
pluto_server_url: `.`,
name: title == null ? null : `sample ${title}`,
})
const u = (/** @type {string | null | undefined} */ x) =>
source_url == null
? x
: x == null
? null
: // URLs are relative to the source URL...
new URL(
x,
// ...and the source URL is relative to the current location
new URL(source_url, window.location.href)
).href

// `direct_html_links` means that we will navigate you directly to the exported HTML file. Otherwise, we use our local editor, with the exported state as parameters. This lets users run the featured notebooks locally.
const href = direct_html_links
? u(entry.html_path)
: with_query_params(`editor.html`, {
statefile: u(entry.statefile_path),
notebookfile: u(entry.notebookfile_path),
notebookfile_integrity: `sha256-${base64url_to_base64(entry.hash)}`,
disable_ui: `true`,
pluto_server_url: `.`,
name: title == null ? null : `sample ${title}`,
})

const author = author_info(entry.frontmatter)

return html`
<featured-card style=${`--card-color-hue: ${str_to_degree(entry.id)}deg;`}>
<a class="banner" href=${href}><img src=${u(entry.frontmatter.image) ?? transparent_svg} /></a>
<a class="banner" href=${href}><img src=${u(entry?.frontmatter?.image) ?? transparent_svg} /></a>
${author?.name == null
? null
: html`
<div class="author">
<a href=${author.url}> <img src=${author.image ?? transparent_svg} /><span>${author.name}</span></a>
</div>
`}
<h3><a href=${href} title=${entry.frontmatter.title}>${entry.frontmatter.title}</a></h3>
<p title=${entry.frontmatter.description}>${entry.frontmatter.description}</p>
<h3><a href=${href} title=${entry?.frontmatter?.title}>${entry?.frontmatter?.title ?? entry.id}</a></h3>
<p title=${entry?.frontmatter?.description}>${entry?.frontmatter?.description}</p>
</featured-card>
`
}
Expand Down
Loading