Commit d4049dde authored by Christophe Benz's avatar Christophe Benz
Browse files

Implement content pages

parent bcf20715
......@@ -27,6 +27,12 @@ pages:
- pageSlug: data
buttonTitle: See all data
limit: 8
- slug: program
appBarTitle: Program
source:
type: discourse
url: https://forum.datafin.fr
topic: 8 # ID or slug
- slug: challenges
appBarTitle: Challenges
title: Challenges of the hackathon
......
......@@ -5579,6 +5579,12 @@
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=",
"dev": true
},
"ramda": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz",
"integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==",
"dev": true
},
"rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
......
......@@ -34,6 +34,7 @@
"nodemon": "^1.19.3",
"npm-run-all": "^4.1.5",
"postcss-import": "^12.0.1",
"ramda": "^0.26.1",
"rollup": "^1.12.0",
"rollup-plugin-babel": "^4.0.2",
"rollup-plugin-commonjs": "^10.0.0",
......@@ -49,4 +50,4 @@
"tailwindcss": "^1.1.2",
"tailwindcss-elevation": "^0.3.3"
}
}
}
\ No newline at end of file
<script>
import { Button, Card, Icon, Image } from "smelte"
export let counts = []
export let html = null
export let imageUrl = null
export let text = null
export let title = null
export let url = null
</script>
<Card.Card class="bg-white w-full sm:h-full">
<div slot="title">
{#if title}
<Card.Title {title} />
{/if}
</div>
<div slot="media">
{#if imageUrl}
<Image class="w-full" src={imageUrl} alt="image" />
{/if}
</div>
<div slot="text">
{#if html}
<div class="text-html p-5 pb-0 pt-3 text-gray-700 body-2">
{@html html}
</div>
{:else if text}
<div class="p-5 pb-0 pt-3 text-gray-700 body-2">{text}</div>
{/if}
</div>
<div slot="actions" class="mt-auto">
{#if counts.length || url}
<div class="px-2 py-3 flex items-center">
{#each counts as { count, icon, title } (icon)}
{#if count}
<div class="mx-3 inline-flex" {title}>
{count}
<Icon>{icon}</Icon>
</div>
{/if}
{/each}
<div class="ml-auto">
{#if url}
<Button text href={url} target="_blank">View</Button>
{/if}
</div>
</div>
{/if}
</div>
</Card.Card>
<script>
import { Button, Card, Icon, Image } from "smelte"
import { omit } from "ramda"
import Card from "./Card.svelte"
import Grid from "./Grid.svelte"
export let data
export let items
$: sortedItems = items.sort((a, b) =>
a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1
)
</script>
<div class="cards px-2">
<div class="flex flex-wrap -mx-2">
{#each data as { id, html, title, text, url, counts, imageUrl } (id)}
<div class="w-full mb-4 sm:w-1/3 sm:px-2 lg:w-1/4">
<Card.Card class="bg-white w-full sm:h-full">
<div slot="title">
{#if title}
<Card.Title {title} />
{/if}
</div>
<div slot="media">
<Image class="w-full" src={imageUrl} alt="image" />
</div>
<div slot="text">
{#if html}
<div class="p-5 pb-0 pt-3 text-gray-700 body-2">
{@html html}
</div>
{:else if text}
<div class="p-5 pb-0 pt-3 text-gray-700 body-2">{text}</div>
{/if}
</div>
<div slot="actions" class="mt-auto">
{#if (counts && counts.length) || url}
<div class="px-2 py-3 flex items-center">
{#each counts as { count, icon, title } (icon)}
{#if count}
<div class="mx-3 inline-flex" {title}>
{count}
<Icon>{icon}</Icon>
</div>
{/if}
{/each}
<div class="ml-auto">
<Button text href={url} target="_blank">View</Button>
</div>
</div>
{/if}
</div>
</Card.Card>
</div>
{/each}
</div>
</div>
<Grid items={sortedItems} let:item>
<Card {...omit(["id"], item)} />
</Grid>
<script>
export let items
export let responsiveClasses = "sm:w-1/3 sm:px-2 lg:w-1/4"
</script>
<div class="px-2">
<div class="flex flex-wrap -mx-2">
{#each items as item}
<div class={`w-full mb-4 ${responsiveClasses}`}>
<slot {item} />
</div>
{/each}
</div>
</div>
<script>
export let title
</script>
<div class="container mx-auto p-8">
<slot name="title">
{#if title}
<h1 class="h2 my-8">{title}</h1>
{/if}
</slot>
<slot />
</div>
<script>
import Cards from "./Cards.svelte"
export let pageData
export let pageType
</script>
{#if pageType === "cards"}
{#if pageData && pageData.length}
<Cards items={pageData} />
{/if}
{:else if pageType === "content"}
{#if pageData.text}
{pageData.text}
{:else if pageData.html}
<div class="text-html">
{@html pageData.html}
</div>
{/if}
{/if}
import OError from "@overleaf/o-error"
import { ConfigError, FetchError } from "./errors.js"
export class ConfigError extends OError {
constructor(options) {
super({ message: "Config error", ...options })
export function getPageType(source) {
if (source.category) {
return "cards"
} else if (source.topic) {
return "content"
} else {
throw new ConfigError({
message: `Missing "category" or "topic" property in "${source.type}" source`,
info: { source }
})
}
}
export class FetchError extends OError {
constructor(options) {
super({ message: "Fetch error", ...options })
export async function fetchCardsPageData(source, options) {
const topicsResults = await fetchCategoryTopics.bind(this)(source, options)
if (topicsResults && topicsResults.topic_list && topicsResults.topic_list.topics) {
return topicsResults.topic_list.topics.map(topic => topicToCardData(source, topic))
}
}
export function discourseTopicToCardData(source, topic) {
export async function fetchContentPageData(source) {
return topicToContentData(await fetchTopic.bind(this)(source))
}
export function topicToCardData(source, topic) {
return {
...topic,
counts: [
{
count: topic.like_count,
......@@ -27,78 +38,30 @@ export function discourseTopicToCardData(source, topic) {
title: "Replies"
},
],
id: topic.id,
imageUrl: topic.image_url,
html: topic.excerpt,
// title already exist in `topic`
title: topic.title,
url: `${source.url}/t/${topic.slug}`
}
}
export function gitlabProjectToCardData(project) {
export function topicToContentData(topic) {
const firstPost = topic.post_stream.posts[0]
return {
...project,
counts: [
{
count: project.star_count,
icon: "star", // Cf https://material.io/resources/icons
title: "Stars"
},
{
count: project.forks_count,
icon: "call_split", // Cf https://material.io/resources/icons
title: "Forks"
}
],
imageUrl: project.avatar_url,
text: project.description,
title: project.name,
url: project.web_url,
html: firstPost.cooked,
title: topic.title,
}
}
export async function fetchZoneCards(source, zone = {}) {
let results
switch (source.type) {
case "discourse":
if (source.category) {
const topicsResults = await fetchDiscourseCategoryTopics.bind(this)(source, zone)
if (topicsResults && topicsResults.topic_list && topicsResults.topic_list.topics) {
results = topicsResults.topic_list.topics.map(topic => discourseTopicToCardData(source, topic))
}
} else if (source.topic) {
results = discourseTopicToCardData(
source,
await fetchDiscourseTopic.bind(this)(source)
)
} else {
throw new ConfigError({
message: `Missing "category" or "topic" property in ${source.type} not supported`,
info: { source, zone }
})
}
break
case "gitlab": {
const projectsResults = await fetchGitLabGroupProjects.bind(this)(source, zone)
results = projectsResults.map(gitlabProjectToCardData)
break
}
default:
throw new ConfigError({
message: `Provider ${source.type} not supported`,
info: { source, zone }
})
}
return results && results.sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase()) ? 1 : -1)
}
export async function fetchDiscourseCategoryTopics(source, zone) {
export async function fetchCategoryTopics(source, options = {}) {
const { url: baseUrl, category } = source
const { limit = null } = zone
const { limit = null } = options
const url = `${baseUrl}/c/${category}.json`
let result = null
for (let page = 0; ; page++) {
const pageUrl = `${url}?page=${page}`
const info = { url, pageUrl, source, zone }
const info = { url, pageUrl, source, options }
let response
try {
response = await this.fetch(pageUrl)
......@@ -137,9 +100,9 @@ export async function fetchDiscourseCategoryTopics(source, zone) {
}
}
export async function fetchDiscourseTopic(source) {
const { url: baseUrl, topic, id } = source
const url = `${baseUrl}/t/${topic}/${id}.json`
export async function fetchTopic(source) {
const { url: baseUrl, topic } = source
const url = `${baseUrl}/t/${topic}.json`
const info = { url, source }
let response
try {
......@@ -149,7 +112,7 @@ export async function fetchDiscourseTopic(source) {
}
} catch (error) {
throw new FetchError({
message: `Error while fetching Discourse topic "${topic}/${id}"`,
message: `Error while fetching Discourse topic "${topic}"`,
info,
}).withCause(error)
}
......@@ -157,40 +120,8 @@ export async function fetchDiscourseTopic(source) {
return await response.json()
} catch (error) {
throw new FetchError({
message: `Error while parsing JSON of Discourse topic "${topic}/${id}"`,
info,
}).withCause(error)
}
}
export async function fetchGitLabGroupProjects(source, zone) {
const { url: baseUrl, group } = source
const {
limit = -1, // means no limit in GitLab API
orderBy = "name"
} = zone
const url = `${baseUrl}/api/v4/groups/${encodeURIComponent(group)}/projects?per_page=${limit}&order_by=${orderBy}&sort=asc`
const info = { url, source }
let response
try {
response = await this.fetch(url)
if (!response.ok) {
throw response
}
} catch (error) {
throw new FetchError({
message: `Error while fetching GitLab group "${group}"`,
info,
}).withCause(error)
}
let projects
try {
projects = await response.json()
} catch (error) {
throw new FetchError({
message: `Error while parsing JSON of GitLab group "${group}"`,
message: `Error while parsing JSON of Discourse topic "${topic}"`,
info,
}).withCause(error)
}
return projects
}
import OError from "@overleaf/o-error"
export class ConfigError extends OError {
constructor(options) {
super({ message: "Config error", ...options })
}
}
export class FetchError extends OError {
constructor(options) {
super({ message: "Fetch error", ...options })
}
}
\ No newline at end of file
import { ConfigError, FetchError } from "./errors.js"
export function getPageType(source) {
if (source.group) {
return "cards"
} else if (source.project) {
return "content"
} else {
throw new ConfigError({
message: `Missing "group" or "project" property in "${source.type}" source`,
info: { source }
})
}
}
export async function fetchCardsPageData(source, options) {
return (await fetchGroupProjects.bind(this)(source, options)).map(projectToCardData)
}
export async function fetchContentPageData(source) {
throw new Error("Not implemented: should return data for a content page, based on the README")
}
export function projectToCardData(project) {
return {
counts: [
{
count: project.star_count,
icon: "star", // Cf https://material.io/resources/icons
title: "Stars"
},
{
count: project.forks_count,
icon: "call_split", // Cf https://material.io/resources/icons
title: "Forks"
}
],
id: project.id,
imageUrl: project.avatar_url,
text: project.description,
title: project.name,
url: project.web_url,
}
}
export async function fetchGroupProjects(source, options = {}) {
const { url: baseUrl, group } = source
const {
limit = -1, // means no limit in GitLab API
orderBy = "name"
} = options
const url = `${baseUrl}/api/v4/groups/${encodeURIComponent(group)}/projects?per_page=${limit}&order_by=${orderBy}&sort=asc`
const info = { url, source }
let response
try {
response = await this.fetch(url)
if (!response.ok) {
throw response
}
} catch (error) {
throw new FetchError({
message: `Error while fetching GitLab group "${group}"`,
info,
}).withCause(error)
}
let projects
try {
projects = await response.json()
} catch (error) {
throw new FetchError({
message: `Error while parsing JSON of GitLab group "${group}"`,
info,
}).withCause(error)
}
return projects
}
export * from "./errors.js"
import * as discourse from "./discourse.js"
import * as gitlab from "./gitlab.js"
import { ConfigError } from "./errors.js"
const fetchers = { discourse, gitlab }
export function loadFetcher(sourceType) {
const fetcher = fetchers[sourceType]
if (!fetcher) {
throw new ConfigError({
message: `Source type "${sourceType}" not supported`,
info: { sourceType }
})
}
return fetcher
}
export async function fetchPageData(pageType, fetcher, source, options) {
if (pageType === "cards") {
return await fetcher.fetchCardsPageData.bind(this)(source, options)
} else if (pageType === "content") {
return await fetcher.fetchContentPageData.bind(this)(source)
}
}
<script context="module">
import { fetchZoneCards, ConfigError, FetchError } from "./_fetchers.js"
import {
loadFetcher,
fetchPageData,
ConfigError,
FetchError
} from "../fetchers"
export async function preload(page, session) {
const { slug } = page.params
......@@ -7,39 +12,50 @@
if (!pageConfig) {
return this.error(404, "Page not found")
}
let cardsData
if (pageConfig.source) {
const { source } = pageConfig
let properties = { pageConfig }
if (source) {
let fetcher
try {
cardsData = await fetchZoneCards.bind(this)(pageConfig.source)
fetcher = loadFetcher(source.type)
} catch (e) {
if (e instanceof ConfigError || e instanceof FetchError) {
if (e instanceof ConfigError) {
console.error(e)
return this.error(500, e.message)
}
throw e
}
const pageType = fetcher.getPageType(source)
properties.pageType = pageType
try {
properties.pageData = await fetchPageData.bind(this)(
pageType,
fetcher,
source
)
} catch (e) {
if (e instanceof FetchError) {
console.error(e)
return this.error(500, e.message)
}
throw e
}
}
return { ...pageConfig, cardsData }
return properties
}
</script>
<script>
import Cards from "../components/Cards.svelte"
export let cardsData
export let slug
export let appBarTitle
export let title
export let newItemButtonTitle
export let source
</script>
import Page from "../components/Page.svelte"
import Zone from "../components/Zone.svelte"
<div class="container mx-auto p-8">
export let pageConfig
export let pageData
export let pageType
<h1 class="h2 my-8">{title}</h1>
{#if cardsData && cardsData.length}
<Cards data={cardsData} />
{/if}
$: title = pageConfig.title || pageData.title
</script>
</div>
<Page {title}>
<Zone {pageData} {pageType} />
</Page>
<script context="module">
import { fetchZoneCards, ConfigError, FetchError } from "./_fetchers.js"
import {
loadFetcher,
fetchPageData,
ConfigError,
FetchError
} from "../fetchers"
async function loadZoneCardData(pages, zone) {
async function loadZonePageData(zone, pages) {
// Resolve pageSlug reference
const zonePageConfig = pages.find(page1 => page1.slug === zone.pageSlug)
let cardsData