Compare commits
3 Commits
fivethirty
...
part-3-tut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb567e88d | ||
|
|
77da0d5693 | ||
|
|
b52aa38249 |
@@ -1,6 +1,8 @@
|
||||
This demo data portal is designed for https://hatespeechdata.com. It catalogs datasets annotated for hate speech, online abuse, and offensive language which are useful for training a natural language processing system to detect this online abuse.
|
||||
## Intro
|
||||
|
||||
The site is built on top of [PortalJS](https://portaljs.org/). It catalogs datasets and lists of offensive keywords. It also includes static pages. All of these are stored as markdown files inside the `content` folder.
|
||||
This page catalogues datasets annotated for hate speech, online abuse, and offensive language. They may be useful for e.g. training a natural language processing system to detect this language.
|
||||
|
||||
Its built on top of [PortalJS](https://portaljs.org/), it allows you to publish datasets, lists of offensive keywords and static pages, all of those are stored as markdown files inside the `content` folder.
|
||||
|
||||
- .md files inside `content/datasets/` will appear on the dataset list section of the homepage and be searchable as well as having a individual page in `datasets/<file name>`
|
||||
- .md files inside `content/keywords/` will appear on the list of offensive keywords section of the homepage as well as having a individual page in `keywords/<file name>`
|
||||
|
||||
@@ -1,41 +1,8 @@
|
||||
import { Fragment, useEffect, useRef } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRef } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Popover, Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { Container } from '../components/Container'
|
||||
|
||||
function CloseIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="m17.25 6.75-10.5 10.5M6.75 6.75l10.5 10.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 8 6" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M1.75 1.75 4 4.25l2.25-2.5"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SunIcon(props) {
|
||||
return (
|
||||
<svg
|
||||
@@ -68,125 +35,6 @@ function MoonIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function GithubIcon(props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 fill-slate-900 dark:fill-zinc-200"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.463 2 11.97c0 4.404 2.865 8.14 6.839 9.458.5.092.682-.216.682-.48 0-.236-.008-.864-.013-1.695-2.782.602-3.369-1.337-3.369-1.337-.454-1.151-1.11-1.458-1.11-1.458-.908-.618.069-.606.069-.606 1.003.07 1.531 1.027 1.531 1.027.892 1.524 2.341 1.084 2.91.828.092-.643.35-1.083.636-1.332-2.22-.251-4.555-1.107-4.555-4.927 0-1.088.39-1.979 1.029-2.675-.103-.252-.446-1.266.098-2.638 0 0 .84-.268 2.75 1.022A9.607 9.607 0 0 1 12 6.82c.85.004 1.705.114 2.504.336 1.909-1.29 2.747-1.022 2.747-1.022.546 1.372.202 2.386.1 2.638.64.696 1.028 1.587 1.028 2.675 0 3.83-2.339 4.673-4.566 4.92.359.307.678.915.678 1.846 0 1.332-.012 2.407-.012 2.734 0 .267.18.577.688.48 3.97-1.32 6.833-5.054 6.833-9.458C22 6.463 17.522 2 12 2Z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNavItem({ href, children }) {
|
||||
return (
|
||||
<li>
|
||||
<Popover.Button
|
||||
as={Link}
|
||||
href={href}
|
||||
className="flex items-center gap-x-2 py-2"
|
||||
>
|
||||
{children}
|
||||
</Popover.Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNavigation(props) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<Popover.Button className="group flex items-center rounded-full bg-white/90 px-4 py-2 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10 dark:hover:ring-white/20">
|
||||
Menu
|
||||
<ChevronDownIcon className="ml-3 h-auto w-2 stroke-zinc-500 group-hover:stroke-zinc-700 dark:group-hover:stroke-zinc-400" />
|
||||
</Popover.Button>
|
||||
<Transition.Root>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Popover.Overlay className="fixed inset-0 z-50 bg-zinc-800/40 backdrop-blur-sm dark:bg-black/80" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel
|
||||
focus
|
||||
className="fixed inset-x-4 top-8 z-50 origin-top rounded-3xl bg-white p-8 ring-1 ring-zinc-900/5 dark:bg-zinc-900 dark:ring-zinc-800"
|
||||
>
|
||||
<div className="flex flex-row-reverse items-center justify-between">
|
||||
<Popover.Button aria-label="Close menu" className="-m-1 p-1">
|
||||
<CloseIcon className="h-6 w-6 text-zinc-500 dark:text-zinc-400" />
|
||||
</Popover.Button>
|
||||
<h2 className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
Navigation
|
||||
</h2>
|
||||
</div>
|
||||
<nav className="mt-6">
|
||||
<ul className="-my-2 divide-y divide-zinc-100 text-base text-zinc-800 dark:divide-zinc-100/5 dark:text-zinc-300">
|
||||
<MobileNavItem href="https://github.com/leondz/hatespeechdata">
|
||||
View on Github <GithubIcon />
|
||||
</MobileNavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
</Popover.Panel>
|
||||
</Transition.Child>
|
||||
</Transition.Root>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({ href, children }) {
|
||||
let isActive = useRouter().pathname === href
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={href}
|
||||
className={clsx(
|
||||
'relative flex items-center gap-x-2 px-3 py-2 transition',
|
||||
isActive
|
||||
? 'text-teal-500 dark:text-teal-400'
|
||||
: 'hover:text-teal-500 dark:hover:text-teal-400'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{isActive && (
|
||||
<span className="absolute inset-x-1 -bottom-px h-px bg-gradient-to-r from-teal-500/0 via-teal-500/40 to-teal-500/0 dark:from-teal-400/0 dark:via-teal-400/40 dark:to-teal-400/0" />
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopNavigation(props) {
|
||||
return (
|
||||
<nav {...props}>
|
||||
<ul className="flex rounded-full bg-white/90 px-3 text-sm font-medium text-zinc-800 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur dark:bg-zinc-800/90 dark:text-zinc-200 dark:ring-white/10">
|
||||
<NavItem href="https://github.com/leondz/hatespeechdata">
|
||||
View on Github <GithubIcon />
|
||||
</NavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeToggle() {
|
||||
function disableTransitionsTemporarily() {
|
||||
document.documentElement.classList.add('[&_*]:!transition-none')
|
||||
@@ -222,13 +70,11 @@ function ModeToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(number, a, b) {
|
||||
let min = Math.min(a, b)
|
||||
let max = Math.max(a, b)
|
||||
return Math.min(Math.max(number, min), max)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
let isHomePage = useRouter().pathname === '/'
|
||||
|
||||
let headerRef = useRef()
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@@ -239,6 +85,7 @@ export function Header() {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="top-0 z-10 h-16 pt-6"
|
||||
style={{ position: 'var(--header-position)' }}
|
||||
>
|
||||
@@ -247,10 +94,6 @@ export function Header() {
|
||||
style={{ position: 'var(--header-inner-position)' }}
|
||||
>
|
||||
<div className="relative flex gap-4">
|
||||
<div className="flex flex-1">
|
||||
<MobileNavigation className="pointer-events-auto md:hidden" />
|
||||
<DesktopNavigation className="pointer-events-auto hidden md:block" />
|
||||
</div>
|
||||
<div className="flex justify-end md:flex-1">
|
||||
<div className="pointer-events-auto">
|
||||
<ModeToggle />
|
||||
@@ -260,6 +103,7 @@ export function Header() {
|
||||
</Container>
|
||||
</div>
|
||||
</header>
|
||||
{isHomePage && <div style={{ height: 'var(--content-offset)' }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
34
examples/alan-turing-portal/content/index.md
Normal file
34
examples/alan-turing-portal/content/index.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Hate Speech Dataset Catalogue
|
||||
---
|
||||
|
||||
This page catalogues datasets annotated for hate speech, online abuse, and offensive language. They may be useful for e.g. training a natural language processing system to detect this language.
|
||||
|
||||
The list is maintained by Leon Derczynski, Bertie Vidgen, Hannah Rose Kirk, Pica Johansson, Yi-Ling Chung, Mads Guldborg Kjeldgaard Kongsbak, Laila Sprejer, and Philine Zeinert.
|
||||
|
||||
We provide a list of datasets and keywords. If you would like to contribute to our catalogue or add your dataset, please see the instructions for contributing.
|
||||
|
||||
If you use these resources, please cite (and read!) our paper: Directions in Abusive Language Training Data: Garbage In, Garbage Out. And if you would like to find other resources for researching online hate, visit The Alan Turing Institute’s Online Hate Research Hub or read The Alan Turing Institute’s Reading List on Online Hate and Abuse Research.
|
||||
|
||||
If you’re looking for a good paper on online hate training datasets (beyond our paper, of course!) then have a look at ‘Resources and benchmark corpora for hate speech detection: a systematic review’ by Poletto et al. in Language Resources and Evaluation.
|
||||
|
||||
## How to contribute
|
||||
|
||||
We accept entries to our catalogue based on pull requests to the content folder. The dataset must be avaliable for download to be included in the list. If you want to add an entry, follow these steps!
|
||||
|
||||
Please send just one dataset addition/edit at a time - edit it in, then save. This will make everyone’s life easier (including yours!)
|
||||
|
||||
- Go to the repo url file and click the "Add file" dropdown and then click on "Create new file".
|
||||

|
||||
|
||||
- In the following page type `content/datasets/<name-of-the-file>.md`. if you want to add an entry to the datasets catalog or `content/keywords/<name-of-the-file>.md` if you want to add an entry to the lists of abusive keywords, if you want to just add an static page you can leave in the root of `content` it will automatically get assigned an url eg: `/content/about.md` becomes the `/about` page
|
||||

|
||||
|
||||
- Copy the contents of `templates/dataset.md` or `templates/keywords.md` respectively to the camp below, filling out the fields with the correct data format
|
||||

|
||||
|
||||
- Click on "Commit changes", on the popup make sure you give some brief detail on the proposed change. and then click on Propose changes
|
||||
<img src='https://i.imgur.com/BxuxKEJ.png' style={{ maxWidth: '50%', margin: '0 auto' }}/>
|
||||
|
||||
- Submit the pull request on the next page when prompted.
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
title: Hate Speech Dataset Catalogue
|
||||
---
|
||||
|
||||
This page catalogues datasets annotated for hate speech, online abuse, and offensive language. They may be useful for e.g. training a natural language processing system to detect this language.
|
||||
|
||||
The list is maintained by [Leon Derczynski](https://www.derczynski.com/), [Bertie Vidgen](https://www.turing.ac.uk/people/researchers/bertie-vidgen), [Hannah Rose Kirk](https://www.hannahrosekirk.com/), Pica Johansson, [Yi-Ling Chung](https://yilingchung.github.io/), Mads Guldborg Kjeldgaard Kongsbak, [Laila Sprejer](https://www.turing.ac.uk/people/researchers/laila-sprejer), and Philine Zeinert.
|
||||
|
||||
We provide a list of [datasets](#Datasets-header) and [keywords](#Keywords-header). If you would like to contribute to our catalogue or add your dataset, please see the [instructions for contributing](#Contributing-header).
|
||||
|
||||
If you use these resources, please cite (and read!) our paper: [Directions in Abusive Language Training Data: Garbage In, Garbage Out](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0243300). And if you would like to find other resources for researching online hate, visit The Alan Turing Institute's [Online Hate Research Hub](https://www.turing.ac.uk/research/research-programmes/public-policy/online-hate-research-hub) or read The Alan Turing Institute's [Reading List on Online Hate and Abuse Research](https://docs.google.com/document/d/1WVkVGp29Jt6d-4fBnZ5OWVYuFn_03rzz-KBqPsu6gTM/edit?usp=sharing).
|
||||
|
||||
If you're looking for a good paper on online hate training datasets (beyond our paper, of course!) then have a look at ['Resources and benchmark corpora for hate speech detection: a systematic review'](https://link.springer.com/article/10.1007/s10579-020-09502-8) by Poletto et al. in *Language Resources and Evaluation*.
|
||||
|
||||
Accompanying [data statements](https://www.mitpressjournals.org/doi/abs/10.1162/tacl_a_00041) preferred for all corpora.
|
||||
|
||||
<a href="#Datasets-header" className="w-fit mx-auto no-underline rounded-md py-3 px-6 outline-offset-2 transition !active:transition-none bg-zinc-800 !font-semibold !text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 !dark:active:bg-zinc-700 dark:active:text-zinc-100/70">See datasets</a>
|
||||
|
||||
<h2 id="Contributing-header">How to contribute</h2>
|
||||
|
||||
We accept entries to our catalogue based on pull requests to the content folder. The dataset must be avaliable for download to be included in the list. If you want to add an entry, follow these steps!
|
||||
|
||||
Please send just one dataset addition/edit at a time - edit it in, then save. This will make everyone’s life easier (including yours!)
|
||||
|
||||
### Create file
|
||||
|
||||
Go to the repo url file and click the "Add file" dropdown and then click on "Create new file".
|
||||
|
||||

|
||||
|
||||
### Choose location
|
||||
|
||||
In the following page type `content/datasets/<name-of-the-file>.md`. if you want to add an entry to the datasets catalog or `content/keywords/<name-of-the-file>.md` if you want to add an entry to the lists of abusive keywords, if you want to just add an static page you can leave in the root of `content` it will automatically get assigned an url eg: `/content/about.md` becomes the `/about` page
|
||||
|
||||

|
||||
|
||||
### Fill in content
|
||||
|
||||
Copy the contents of `templates/dataset.md` or `templates/keywords.md` respectively to the camp below, filling out the fields with the correct data format
|
||||
|
||||

|
||||
|
||||
### Commit changes
|
||||
|
||||
Click on "Commit changes", on the popup make sure you give some brief detail on the proposed change. and then click on Propose changes
|
||||
|
||||
<img src='https://i.imgur.com/BxuxKEJ.png' style={{ maxWidth: '50%', margin: '0 auto' }}/>
|
||||
|
||||
### Submit PR
|
||||
|
||||
Submit the pull request on the next page when prompted.
|
||||
|
||||
Binary file not shown.
@@ -27,8 +27,13 @@
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@tanstack/react-table": "^8.8.5",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"clsx": "^1.2.1",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-next": "13.3.1",
|
||||
"fast-glob": "^3.2.11",
|
||||
"feed": "^4.2.2",
|
||||
"flexsearch": "^0.7.31",
|
||||
@@ -63,9 +68,6 @@
|
||||
"eslint": "8.26.0",
|
||||
"eslint-config-next": "13.0.2",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.2.6",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0"
|
||||
"prettier-plugin-tailwindcss": "^0.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Container } from '../components/Container'
|
||||
import clientPromise from '../lib/mddb'
|
||||
import { promises as fs } from 'fs';
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import { Card } from '../components/Card'
|
||||
import Head from 'next/head'
|
||||
import parse from '../lib/markdown'
|
||||
import { Mermaid } from '@flowershow/core';
|
||||
import { Header } from '../components/Header';
|
||||
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const urlPath = params.slug ? params.slug.join('/') : ''
|
||||
@@ -82,12 +82,10 @@ export default function DRDPage({ mdxSource }) {
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Head>
|
||||
<title>{meta.title}</title>
|
||||
</Head>
|
||||
<Container className="mt-16 lg:mt-32 relative">
|
||||
<Header />
|
||||
<Container className="mt-16 lg:mt-32">
|
||||
<article>
|
||||
<header className="flex flex-col">
|
||||
<h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Head, Html, Main, NextScript } from 'next/document'
|
||||
|
||||
const modeScript = `
|
||||
let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
updateMode()
|
||||
darkModeMediaQuery.addEventListener('change', updateModeWithoutTransitions)
|
||||
window.addEventListener('storage', updateModeWithoutTransitions)
|
||||
|
||||
function updateMode() {
|
||||
let isDarkMode = window.localStorage.isDarkMode === 'true'
|
||||
let isSystemDarkMode = darkModeMediaQuery.matches
|
||||
let isDarkMode = window.localStorage.isDarkMode === 'true' || (!('isDarkMode' in window.localStorage) && isSystemDarkMode)
|
||||
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
if (isDarkMode === isSystemDarkMode) {
|
||||
delete window.localStorage.isDarkMode
|
||||
}
|
||||
}
|
||||
|
||||
function disableTransitionsTemporarily() {
|
||||
|
||||
@@ -13,9 +13,7 @@ import { MDXRemote } from 'next-mdx-remote'
|
||||
function DatasetCard({ dataset }) {
|
||||
return (
|
||||
<Card as="article">
|
||||
<Card.Title>
|
||||
<Link href={dataset.url}>{dataset.title}</Link>
|
||||
</Card.Title>
|
||||
<Card.Title><Link href={dataset.url}>{dataset.title}</Link></Card.Title>
|
||||
<Card.Description>
|
||||
<span className="font-semibold">Link to publication: </span>{' '}
|
||||
<a
|
||||
@@ -77,9 +75,7 @@ function DatasetCard({ dataset }) {
|
||||
function ListOfAbusiveKeywordsCard({ list }) {
|
||||
return (
|
||||
<Card as="article">
|
||||
<Card.Title>
|
||||
<Link href={list.url}>{list.title}</Link>
|
||||
</Card.Title>
|
||||
<Card.Title><Link href={list.url}>{list.title}</Link></Card.Title>
|
||||
{list.description && (
|
||||
<Card.Description>
|
||||
<span className="font-semibold">List Description: </span>{' '}
|
||||
@@ -143,23 +139,17 @@ export default function Home({
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
{indexText.frontmatter.title}
|
||||
</h1>
|
||||
<article className="index-text prose mt-6 flex flex-col gap-y-2 text-base text-zinc-600 dark:prose-invert prose-h3:mt-4 prose-a:font-normal prose-a:text-zinc-600 prose-a:decoration-inherit prose-img:rounded-none dark:text-zinc-400 prose-a:dark:text-zinc-400 hover:prose-a:text-teal-600 hover:prose-a:dark:text-teal-900">
|
||||
<article className="mt-6 index-text flex flex-col gap-y-2 text-base text-zinc-600 dark:text-zinc-400 prose dark:prose-invert">
|
||||
<MDXRemote {...indexText} />
|
||||
</article>
|
||||
</div>
|
||||
</Container>
|
||||
<Container className="mt-12 md:mt-14">
|
||||
<Container className="mt-24 md:mt-28">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-y-8 lg:max-w-none">
|
||||
<h2
|
||||
id="Datasets-header"
|
||||
className="text-xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl"
|
||||
>
|
||||
<h2 className="text-xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
Datasets
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={handleSubmit(() => reset())}
|
||||
className="rounded-2xl border border-zinc-100 px-4 py-6 dark:border-zinc-700/40 sm:p-6"
|
||||
>
|
||||
<form onSubmit={handleSubmit(() => reset())} className="rounded-2xl border border-zinc-100 px-4 py-6 dark:border-zinc-700/40 sm:p-6">
|
||||
<p className="mt-2 text-lg font-semibold text-zinc-600 dark:text-zinc-100">
|
||||
Search for datasets
|
||||
</p>
|
||||
@@ -208,12 +198,7 @@ export default function Home({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex flex-none items-center justify-center gap-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 outline-offset-2 transition hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 active:transition-none dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
<button type='submit' className='inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70 flex-none'>Clear filters</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex flex-col gap-16">
|
||||
@@ -240,7 +225,7 @@ export default function Home({
|
||||
</div>
|
||||
</Container>
|
||||
<Container className="mt-16">
|
||||
<h2 id="Keywords-header" className="text-xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
<h2 className="text-xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
Lists of Abusive Keywords
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-col gap-16">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./content/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
|
||||
@@ -10,24 +10,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.0.17",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/react": "18.0.38",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-next": "13.3.1",
|
||||
"next": "13.3.1",
|
||||
"next-seo": "^6.0.0",
|
||||
"octokit": "^2.0.14",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"remark-gfm": "^3.0.1"
|
||||
"remark-gfm": "^3.0.1",
|
||||
"typescript": "5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.23",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-next": "13.3.1",
|
||||
"typescript": "5.0.4",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/react": "18.0.38",
|
||||
"@types/react-dom": "18.0.11"
|
||||
"tailwindcss": "^3.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
35
examples/fiverthirtyeight-example/.gitignore
vendored
35
examples/fiverthirtyeight-example/.gitignore
vendored
@@ -1,35 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,38 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
4282
examples/fiverthirtyeight-example/package-lock.json
generated
4282
examples/fiverthirtyeight-example/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "fiverthirtyeight-example",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "20.1.1",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"autoprefixer": "10.4.14",
|
||||
"eslint": "8.40.0",
|
||||
"eslint-config-next": "13.4.1",
|
||||
"next": "13.4.1",
|
||||
"postcss": "8.4.23",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tailwindcss": "3.3.2",
|
||||
"timeago.js": "^4.0.2",
|
||||
"typescript": "5.0.4"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import '@/styles/globals.css'
|
||||
import type { AppProps } from 'next/app'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
type Data = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
res.status(200).json({ name: 'John Doe' })
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { format } from 'timeago.js'
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
interface Article {
|
||||
date: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Dataset {
|
||||
url: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
articles: Article[];
|
||||
}
|
||||
|
||||
export function MobileItem({dataset} : { dataset: Dataset}) {
|
||||
return (
|
||||
<div className="flex gap-x-2 pb-2 py-4 items-center justify-between border-b border-zinc-600">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-light">{dataset.name}</span>
|
||||
{dataset.articles.map((article) => (
|
||||
<div className='py-1 flex flex-col'>
|
||||
<span className="font-bold hover:underline">{article.title}</span>
|
||||
<span className="font-light text-base">{format(article.date)}</span>{' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col justify-start">
|
||||
<a
|
||||
className="border border-zinc-900 font-light px-4 py-1 text-sm transition hover:bg-zinc-900 hover:text-white"
|
||||
href={dataset.url}
|
||||
target="_blank"
|
||||
>
|
||||
info
|
||||
</a>
|
||||
{/*
|
||||
<button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-12 h-12 text-blue-400 hover:text-blue-300 transition mt-1"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-.53 14.03a.75.75 0 001.06 0l3-3a.75.75 0 10-1.06-1.06l-1.72 1.72V8.25a.75.75 0 00-1.5 0v5.69l-1.72-1.72a.75.75 0 00-1.06 1.06l3 3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopItem({dataset} : { dataset: Dataset}) {
|
||||
return (
|
||||
<>
|
||||
{dataset.articles.map((article, index) => (
|
||||
<tr className={`${index === (dataset.articles.length - 1) ? 'border-b' : ''} border-zinc-400`}>
|
||||
<td className="py-8 font-light">{index === 0 ? dataset.name : ''}</td>
|
||||
<td>
|
||||
<a className="py-8 font-bold hover:underline" href={article.url}>
|
||||
{article.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-8 font-light text-base min-w-[120px]">{format(article.date)}</td>
|
||||
<td className="py-8">
|
||||
{index === 0 && (
|
||||
<a
|
||||
className="border border-zinc-900 font-light px-[25px] py-2.5 text-sm transition hover:bg-zinc-900 hover:text-white"
|
||||
href={dataset.url}
|
||||
target="_blank"
|
||||
>
|
||||
info
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
{/*
|
||||
<td>
|
||||
<button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-12 h-12 text-blue-400 hover:text-blue-300 transition mt-1"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-.53 14.03a.75.75 0 001.06 0l3-3a.75.75 0 10-1.06-1.06l-1.72 1.72V8.25a.75.75 0 00-1.5 0v5.69l-1.72-1.72a.75.75 0 00-1.06 1.06l3 3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>*/}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStaticProps() {
|
||||
const jsonDirectory = path.join(
|
||||
process.cwd(),
|
||||
'/datasets.json'
|
||||
);
|
||||
const datasetString = await fs.readFile(jsonDirectory, 'utf8');
|
||||
const datasets = JSON.parse(datasetString)
|
||||
return {
|
||||
props: { datasets },
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home( { datasets }: { datasets: Dataset[] }) {
|
||||
return (
|
||||
<>
|
||||
<header className="max-w-5xl mx-auto mt-8 w-full">
|
||||
<div className="border-b-2 pb-2.5 mx-2 border-zinc-800">
|
||||
<h1>
|
||||
<span className="sr-only">FiveThirtyEight</span>
|
||||
<a className='flex gap-x-2 items-center' href="http://fivethirtyeight.com">
|
||||
<img
|
||||
width="197"
|
||||
height="25"
|
||||
alt="FiveThirtyEight"
|
||||
src="data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MjEgNTMuNzYiPjxkZWZzPjxzdHlsZT4uY2xzLTF7ZmlsbDojMDEwMTAxO308L3N0eWxlPjwvZGVmcz48dGl0bGU+QXJ0Ym9hcmQgOTU8L3RpdGxlPjxwYXRoIGNsYXNzPSJjbHMtMSIgZD0iTTAgMGgyNXY4SDl2MTBoMTV2OEg5djE3SDBWMHpNMzEgMzZoNVYxOGgtNXYtOGgxM3YyNmg0djdIMzF6bTUtMzZoOHY4aC04ek0xNzkgMzZoNVYxOGgtNXYtOGgxM3YyNmg0djdoLTE3em01LTM2aDh2OGgtOHpNMzE2IDM2aDVWMThoLTV2LThoMTN2MjZoNHY3aC0xN3ptNS0zNmg4djhoLTh6TTU0IDI3VjEwaDh2MTVsNCA5Ljk4aDFMNzEgMjVWMTBoOHYxN2wtNyAxNkg2MWwtNy0xNnpNMTExIDQzSDk3LjQyQzg5LjIzIDQzIDg1IDM5LjE5IDg1IDMxLjE3VjIyYzAtNy41NyA0LjMtMTMgMTMtMTMgOS4zMyAwIDEzIDUuMDcgMTMgMTR2N0g5NHYxLjc0YzAgMi42MiAxIDQuMjYgMy40MiA0LjI2SDExMXpNOTQgMjNoOHYtMS41NWMwLTIuNjItMS4wNi01LjQ1LTQuMTMtNS40NS0yLjc5IDAtMy44NyAyLjItMy44NyA1LjQ1ek0xMjUgOGgtMTBWMGgyOXY4aC0xMHYzNWgtOVY4ek0yMDIgNDNWMTBoOHY0YzEuMTQtMi40NSAzLjc1LTQgNy4yMi00SDIyMHY4aC02Yy0yLjg0IDAtNCAuOTQtNCAzLjlWNDN6TTI0NSA0M2gtNC44NEMyMzMuMDUgNDMgMjMwIDM5LjMxIDIzMCAzMS44NVYxOGgtNnYtOGg2VjNoOHY3aDd2OGgtN2wtLjA3IDEzLjkzYzAgMi4yMi45MyA0LjA3IDMuNjYgNC4wN0gyNDV6TTQyMSA0M2gtNC44NEM0MDkuMDUgNDMgNDA2IDM5LjMxIDQwNiAzMS44NVYxOGgtNnYtOGg2VjNoOHY3aDd2OGgtN2wtLjA3IDEzLjkzYzAgMi4yMi45MyA0LjA3IDMuNjYgNC4wN0g0MjF6TTI1NC4yNiA1My43Nmw0LjYxLTkuNUwyNTEgMjdWMTBoOHYxNWw0IDEwaDFsNC0xMFYxMGg4djE3bC0xMi4zIDI2Ljc2aC05LjQ0ek0yODQgMGgyNXY4aC0xNnY5aDE1djhoLTE1djEwaDE2djhoLTI1VjB6TTMzNyA0OHYtMmgxNi4xYzIgMCAyLjktLjE4IDIuOS0xLjI3di0uMzRjMC0xLjA4LS45MS0xLjM5LTIuOS0xLjM5SDM0MHYtNWw1LTVjLTUuMjktMS40OC04LTUuNDMtOC0xMXYtMWMwLTcuNTYgNC40NC0xMiAxNC0xMmEyMS45MyAyMS45MyAwIDAgMSA1Ljk1IDFMMzYxIDRsNSAzLTQgNmMxLjM3IDEuOTMgMyA0LjkzIDMgOHYxYzAgNy0zLjMgMTAuNjYtMTIgMTFsLTMgNGg2YzUuOTIgMCA5IDIuNjIgOSA3LjY4di4xMWMwIDUuMDYtMi43MSA4LjIxLTguNjIgOC4yMWgtMTNjLTQuMjkgMC02LjM4LTEuODQtNi4zOC01em0xOS0yNXYtM2MwLTMuMy0xLjMzLTQtNS00cy01IC43LTUgNHYzYzAgMy4zIDEuMzkgNCA1IDRzNS0uNyA1LTR6TTM4MCA0M2gtOFYwaDh2MTRjMS4xNC0yLjY3IDMuNC00IDctNCA2LjI2IDAgOSAzLjA4IDkgMTAuNzZWNDNoLThWMjJjMC0zLjEzLTEuMDctNS00LTVzLTQgMS44Ny00IDV6TTE1NyA0M2gtOFYwaDh2MTRjMS4xNC0yLjY3IDMuOTEtNCA3LjQ5LTQgNi4yNiAwIDguNTEgMy4xMyA4LjUxIDEwLjgxVjQzaC04VjIxYzAtMy4xMy0xLjA3LTQuNDQtNC00LjQ0cy00IDIuMjYtNCA1LjM5eiIvPjwvc3ZnPg=="
|
||||
/> by PortalJS
|
||||
</a>
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
className={`flex min-h-screen flex-col items-center max-w-5xl mx-auto pt-20 px-2.5 ${inter.className}`}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-[40px] font-bold text-zinc-800 text-center">
|
||||
Our Data
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg text-center text-zinc-700">
|
||||
We’re sharing the data and code behind some of our articles and
|
||||
graphics. We hope you’ll use it to check our work and to create
|
||||
stories and visualizations of your own.
|
||||
</p>
|
||||
</div>
|
||||
<article className="w-full px-2 md:hidden py-4">{datasets.map(dataset => <MobileItem dataset={dataset} />)}</article>
|
||||
<table className="w-full mt-10 mb-4 hidden md:table">
|
||||
<thead className="border-b-4 pb-2 border-zinc-900">
|
||||
<tr>
|
||||
<th className="uppercase text-left font-light text-xs pb-3">
|
||||
data set
|
||||
</th>
|
||||
<th className="uppercase text-left font-light text-xs pb-3">
|
||||
related content
|
||||
</th>
|
||||
<th className="uppercase text-left font-light text-xs pb-3">
|
||||
last updated
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{datasets.map(dataset => <DesktopItem dataset={dataset} />)}</tbody>
|
||||
</table>
|
||||
<p className="text-[13px] py-8">
|
||||
Unless otherwise noted, our data sets are available under the{' '}
|
||||
<a
|
||||
className="text-blue-400 hover:underline"
|
||||
href="http://creativecommons.org/licenses/by/4.0/"
|
||||
>
|
||||
Creative Commons Attribution 4.0 International license
|
||||
</a>
|
||||
, and the code is available under the{' '}
|
||||
<a
|
||||
className="text-blue-400 hover:underline"
|
||||
href="http://opensource.org/licenses/MIT"
|
||||
>
|
||||
MIT license
|
||||
</a>
|
||||
. If you find this information useful, please{' '}
|
||||
<a
|
||||
className="text-blue-400 hover:underline"
|
||||
href="mailto:data@fivethirtyeight.com"
|
||||
>
|
||||
let us know
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
Before Width: | Height: | Size: 629 B |
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,18 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
40
examples/learn-example/components/Catalog.tsx
Normal file
40
examples/learn-example/components/Catalog.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Index } from 'flexsearch';
|
||||
import { useState } from 'react';
|
||||
import DebouncedInput from './DebouncedInput';
|
||||
|
||||
export default function Catalog({ datasets }: { datasets: any[] }) {
|
||||
const [indexFilter, setIndexFilter] = useState('');
|
||||
const index = new Index({ tokenize: "full"});
|
||||
datasets.forEach((dataset) =>
|
||||
index.add(
|
||||
dataset._id,
|
||||
Object.entries(dataset.metadata).reduce(
|
||||
(acc, curr) => acc + ' ' + curr.toString(),
|
||||
''
|
||||
) + ' ' + dataset.url_path
|
||||
)
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<DebouncedInput
|
||||
value={indexFilter ?? ''}
|
||||
onChange={(value) => setIndexFilter(String(value))}
|
||||
className="p-2 text-sm shadow border border-block"
|
||||
placeholder="Search all datasets..."
|
||||
/>
|
||||
<ul>
|
||||
{datasets
|
||||
.filter((dataset) =>
|
||||
indexFilter !== ''
|
||||
? index.search(indexFilter).includes(dataset._id)
|
||||
: true
|
||||
)
|
||||
.map((dataset) => (
|
||||
<li key={dataset.id}>
|
||||
<a href={dataset.url_path}>{dataset.metadata.title ? dataset.metadata.title : dataset.url_path}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import { Mermaid } from '@flowershow/core';
|
||||
// to handle import statements. Instead, you must include components in scope
|
||||
// here.
|
||||
const components = {
|
||||
Table: dynamic(() => import('@portaljs/components').then(mod => mod.Table)),
|
||||
Catalog: dynamic(() => import('@portaljs/components').then(mod => mod.Catalog)),
|
||||
Table: dynamic(() => import('./Table')),
|
||||
Catalog: dynamic(() => import('./Catalog')),
|
||||
mermaid: Mermaid,
|
||||
Vega: dynamic(() => import('@portaljs/components').then(mod => mod.Vega)),
|
||||
VegaLite: dynamic(() => import('@portaljs/components').then(mod => mod.VegaLite)),
|
||||
LineChart: dynamic(() => import('@portaljs/components').then(mod => mod.LineChart)),
|
||||
// Excel: dynamic(() => import('../components/Excel')),
|
||||
// TODO: try and make these dynamic ...
|
||||
Vega: dynamic(() => import('./Vega')),
|
||||
VegaLite: dynamic(() => import('./VegaLite')),
|
||||
LineChart: dynamic(() => import('./LineChart')),
|
||||
} as any;
|
||||
|
||||
export default function DRD({ source }: { source: any }) {
|
||||
32
examples/learn-example/components/DebouncedInput.tsx
Normal file
32
examples/learn-example/components/DebouncedInput.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const DebouncedInput = ({
|
||||
value: initialValue,
|
||||
onChange,
|
||||
debounce = 500,
|
||||
...props
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
onChange(value);
|
||||
}, debounce);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebouncedInput;
|
||||
55
examples/learn-example/components/LineChart.tsx
Normal file
55
examples/learn-example/components/LineChart.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import VegaLite from "./VegaLite";
|
||||
|
||||
export default function LineChart({
|
||||
data = [],
|
||||
fullWidth = false,
|
||||
title = "",
|
||||
xAxis = "x",
|
||||
yAxis = "y",
|
||||
}) {
|
||||
var tmp = data;
|
||||
if (Array.isArray(data)) {
|
||||
tmp = data.map((r, i) => {
|
||||
return { x: r[0], y: r[1] };
|
||||
});
|
||||
}
|
||||
const vegaData = { table: tmp };
|
||||
const spec = {
|
||||
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
title,
|
||||
width: "container",
|
||||
height: 300,
|
||||
mark: {
|
||||
type: "line",
|
||||
color: "black",
|
||||
strokeWidth: 1,
|
||||
tooltip: true,
|
||||
},
|
||||
data: {
|
||||
name: "table",
|
||||
},
|
||||
selection: {
|
||||
grid: {
|
||||
type: "interval",
|
||||
bind: "scales",
|
||||
},
|
||||
},
|
||||
encoding: {
|
||||
x: {
|
||||
field: xAxis,
|
||||
timeUnit: "year",
|
||||
type: "temporal",
|
||||
},
|
||||
y: {
|
||||
field: yAxis,
|
||||
type: "quantitative",
|
||||
},
|
||||
},
|
||||
};
|
||||
if (typeof data === 'string') {
|
||||
spec.data = { "url": data } as any
|
||||
return <VegaLite fullWidth={fullWidth} spec={spec} />;
|
||||
}
|
||||
|
||||
return <VegaLite fullWidth={fullWidth} data={vegaData} spec={spec} />;
|
||||
}
|
||||
189
examples/learn-example/components/Table.tsx
Normal file
189
examples/learn-example/components/Table.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
createColumnHelper,
|
||||
FilterFn,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
ChevronDoubleLeftIcon,
|
||||
ChevronDoubleRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import parseCsv from "../lib/parseCsv";
|
||||
import DebouncedInput from "./DebouncedInput";
|
||||
import loadData from "../lib/loadData";
|
||||
|
||||
const Table = ({
|
||||
data: ogData = [],
|
||||
cols: ogCols = [],
|
||||
csv = "",
|
||||
url = "",
|
||||
fullWidth = false,
|
||||
}) => {
|
||||
if (csv) {
|
||||
const out = parseCsv(csv);
|
||||
ogData = out.rows;
|
||||
ogCols = out.fields;
|
||||
}
|
||||
|
||||
const [data, setData] = React.useState(ogData);
|
||||
const [cols, setCols] = React.useState(ogCols);
|
||||
const [error, setError] = React.useState(""); // TODO: add error handling
|
||||
|
||||
const tableCols = useMemo(() => {
|
||||
const columnHelper = createColumnHelper();
|
||||
return cols.map((c) =>
|
||||
columnHelper.accessor(c.key, {
|
||||
header: () => c.name,
|
||||
cell: (info) => info.getValue(),
|
||||
})
|
||||
);
|
||||
}, [data, cols]);
|
||||
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableCols,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
state: {
|
||||
globalFilter,
|
||||
},
|
||||
globalFilterFn: globalFilterFn,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
loadData(url).then((data) => {
|
||||
const { rows, fields } = parseCsv(data);
|
||||
setData(rows);
|
||||
setCols(fields);
|
||||
});
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className={`${fullWidth ? "w-[90vw] ml-[calc(50%-45vw)]" : "w-full"}`}>
|
||||
<DebouncedInput
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(value) => setGlobalFilter(String(value))}
|
||||
className="p-2 text-sm shadow border border-block"
|
||||
placeholder="Search all columns..."
|
||||
/>
|
||||
<table>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<tr key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<th key={h.id}>
|
||||
<div
|
||||
{...{
|
||||
className: h.column.getCanSort()
|
||||
? "cursor-pointer select-none"
|
||||
: "",
|
||||
onClick: h.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
{{
|
||||
asc: (
|
||||
<ArrowUpIcon className="inline-block ml-2 h-4 w-4" />
|
||||
),
|
||||
desc: (
|
||||
<ArrowDownIcon className="inline-block ml-2 h-4 w-4" />
|
||||
),
|
||||
}[h.column.getIsSorted() as string] ?? (
|
||||
<div className="inline-block ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
{r.getVisibleCells().map((c) => (
|
||||
<td key={c.id}>
|
||||
{flexRender(c.column.columnDef.cell, c.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<button
|
||||
className={`w-6 h-6 ${
|
||||
!table.getCanPreviousPage() ? "opacity-25" : "opacity-100"
|
||||
}`}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronDoubleLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`w-6 h-6 ${
|
||||
!table.getCanPreviousPage() ? "opacity-25" : "opacity-100"
|
||||
}`}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<span className="flex items-center gap-1">
|
||||
<div>Page</div>
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</strong>
|
||||
</span>
|
||||
<button
|
||||
className={`w-6 h-6 ${
|
||||
!table.getCanNextPage() ? "opacity-25" : "opacity-100"
|
||||
}`}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`w-6 h-6 ${
|
||||
!table.getCanNextPage() ? "opacity-25" : "opacity-100"
|
||||
}`}
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronDoubleRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const globalFilterFn: FilterFn<any> = (row, columnId, filterValue: string) => {
|
||||
const search = filterValue.toLowerCase();
|
||||
|
||||
let value = row.getValue(columnId) as string;
|
||||
if (typeof value === "number") value = String(value);
|
||||
|
||||
return value?.toLowerCase().includes(search);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
6
examples/learn-example/components/Vega.tsx
Normal file
6
examples/learn-example/components/Vega.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Wrapper for the Vega component
|
||||
import { Vega as VegaOg } from "react-vega";
|
||||
|
||||
export default function Vega(props) {
|
||||
return <VegaOg {...props} />;
|
||||
}
|
||||
9
examples/learn-example/components/VegaLite.tsx
Normal file
9
examples/learn-example/components/VegaLite.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// Wrapper for the Vega Lite component
|
||||
import { VegaLite as VegaLiteOg } from "react-vega";
|
||||
import applyFullWidthDirective from "../lib/applyFullWidthDirective";
|
||||
|
||||
export default function VegaLite(props) {
|
||||
const Component = applyFullWidthDirective({ Component: VegaLiteOg });
|
||||
|
||||
return <Component {...props} />;
|
||||
}
|
||||
21
examples/learn-example/lib/applyFullWidthDirective.tsx
Normal file
21
examples/learn-example/lib/applyFullWidthDirective.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function applyFullWidthDirective({
|
||||
Component,
|
||||
defaultWFull = true,
|
||||
}) {
|
||||
return (props) => {
|
||||
const newProps = { ...props };
|
||||
|
||||
let newClassName = newProps.className || "";
|
||||
if (newProps.fullWidth === true) {
|
||||
newClassName += " w-[90vw] ml-[calc(50%-45vw)] max-w-none";
|
||||
} else if (defaultWFull) {
|
||||
// So that charts and tables will have the
|
||||
// same width as the text content, but images
|
||||
// can have its width set using the width prop
|
||||
newClassName += " w-full";
|
||||
}
|
||||
newProps.className = newClassName;
|
||||
|
||||
return <Component {...newProps} />;
|
||||
};
|
||||
}
|
||||
5
examples/learn-example/lib/loadData.tsx
Normal file
5
examples/learn-example/lib/loadData.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export default async function loadData(url: string) {
|
||||
const response = await fetch(url)
|
||||
const data = await response.text()
|
||||
return data
|
||||
}
|
||||
@@ -91,7 +91,7 @@ const parse = async function (source, format, scope) {
|
||||
],
|
||||
format,
|
||||
},
|
||||
scope,
|
||||
scope: { ...scope, ...data},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
16
examples/learn-example/lib/parseCsv.ts
Normal file
16
examples/learn-example/lib/parseCsv.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import papa from "papaparse";
|
||||
|
||||
const parseCsv = (csv) => {
|
||||
csv = csv.trim();
|
||||
const rawdata = papa.parse(csv, { header: true });
|
||||
const cols = rawdata.meta.fields.map((r, i) => {
|
||||
return { key: r, name: r };
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rawdata.data,
|
||||
fields: cols,
|
||||
};
|
||||
};
|
||||
|
||||
export default parseCsv;
|
||||
291
examples/learn-example/package-lock.json
generated
291
examples/learn-example/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,9 +19,14 @@
|
||||
"@flowershow/remark-wiki-link": "^1.1.2",
|
||||
"@heroicons/react": "^2.0.17",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@portaljs/components": "^0.1.0",
|
||||
"@tanstack/react-table": "^8.8.5",
|
||||
"flexsearch": "0.7.21",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-next": "13.3.1",
|
||||
"flexsearch": "^0.7.31",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hastscript": "^7.2.0",
|
||||
"mdx-mermaid": "2.0.0-rc7",
|
||||
@@ -30,7 +35,6 @@
|
||||
"papaparse": "^5.4.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-vega": "^7.6.0",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-katex": "^6.0.3",
|
||||
@@ -44,13 +48,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-next": "13.3.1",
|
||||
"postcss": "^8.4.23",
|
||||
"tailwindcss": "^3.3.1"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import parse from '../lib/markdown';
|
||||
|
||||
import DataRichDocument from '../components/DataRichDocument';
|
||||
import DRD from '../components/DRD';
|
||||
import clientPromise from '../lib/mddb';
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
@@ -50,76 +49,31 @@ export const getStaticProps = async (context) => {
|
||||
return {
|
||||
props: {
|
||||
mdxSource,
|
||||
frontMatter: JSON.stringify(frontMatter),
|
||||
frontMatter,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function DatasetPage({ mdxSource, frontMatter }) {
|
||||
frontMatter = JSON.parse(frontMatter);
|
||||
return (
|
||||
<div className="prose dark:prose-invert mx-auto py-8">
|
||||
<div className="prose dark:prose-invert mx-auto">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
<>
|
||||
<h1 className="mb-2">{frontMatter.title}</h1>
|
||||
<h1>{frontMatter.title}</h1>
|
||||
{frontMatter.author && (
|
||||
<p className="my-0">
|
||||
<span className="font-semibold">Author: </span>
|
||||
<span className="my-0">{frontMatter.author}</span>
|
||||
</p>
|
||||
<div className="-mt-6">
|
||||
<p className="opacity-60 pl-1">{frontMatter.author}</p>
|
||||
</div>
|
||||
)}
|
||||
{frontMatter.description && (
|
||||
<p className="my-0">
|
||||
<span className="font-semibold">Description: </span>
|
||||
<span className="description my-0">
|
||||
{frontMatter.description}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{frontMatter.modified && (
|
||||
<p className="my-0">
|
||||
<span className="font-semibold">Modified: </span>
|
||||
<span className="description my-0">
|
||||
{new Date(frontMatter.modified).toLocaleDateString()}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{frontMatter.files && (
|
||||
<section className="py-6">
|
||||
<h2 className="mt-0">Data files</h2>
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Format</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{frontMatter.files.map((f) => {
|
||||
const fileName = f.split('/').slice(-1);
|
||||
return (
|
||||
<tr key={`resources-list-${f}`}>
|
||||
<td>
|
||||
<a target="_blank" href={f}>
|
||||
{fileName}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{fileName[0].split('.').slice(-1)[0].toUpperCase()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<p className="description">{frontMatter.description}</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<DataRichDocument source={mdxSource} />
|
||||
<DRD source={mdxSource} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import '../styles/globals.css'
|
||||
import '@portaljs/components/styles.css'
|
||||
|
||||
import type { AppProps } from 'next/app'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
# PortalJS React Components
|
||||
# `components` package
|
||||
|
||||
**Storybook:** https://storybook.portaljs.org
|
||||
**Docs**: https://portaljs.org/docs
|
||||
|
||||
## Usage
|
||||
|
||||
To install this package on your project:
|
||||
|
||||
```bash
|
||||
npm i @portaljs/components
|
||||
```
|
||||
|
||||
> Note: React 18 is required.
|
||||
|
||||
You'll also have to import the styles CSS file in your project:
|
||||
|
||||
```ts
|
||||
// E.g.: Next.js => pages/_app.tsx
|
||||
import '@portaljs/components/styles.css'
|
||||
```
|
||||
**See live:** https://storybook.portaljs.org
|
||||
|
||||
## Dev
|
||||
|
||||
Use Storybook to work on components by running:
|
||||
|
||||
```bash
|
||||
npm run storybook
|
||||
yarn storybook
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
To build this project, run:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
TODO: command to install the package
|
||||
|
||||
### Next.js
|
||||
|
||||
Add this line at the start of `_app.tsx`:
|
||||
|
||||
```ts
|
||||
// pages/_app.tsx
|
||||
import "dist/styles.css";
|
||||
```
|
||||
24796
packages/components/package-lock.json
generated
24796
packages/components/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,11 @@
|
||||
{
|
||||
"name": "@portaljs/components",
|
||||
"version": "0.1.0",
|
||||
"name": "components",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "https://portaljs.org",
|
||||
"keywords": [
|
||||
"data portal",
|
||||
"data catalog",
|
||||
"table",
|
||||
"charts",
|
||||
"visualization"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run storybook",
|
||||
"build": "tsc && vite build && npm run build-tailwind",
|
||||
"dev": "yarn storybook",
|
||||
"build": "tsc && vite build && yarn build-tailwind",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"prepack": "json -f package.json -I -e \"delete this.devDependencies; delete this.dependencies\"",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
@@ -25,19 +18,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.0.17",
|
||||
"@tanstack/react-table": "^8.8.5",
|
||||
"flexsearch": "0.7.21",
|
||||
"next-mdx-remote": "^4.4.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-vega": "^7.6.0",
|
||||
"vega": "5.20.2",
|
||||
"vega-lite": "5.1.0"
|
||||
"vega-lite": "5.1.0",
|
||||
"@tanstack/react-table": "^8.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@storybook/addon-essentials": "^7.0.7",
|
||||
"@storybook/addon-interactions": "^7.0.7",
|
||||
"@storybook/addon-links": "^7.0.7",
|
||||
@@ -75,12 +65,6 @@
|
||||
".": {
|
||||
"import": "./dist/components.es.js",
|
||||
"require": "./dist/components.umd.js"
|
||||
},
|
||||
"./styles.css": {
|
||||
"import": "./dist/styles.css"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Index } from 'flexsearch';
|
||||
import { useState } from 'react';
|
||||
import DebouncedInput from './DebouncedInput';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
export function Catalog({
|
||||
datasets,
|
||||
facets,
|
||||
}: {
|
||||
datasets: any[];
|
||||
facets: string[];
|
||||
}) {
|
||||
const [indexFilter, setIndexFilter] = useState('');
|
||||
const index = new Index({ tokenize: 'full' });
|
||||
datasets.forEach((dataset) =>
|
||||
index.add(
|
||||
dataset._id,
|
||||
//This will join every metadata value + the url_path into one big string and index that
|
||||
Object.entries(dataset.metadata).reduce(
|
||||
(acc, curr) => acc + ' ' + curr[1].toString(),
|
||||
''
|
||||
) +
|
||||
' ' +
|
||||
dataset.url_path
|
||||
)
|
||||
);
|
||||
|
||||
const facetValues = facets
|
||||
? facets.reduce((acc, facet) => {
|
||||
const possibleValues = datasets.reduce((acc, curr) => {
|
||||
const facetValue = curr.metadata[facet];
|
||||
if (facetValue) {
|
||||
return Array.isArray(facetValue)
|
||||
? acc.concat(facetValue)
|
||||
: acc.concat([facetValue]);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
acc[facet] = {
|
||||
possibleValues: [...new Set(possibleValues)],
|
||||
selectedValue: null,
|
||||
};
|
||||
return acc;
|
||||
}, {})
|
||||
: [];
|
||||
|
||||
const { register, watch } = useForm(facetValues);
|
||||
|
||||
const filteredDatasets = datasets
|
||||
// First filter by flex search
|
||||
.filter((dataset) =>
|
||||
indexFilter !== ''
|
||||
? index.search(indexFilter).includes(dataset._id)
|
||||
: true
|
||||
)
|
||||
//Then check if the selectedValue for the given facet is included in the dataset metadata
|
||||
.filter((dataset) => {
|
||||
//Avoids a server rendering breakage
|
||||
if (!watch() || Object.keys(watch()).length === 0) return true
|
||||
//This will filter only the key pairs of the metadata values that were selected as facets
|
||||
const datasetFacets = Object.entries(dataset.metadata).filter((entry) =>
|
||||
facets.includes(entry[0])
|
||||
);
|
||||
//Check if the value present is included in the selected value in the form
|
||||
return datasetFacets.every((elem) =>
|
||||
watch()[elem[0]].selectedValue
|
||||
? (elem[1] as string | string[]).includes(
|
||||
watch()[elem[0]].selectedValue
|
||||
)
|
||||
: true
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DebouncedInput
|
||||
value={indexFilter ?? ''}
|
||||
onChange={(value) => setIndexFilter(String(value))}
|
||||
className="p-2 text-sm shadow border border-block mr-1"
|
||||
placeholder="Search all datasets..."
|
||||
/>
|
||||
{Object.entries(facetValues).map((elem) => (
|
||||
<select
|
||||
key={elem[0]}
|
||||
defaultValue=""
|
||||
className="p-2 ml-1 text-sm shadow border border-block"
|
||||
{...register(elem[0] + '.selectedValue')}
|
||||
>
|
||||
<option value="">
|
||||
Filter by {elem[0]}
|
||||
</option>
|
||||
{(elem[1] as { possibleValues: string[] }).possibleValues.map(
|
||||
(val) => (
|
||||
<option
|
||||
key={val}
|
||||
className="dark:bg-white dark:text-black"
|
||||
value={val}
|
||||
>
|
||||
{val}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
))}
|
||||
<ul className='mb-5 pl-6 mt-5 list-disc'>
|
||||
{filteredDatasets.map((dataset) => (
|
||||
<li className='py-2' key={dataset._id}>
|
||||
<a className='font-medium underline' href={dataset.url_path}>
|
||||
{dataset.metadata.title
|
||||
? dataset.metadata.title
|
||||
: dataset.url_path}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./components/Table";
|
||||
export * from "./components/Catalog";
|
||||
export * from "./components/LineChart";
|
||||
export * from "./components/Vega";
|
||||
export * from "./components/VegaLite";
|
||||
@@ -1,226 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { Catalog } from '../src/components/Catalog';
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
|
||||
const meta: Meta = {
|
||||
title: 'Components/Catalog',
|
||||
component: Catalog,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
datasets: {
|
||||
description:
|
||||
'Lists of datasets to be displayed in the list, will usually be automatically available',
|
||||
},
|
||||
facets: {
|
||||
description:
|
||||
'List of frontmatter fields that should be used as filters, needs to match exactly with the field name',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<{ datasets: any; facets: string[] }>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
export const WithoutFacets: Story = {
|
||||
name: 'Catalog without facets',
|
||||
args: {
|
||||
datasets: [
|
||||
{
|
||||
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
|
||||
url_path: 'dataset-4',
|
||||
file_path: 'content/dataset-4/index.md',
|
||||
metadata: {
|
||||
title: 'Detecting Abusive Albanian',
|
||||
'link-to-publication': 'https://arxiv.org/abs/2107.13592',
|
||||
'link-to-data': 'https://doi.org/10.6084/m9.figshare.19333298.v1',
|
||||
'task-description':
|
||||
'Hierarchical (offensive/not; untargeted/targeted; person/group/other)',
|
||||
'details-of-task':
|
||||
'Detect and categorise abusive language in social media data',
|
||||
'size-of-dataset': 11874,
|
||||
'percentage-abusive': 13.2,
|
||||
language: 'Albanian',
|
||||
'level-of-annotation': ['Posts'],
|
||||
platform: ['Instagram', 'Youtube'],
|
||||
medium: ['Text'],
|
||||
reference:
|
||||
'Nurce, E., Keci, J., Derczynski, L., 2021. Detecting Abusive Albanian. arXiv:2107.13592',
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: '42c86cf3c4fbbab11d91c2a7d6dcb8f750bc4e19',
|
||||
url_path: 'dataset-1',
|
||||
file_path: 'content/dataset-1/index.md',
|
||||
metadata: {
|
||||
title: 'AbuseEval v1.0',
|
||||
'link-to-publication':
|
||||
'http://www.lrec-conf.org/proceedings/lrec2020/pdf/2020.lrec-1.760.pdf',
|
||||
'link-to-data': 'https://github.com/tommasoc80/AbuseEval',
|
||||
'task-description':
|
||||
'Explicitness annotation of offensive and abusive content',
|
||||
'details-of-task':
|
||||
'Enriched versions of the OffensEval/OLID dataset with the distinction of explicit/implicit offensive messages and the new dimension for abusive messages. Labels for offensive language: EXPLICIT, IMPLICT, NOT; Labels for abusive language: EXPLICIT, IMPLICT, NOTABU',
|
||||
'size-of-dataset': 14100,
|
||||
'percentage-abusive': 20.75,
|
||||
language: 'English',
|
||||
'level-of-annotation': ['Tweets'],
|
||||
platform: ['Twitter'],
|
||||
medium: ['Text'],
|
||||
reference:
|
||||
'Caselli, T., Basile, V., Jelena, M., Inga, K., and Michael, G. 2020. "I feel offended, don’t be abusive! implicit/explicit messages in offensive and abusive language". The 12th Language Resources and Evaluation Conference (pp. 6193-6202). European Language Resources Association.',
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: '80001dd32a752421fdcc64e91fbd237dc31d6bb3',
|
||||
url_path: 'dataset-2',
|
||||
file_path: 'content/dataset-2/index.md',
|
||||
metadata: {
|
||||
title:
|
||||
'Abusive Language Detection on Arabic Social Media (Al Jazeera)',
|
||||
'link-to-publication': 'https://www.aclweb.org/anthology/W17-3008',
|
||||
'link-to-data':
|
||||
'http://alt.qcri.org/~hmubarak/offensive/AJCommentsClassification-CF.xlsx',
|
||||
'task-description':
|
||||
'Ternary (Obscene, Offensive but not obscene, Clean)',
|
||||
'details-of-task': 'Incivility',
|
||||
'size-of-dataset': 32000,
|
||||
'percentage-abusive': 0.81,
|
||||
language: 'Arabic',
|
||||
'level-of-annotation': ['Posts'],
|
||||
platform: ['AlJazeera'],
|
||||
medium: ['Text'],
|
||||
reference:
|
||||
'Mubarak, H., Darwish, K. and Magdy, W., 2017. Abusive Language Detection on Arabic Social Media. In: Proceedings of the First Workshop on Abusive Language Online. Vancouver, Canada: Association for Computational Linguistics, pp.52-56.',
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: '96649d05d8193f4333b10015af76c6562971bd8c',
|
||||
url_path: 'dataset-3',
|
||||
file_path: 'content/dataset-3/index.md',
|
||||
metadata: {
|
||||
title: 'CoRAL: a Context-aware Croatian Abusive Language Dataset',
|
||||
'link-to-publication':
|
||||
'https://aclanthology.org/2022.findings-aacl.21/',
|
||||
'link-to-data':
|
||||
'https://github.com/shekharRavi/CoRAL-dataset-Findings-of-the-ACL-AACL-IJCNLP-2022',
|
||||
'task-description':
|
||||
'Multi-class based on context dependency categories (CDC)',
|
||||
'details-of-task': 'Detectioning CDC from abusive comments',
|
||||
'size-of-dataset': 2240,
|
||||
'percentage-abusive': 100,
|
||||
language: 'Croatian',
|
||||
'level-of-annotation': ['Posts'],
|
||||
platform: ['Posts'],
|
||||
medium: ['Newspaper Comments'],
|
||||
reference:
|
||||
'Ravi Shekhar, Mladen Karan and Matthew Purver (2022). CoRAL: a Context-aware Croatian Abusive Language Dataset. Findings of the ACL: AACL-IJCNLP.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
;
|
||||
|
||||
export const WithFacets: Story = {
|
||||
name: 'Catalog with facets',
|
||||
args: {
|
||||
datasets: [
|
||||
{
|
||||
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
|
||||
url_path: 'dataset-4',
|
||||
file_path: 'content/dataset-4/index.md',
|
||||
metadata: {
|
||||
title: 'Detecting Abusive Albanian',
|
||||
'link-to-publication': 'https://arxiv.org/abs/2107.13592',
|
||||
'link-to-data': 'https://doi.org/10.6084/m9.figshare.19333298.v1',
|
||||
'task-description':
|
||||
'Hierarchical (offensive/not; untargeted/targeted; person/group/other)',
|
||||
'details-of-task':
|
||||
'Detect and categorise abusive language in social media data',
|
||||
'size-of-dataset': 11874,
|
||||
'percentage-abusive': 13.2,
|
||||
language: 'Albanian',
|
||||
'level-of-annotation': ['Posts'],
|
||||
platform: ['Instagram', 'Youtube'],
|
||||
medium: ['Text'],
|
||||
reference:
|
||||
'Nurce, E., Keci, J., Derczynski, L., 2021. Detecting Abusive Albanian. arXiv:2107.13592',
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: '42c86cf3c4fbbab11d91c2a7d6dcb8f750bc4e19',
|
||||
url_path: 'dataset-1',
|
||||
file_path: 'content/dataset-1/index.md',
|
||||
metadata: {
|
||||
title: 'AbuseEval v1.0',
|
||||
'link-to-publication':
|
||||
'http://www.lrec-conf.org/proceedings/lrec2020/pdf/2020.lrec-1.760.pdf',
|
||||
'link-to-data': 'https://github.com/tommasoc80/AbuseEval',
|
||||
'task-description':
|
||||
'Explicitness annotation of offensive and abusive content',
|
||||
'details-of-task':
|
||||
'Enriched versions of the OffensEval/OLID dataset with the distinction of explicit/implicit offensive messages and the new dimension for abusive messages. Labels for offensive language: EXPLICIT, IMPLICT, NOT; Labels for abusive language: EXPLICIT, IMPLICT, NOTABU',
|
||||
'size-of-dataset': 14100,
|
||||
'percentage-abusive': 20.75,
|
||||
language: 'English',
|
||||
'level-of-annotation': ['Tweets'],
|
||||
platform: ['Twitter'],
|
||||
medium: ['Text'],
|
||||
reference:
|
||||
'Caselli, T., Basile, V., Jelena, M., Inga, K., and Michael, G. 2020. "I feel offended, don’t be abusive! implicit/explicit messages in offensive and abusive language". The 12th Language Resources and Evaluation Conference (pp. 6193-6202). European Language Resources Association.',
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: '80001dd32a752421fdcc64e91fbd237dc31d6bb3',
|
||||
url_path: 'dataset-2',
|
||||
file_path: 'content/dataset-2/index.md',
|
||||
metadata: {
|
||||
title:
|
||||
'Abusive Language Detection on Arabic Social Media (Al Jazeera)',
|
||||
'link-to-publication': 'https://www.aclweb.org/anthology/W17-3008',
|
||||
'link-to-data':
|
||||
'http://alt.qcri.org/~hmubarak/offensive/AJCommentsClassification-CF.xlsx',
|
||||
'task-description':
|
||||
'Ternary (Obscene, Offensive but not obscene, Clean)',
|
||||
'details-of-task': 'Incivility',
|
||||
'size-of-dataset': 32000,
|
||||
'percentage-abusive': 0.81,
|
||||
language: 'Arabic',
|
||||
'level-of-annotation': ['Posts'],
|
||||
platform: ['AlJazeera'],
|
||||
medium: ['Text'],
|
||||
reference:
|
||||
'Mubarak, H., Darwish, K. and Magdy, W., 2017. Abusive Language Detection on Arabic Social Media. In: Proceedings of the First Workshop on Abusive Language Online. Vancouver, Canada: Association for Computational Linguistics, pp.52-56.',
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: '96649d05d8193f4333b10015af76c6562971bd8c',
|
||||
url_path: 'dataset-3',
|
||||
file_path: 'content/dataset-3/index.md',
|
||||
metadata: {
|
||||
title: 'CoRAL: a Context-aware Croatian Abusive Language Dataset',
|
||||
'link-to-publication':
|
||||
'https://aclanthology.org/2022.findings-aacl.21/',
|
||||
'link-to-data':
|
||||
'https://github.com/shekharRavi/CoRAL-dataset-Findings-of-the-ACL-AACL-IJCNLP-2022',
|
||||
'task-description':
|
||||
'Multi-class based on context dependency categories (CDC)',
|
||||
'details-of-task': 'Detectioning CDC from abusive comments',
|
||||
'size-of-dataset': 2240,
|
||||
'percentage-abusive': 100,
|
||||
language: 'Croatian',
|
||||
'level-of-annotation': ['Posts'],
|
||||
platform: ['Posts'],
|
||||
medium: ['Newspaper Comments'],
|
||||
reference:
|
||||
'Ravi Shekhar, Mladen Karan and Matthew Purver (2022). CoRAL: a Context-aware Croatian Abusive Language Dataset. Findings of the ACL: AACL-IJCNLP.',
|
||||
},
|
||||
},
|
||||
],
|
||||
facets: ['language', 'platform']
|
||||
},
|
||||
};
|
||||
;
|
||||
8947
packages/components/yarn.lock
Normal file
8947
packages/components/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
export const Avatar: React.FC<any> = ({ name, img, href }) => {
|
||||
const Component = href ? "a" : "div";
|
||||
return (
|
||||
<Component href={href} className="group block flex-shrink-0 mt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<img
|
||||
className="inline-block h-9 w-9 rounded-full"
|
||||
src={img}
|
||||
alt={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-primary dark:text-primary-dark">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
export default function DocsPagination({ prev = '', next = '' }) {
|
||||
return (
|
||||
<div className="w-full flex my-20">
|
||||
{prev && (
|
||||
<a href={prev} className="mr-10 no-underline">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6 inline mr-2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
Prev
|
||||
</a>
|
||||
)}
|
||||
{next && (
|
||||
<a href={next} className="no-underline ml-auto">
|
||||
Next Lesson
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6 inline ml-2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
site/components/ExcelViewerApp.js
Normal file
186
site/components/ExcelViewerApp.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import XLSX from 'xlsx';
|
||||
import React from 'react';
|
||||
|
||||
function SheetJSApp() {
|
||||
const [data, setData] = React.useState([]);
|
||||
const [cols, setCols] = React.useState([]);
|
||||
|
||||
const handleFile = (file) => {
|
||||
const reader = new FileReader();
|
||||
const rABS = !!reader.readAsBinaryString;
|
||||
reader.onload = (e) => {
|
||||
/* Parse data */
|
||||
const bstr = e.target.result;
|
||||
const wb = XLSX.read(bstr, {type:rABS ? 'binary' : 'array'});
|
||||
displayWorkbook(wb);
|
||||
};
|
||||
if(rABS) reader.readAsBinaryString(file); else reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
const handleUrl = (url) => {
|
||||
let oReq = new XMLHttpRequest();
|
||||
oReq.open("GET", url, true);
|
||||
oReq.responseType = "arraybuffer";
|
||||
oReq.onload = function (e) {
|
||||
let arraybuffer = oReq.response;
|
||||
/* not responseText!! */
|
||||
|
||||
/* convert data to binary string */
|
||||
let data = new Uint8Array(arraybuffer);
|
||||
let arr = new Array();
|
||||
for (let i = 0; i != data.length; ++i) arr[i] = String.fromCharCode(data[i]);
|
||||
let bstr = arr.join("");
|
||||
/* Call XLSX */
|
||||
let workbook = XLSX.read(bstr, {type: "binary"});
|
||||
displayWorkbook(workbook);
|
||||
};
|
||||
|
||||
oReq.send();
|
||||
}
|
||||
|
||||
const displayWorkbook = (wb) => {
|
||||
/* Get first worksheet */
|
||||
const wsname = wb.SheetNames[0];
|
||||
const ws = wb.Sheets[wsname];
|
||||
/t Convert array of arrays */
|
||||
const data = XLSX.utils.sheet_to_json(ws, {header:1});
|
||||
/* Update state */
|
||||
setData(data);
|
||||
setCols(make_cols(ws['!ref']));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DragDropFile handleFile={handleFile}>
|
||||
<h2>Drag or choose a spreadsheet file</h2>
|
||||
<div className="">
|
||||
<DataInput handleFile={handleFile} />
|
||||
</div>
|
||||
</DragDropFile>
|
||||
<div className="mb-6">
|
||||
<h3>Enter spreadsheet URL</h3>
|
||||
<UrlInput handleUrl={handleUrl} />
|
||||
</div>
|
||||
<div className="row">
|
||||
<OutTable data={data} cols={cols} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if(typeof module !== 'undefined') module.exports = SheetJSApp
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
Simple HTML5 file drag-and-drop wrapper
|
||||
usage: <DragDropFile handleFile={handleFile}>...</DragDropFile>
|
||||
handleFile(file:File):void;
|
||||
*/
|
||||
|
||||
function DragDropFile({ handleFile, children }) {
|
||||
const suppress = (e) => { e.stopPropagation(); e.preventDefault(); };
|
||||
const handleDrop = (e) => { e.stopPropagation(); e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if(files && files[0]) handleFile(files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={suppress}
|
||||
onDragOver={suppress}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlInput({ handleUrl }) {
|
||||
const handleChange = (e) => {
|
||||
const url = e.target.value;
|
||||
if(url) handleUrl(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form-inline">
|
||||
<div className="form-group">
|
||||
<label htmlFor="url">Spreadsheet URL (with CORS enabled!)</label>
|
||||
<br />
|
||||
<small>Here is one: http://localhost:3000/_files/eight-centuries-of-global-real-interest-rates-r-g-and-the-suprasecular-decline-1311-2018-data.xlsx</small>
|
||||
<br />
|
||||
<input
|
||||
type="text"
|
||||
id="url"
|
||||
className="border w-96"
|
||||
accept={SheetJSFT}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
Simple HTML5 file input wrapper
|
||||
usage: <DataInput handleFile={callback} />
|
||||
handleFile(file:File):void;
|
||||
*/
|
||||
|
||||
function DataInput({ handleFile }) {
|
||||
const handleChange = (e) => {
|
||||
const files = e.target.files;
|
||||
if(files && files[0]) handleFile(files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form-inline">
|
||||
<div className="form-group">
|
||||
<label htmlFor="file">Select spreadsheet file</label>
|
||||
<br />
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
id="file"
|
||||
accept={SheetJSFT}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
Simple HTML Table
|
||||
usage: <OutTable data={data} cols={cols} />
|
||||
data:Array<Array<any> >;
|
||||
cols:Array<{name:string, key:number|string}>;
|
||||
*/
|
||||
function OutTable({ data, cols }) {
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped">
|
||||
<thead>
|
||||
<tr>{cols.map((c) => <th key={c.key}>{c.name}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((r,i) => <tr key={i}>
|
||||
{cols.map(c => <td key={c.key}>{ r[c.key] }</td>)}
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* list of supported file types */
|
||||
const SheetJSFT = [
|
||||
"xlsx", "xlsb", "xlsm", "xls", "xml", "csv", "txt", "ods", "fods", "uos", "sylk", "dif", "dbf", "prn", "qpw", "123", "wb*", "wq*", "html", "htm"
|
||||
].map(x => `.${x}`).join(",");
|
||||
|
||||
/* generate an array of column objects */
|
||||
const make_cols = refstr => {
|
||||
let o = [], C = XLSX.utils.decode_range(refstr).e.c + 1;
|
||||
for(var i = 0; i < C; ++i) o[i] = {name:XLSX.utils.encode_col(i), key:i}
|
||||
return o;
|
||||
};
|
||||
@@ -21,6 +21,12 @@ const items = [
|
||||
description: 'Government Open Data Portal',
|
||||
sourceUrl: 'https://github.com/FCSCOpendata/frontend',
|
||||
},
|
||||
{
|
||||
title: 'Brazil Open Data',
|
||||
href: 'https://dados.gov.br/',
|
||||
image: '/images/showcases/brazil.png',
|
||||
description: 'Government Open Data Portal',
|
||||
},
|
||||
{
|
||||
title: 'Datahub Open Data',
|
||||
href: 'https://opendata.datahub.io/',
|
||||
|
||||
@@ -56,6 +56,10 @@ export function Hero() {
|
||||
Get started
|
||||
</ButtonLink>
|
||||
|
||||
<ButtonLink className="ml-3" href="#gallery" style="secondary">
|
||||
Gallery
|
||||
</ButtonLink>
|
||||
|
||||
<div className="md:max-w-md mx-auto">
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import Nav from './Nav';
|
||||
import { SiteToc } from '@/components/SiteToc';
|
||||
|
||||
function useTableOfContents(tableOfContents) {
|
||||
const [currentSection, setCurrentSection] = useState(tableOfContents[0]?.id);
|
||||
@@ -54,14 +53,10 @@ export default function Layout({
|
||||
children,
|
||||
title,
|
||||
tableOfContents = [],
|
||||
urlPath,
|
||||
sidebarTree = []
|
||||
}: {
|
||||
children;
|
||||
title?: string;
|
||||
tableOfContents?;
|
||||
urlPath?: string;
|
||||
sidebarTree?: [];
|
||||
}) {
|
||||
// const { toc } = children.props;
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -134,7 +129,7 @@ export default function Layout({
|
||||
href={`#${subSection.id}`}
|
||||
className={
|
||||
isActive(subSection)
|
||||
? 'text-secondary'
|
||||
? 'text-sky-500'
|
||||
: 'hover:text-slate-600 dark:hover:text-slate-300'
|
||||
}
|
||||
>
|
||||
@@ -150,12 +145,6 @@ export default function Layout({
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
{/* LHS NAVIGATION */}
|
||||
{/* {showSidebar && ( */}
|
||||
<div className="hidden lg:block fixed z-20 w-[18rem] top-[4.6rem] right-auto bottom-0 left-[max(0px,calc(50%-44rem))] pt-8 pl-8 overflow-y-auto">
|
||||
<SiteToc currentPath={urlPath} nav={sidebarTree} />
|
||||
</div>
|
||||
{/* )} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { MDXRemote } from 'next-mdx-remote';
|
||||
import layouts from 'layouts';
|
||||
import DocsPagination from './DocsPagination';
|
||||
import { MDXRemote } from "next-mdx-remote";
|
||||
import layouts from "layouts";
|
||||
|
||||
export default function MDXPage({ source, frontMatter }) {
|
||||
const Layout = ({ children }) => {
|
||||
const layoutName = frontMatter?.layout || 'default';
|
||||
const LayoutComponent = layouts[layoutName];
|
||||
|
||||
if (frontMatter.layout) {
|
||||
let LayoutComponent = layouts[frontMatter.layout];
|
||||
return <LayoutComponent {...frontMatter}>{children}</LayoutComponent>;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="prose mx-auto prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark text-primary dark:text-primary-dark prose-headings:font-headings dark:prose-invert prose-a:break-words">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
{/* Default layout */}
|
||||
{!frontMatter.layout && (
|
||||
<>
|
||||
<h1>{frontMatter.title}</h1>
|
||||
{frontMatter.author && (
|
||||
<div className="-mt-6">
|
||||
<p className="opacity-60 pl-1">{frontMatter.author}</p>
|
||||
</div>
|
||||
)}
|
||||
{frontMatter.description && (
|
||||
<p className="description">{frontMatter.description}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<Layout>
|
||||
<MDXRemote {...source} components={{ DocsPagination }} />
|
||||
<MDXRemote {...source} />
|
||||
</Layout>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import Link from 'next/link.js';
|
||||
import clsx from 'clsx';
|
||||
import { Disclosure, Transition } from '@headlessui/react';
|
||||
|
||||
export interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
name: string;
|
||||
path: string;
|
||||
level: number;
|
||||
children: Array<NavItem | NavGroup>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
nav: Array<NavItem | NavGroup>;
|
||||
}
|
||||
|
||||
function isNavGroup(item: NavItem | NavGroup): item is NavGroup {
|
||||
return (item as NavGroup).children !== undefined;
|
||||
}
|
||||
|
||||
function navItemBeforeNavGroup(a, b) {
|
||||
if (isNavGroup(a) === isNavGroup(b)) {
|
||||
return 0;
|
||||
}
|
||||
if (isNavGroup(a) && !isNavGroup(b)) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function sortNavGroupChildren(items: Array<NavItem | NavGroup>) {
|
||||
return items.sort(
|
||||
(a, b) => navItemBeforeNavGroup(a, b) || a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
export const SiteToc: React.FC<Props> = ({ currentPath, nav }) => {
|
||||
return (
|
||||
<nav data-testid="lhs-sidebar" className="flex flex-col space-y-3 text-sm">
|
||||
{/* {sortNavGroupChildren(nav).map((n) => ( */}
|
||||
{nav.map((n) => (
|
||||
<NavComponent item={n} currentPath={currentPath} />
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const NavComponent: React.FC<{
|
||||
item: NavItem | NavGroup;
|
||||
currentPath: string;
|
||||
}> = ({ item, currentPath }) => {
|
||||
function isActiveItem(item: NavItem) {
|
||||
return item.href === "/" + currentPath;
|
||||
}
|
||||
|
||||
return !isNavGroup(item) ? (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
isActiveItem(item)
|
||||
? 'text-secondary'
|
||||
: 'font-normal text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300',
|
||||
'block'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<Disclosure as="div" key={item.name} className="flex flex-col space-y-3" defaultOpen={true}>
|
||||
{({ open }) => (
|
||||
<div>
|
||||
<Disclosure.Button className="group w-full flex items-center text-left text-md font-medium text-slate-900 dark:text-white">
|
||||
<svg
|
||||
className={clsx(
|
||||
open ? 'text-slate-400 rotate-90' : 'text-slate-300',
|
||||
'h-3 w-3 mr-2 flex-shrink-0 transform transition-colors duration-150 ease-in-out group-hover:text-slate-400'
|
||||
)}
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 6L14 10L6 14V6Z" fill="currentColor" />
|
||||
</svg>
|
||||
{item.name}
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="flex flex-col space-y-3 pl-5 mt-3">
|
||||
{/* {sortNavGroupChildren(item.children).map((subItem) => ( */}
|
||||
{item.children.map((subItem) => (
|
||||
<NavComponent item={subItem} currentPath={currentPath} />
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
46
site/components/data-literate/DataLiterate.js
Normal file
46
site/components/data-literate/DataLiterate.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import Layout from '../Layout'
|
||||
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Head from 'next/head'
|
||||
|
||||
import CustomLink from '../CustomLink'
|
||||
import { Vega, VegaLite } from 'react-vega'
|
||||
|
||||
// Custom components/renderers to pass to MDX.
|
||||
// Since the MDX files aren't loaded by webpack, they have no knowledge of how
|
||||
// to handle import statements. Instead, you must include components in scope
|
||||
// here.
|
||||
const components = {
|
||||
a: CustomLink,
|
||||
Table: dynamic(() => import('./Table')),
|
||||
Excel: dynamic(() => import('./Excel')),
|
||||
// TODO: try and make these dynamic ...
|
||||
Vega: Vega,
|
||||
VegaLite: VegaLite,
|
||||
LineChart: dynamic(() => import('./LineChart')),
|
||||
Head,
|
||||
}
|
||||
|
||||
export default function DataLiterate({ source, frontMatter }) {
|
||||
return (
|
||||
<Layout title={frontMatter.title}>
|
||||
<div className="prose mx-auto">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
<h1>{frontMatter.title}</h1>
|
||||
{frontMatter.author && (
|
||||
<div className="-mt-6"><p className="opacity-60 pl-1">{frontMatter.author}</p></div>
|
||||
)}
|
||||
{frontMatter.description && (
|
||||
<p className="description">{frontMatter.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<MDXRemote {...source} components={components} />
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
74
site/components/data-literate/Excel.js
Normal file
74
site/components/data-literate/Excel.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios'
|
||||
import XLSX from 'xlsx'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import Table from './Table'
|
||||
|
||||
export default function Excel ({ src='' }) {
|
||||
const [data, setData] = React.useState([])
|
||||
const [cols, setCols] = React.useState([])
|
||||
const [workbook, setWorkbook] = React.useState(null)
|
||||
const [error, setError] = React.useState('')
|
||||
const [hasMounted, setHasMounted] = React.useState(0)
|
||||
|
||||
// so this is here so we re-render this in the browser
|
||||
// and not just when we build the page statically in nextjs
|
||||
useEffect(() => {
|
||||
if (hasMounted==0) {
|
||||
handleUrl(src)
|
||||
}
|
||||
setHasMounted(1)
|
||||
})
|
||||
|
||||
function handleUrl(url) {
|
||||
// if url is external may have CORS issue so we proxy it ...
|
||||
if (url.startsWith('http')) {
|
||||
const PROXY_URL = window.location.origin + '/api/proxy'
|
||||
url = PROXY_URL + '?url=' + encodeURIComponent(url)
|
||||
}
|
||||
axios.get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
}).then((res) => {
|
||||
let out = new Uint8Array(res.data)
|
||||
let workbook = XLSX.read(out, {type: "array"})
|
||||
// Get first worksheet
|
||||
const wsname = workbook.SheetNames[0]
|
||||
const ws = workbook.Sheets[wsname]
|
||||
// Convert array of arrays
|
||||
const datatmp = XLSX.utils.sheet_to_json(ws, {header:1})
|
||||
const colstmp = make_cols(ws['!ref'])
|
||||
setData(datatmp)
|
||||
setCols(colstmp)
|
||||
setWorkbook(workbook)
|
||||
}).catch((e) => {
|
||||
setError(e.message)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error &&
|
||||
<div>
|
||||
There was an error loading the excel file at {src}:
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
}
|
||||
{workbook &&
|
||||
<ul>
|
||||
{workbook.SheetNames.map((value, index) => {
|
||||
return <li key={index}>{value}</li>
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
<Table data={data} cols={cols} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* generate an array of column objects */
|
||||
const make_cols = refstr => {
|
||||
let o = [], C = XLSX.utils.decode_range(refstr).e.c + 1
|
||||
for(var i = 0; i < C; ++i) o[i] = {name:XLSX.utils.encode_col(i), key:i}
|
||||
return o
|
||||
}
|
||||
|
||||
33
site/components/data-literate/LineChart.js
Normal file
33
site/components/data-literate/LineChart.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Vega, VegaLite } from 'react-vega'
|
||||
|
||||
export default function LineChart( { data=[] }) {
|
||||
var tmp = data
|
||||
if (Array.isArray(data)) {
|
||||
tmp = data.map((r,i) => {
|
||||
return { x: r[0], y: r[1] }
|
||||
})
|
||||
}
|
||||
const vegaData = { "table": tmp }
|
||||
const spec = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"mark": "line",
|
||||
"data": {
|
||||
"name": "table"
|
||||
},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "x",
|
||||
"timeUnit": "year",
|
||||
"type": "temporal"
|
||||
},
|
||||
"y": {
|
||||
"field": "y",
|
||||
"type": "quantitative"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<VegaLite data={ vegaData } spec={ spec } />
|
||||
)
|
||||
}
|
||||
83
site/components/data-literate/Table.js
Normal file
83
site/components/data-literate/Table.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import axios from 'axios'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
const papa = require("papaparse")
|
||||
|
||||
/*
|
||||
Simple HTML Table
|
||||
usage: <OutTable data={data} cols={cols} />
|
||||
data:Array<Array<any> >;
|
||||
cols:Array<{name:string, key:number|string}>;
|
||||
*/
|
||||
export default function Table({ data=[], cols=[], csv='', url='' }) {
|
||||
if (csv) {
|
||||
const out = parseCsv(csv)
|
||||
data = out.rows
|
||||
cols = out.cols
|
||||
}
|
||||
|
||||
const [ourdata, setData] = React.useState(data)
|
||||
const [ourcols, setCols] = React.useState(cols)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
loadUrl(url)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
function loadUrl(path) {
|
||||
// HACK: duplicate of Excel code - maybe refactor
|
||||
// if url is external may have CORS issue so we proxy it ...
|
||||
if (url.startsWith('http')) {
|
||||
const PROXY_URL = window.location.origin + '/api/proxy'
|
||||
url = PROXY_URL + '?url=' + encodeURIComponent(url)
|
||||
}
|
||||
axios.get(url).then((res) => {
|
||||
const { rows, fields } = parseCsv(res.data)
|
||||
setData(rows)
|
||||
setCols(fields)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleTable data={ourdata} cols={ourcols} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
Simple HTML Table
|
||||
usage: <OutTable data={data} cols={cols} />
|
||||
data:Array<Array<any> >;
|
||||
cols:Array<{name:string, key:number|string}>;
|
||||
*/
|
||||
function SimpleTable({ data=[], cols=[] }) {
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped">
|
||||
<thead>
|
||||
<tr>{cols.map((c) => <th key={c.key}>{c.name}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((r,i) => <tr key={i}>
|
||||
{cols.map(c => <td key={c.key}>{ r[c.key] }</td>)}
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseCsv(csv) {
|
||||
csv = csv.trim()
|
||||
const rawdata = papa.parse(csv, {header: true})
|
||||
const cols = rawdata.meta.fields.map((r,i) => {
|
||||
return { key: r, name: r }
|
||||
})
|
||||
return {
|
||||
rows: rawdata.data,
|
||||
fields: cols
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const config = {
|
||||
{ name: "Docs", href: "/docs" },
|
||||
// { name: "Components", href: "/docs/components" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
{ name: "Gallery", href: "/#gallery" },
|
||||
// { name: "Gallery", href: "/gallery" },
|
||||
// { name: "Data Literate", href: "/data-literate" },
|
||||
// { name: "DL Demo", href: "/data-literate/demo" },
|
||||
// { name: "Excel Viewer", href: "/excel-viewer" },
|
||||
@@ -52,7 +52,7 @@ const config = {
|
||||
github: "https://github.com/datopian/portaljs",
|
||||
discord: "https://discord.gg/EeyfGrGu4U",
|
||||
tableOfContents: true,
|
||||
analytics: "G-96GWZHMH57",
|
||||
// analytics: "xxxxxx",
|
||||
// editLinkShow: true,
|
||||
};
|
||||
export default config;
|
||||
|
||||
21
site/content/data-literate.md
Normal file
21
site/content/data-literate.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Data Literate Documents
|
||||
author: Rufus Pollock and Friends
|
||||
---
|
||||
|
||||
**What?** An experiment in simple, lightweight approach to creating, displaying and sharing datasets and data-driven stories.
|
||||
|
||||
**Why?** a simple, fast, extensible way to present data(sets) and author data-driven content. I want to work with markdown for content and quickly add data in the simplest way possible e.g. dropping in links, pasting tables or adding links to the metadata.
|
||||
|
||||
**How?** Technically the essence is Markdown+React (MDX) + a curated toolkit of components for data-presentation + NextJS for framework and deployment.
|
||||
|
||||
Check out the [demo](/data-literate/demo).
|
||||
|
||||
## Background
|
||||
|
||||
I have observed two converging data-rich use cases:
|
||||
|
||||
* **Data Publishing**: quickly presenting data whether a single file or a full dataset.
|
||||
* **Data Stories**: creating data-driven content from the simplest of a blog post with a graph to high end there is sophisticated data journalism and visualization.
|
||||
|
||||
Both of these can now be well served by a simple markdown-plus approach. Taking data publishing first. I've long been a fan of ultra-simple `README + metadata + csv` datasets. With the evolution of frontmatter we can merge the metadata into the README. However, we still need to "present" the dataset and the key thing for a dataset is the data and this is not something markdown ever supported well ... But now with MDX and the richness of the javascript ecosystem it's quite easy to enhance our markdown and build a rendering pipeleine.
|
||||
268
site/content/data-literate/demo.mdx
Normal file
268
site/content/data-literate/demo.mdx
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
title: Demo
|
||||
---
|
||||
|
||||
This demos and documents Data Literate features live.
|
||||
|
||||
You can see the raw source of this page here: https://raw.githubusercontent.com/datopian/data-literate/main/content/demo.mdx
|
||||
|
||||
## Table of Contents
|
||||
|
||||
## GFM
|
||||
|
||||
We can have github-flavored markdown including markdown tables, auto-linked links and checklists:
|
||||
|
||||
```
|
||||
https://github.com/datopian/portaljs
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
* [x] one thing to do
|
||||
* [ ] a second thing to do
|
||||
```
|
||||
|
||||
https://github.com/datopian/portaljs
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
* [x] one thing to do
|
||||
* [ ] a second thing to do
|
||||
|
||||
## Footnotes
|
||||
|
||||
```
|
||||
here is a footnote reference[^1]
|
||||
|
||||
[^1]: a very interesting footnote.
|
||||
```
|
||||
|
||||
here is a footnote reference[^1]
|
||||
|
||||
[^1]: a very interesting footnote.
|
||||
|
||||
|
||||
## Frontmatter
|
||||
|
||||
Posts can have frontmatter like:
|
||||
|
||||
```
|
||||
---
|
||||
title: Hello World
|
||||
author: Rufus Pollock
|
||||
---
|
||||
```
|
||||
|
||||
The title and description are pulled from the MDX file and processed using `gray-matter`. Additionally, links are rendered using a custom component passed to `next-mdx-remote`.
|
||||
|
||||
## A Table of Contents
|
||||
|
||||
You can create a table of contents by having a markdown heading named `Table of Contents`. You can see an example at the start of this post.
|
||||
|
||||
|
||||
## A Table
|
||||
|
||||
You can create tables ...
|
||||
|
||||
```
|
||||
<Table cols={[
|
||||
{ key: 'id', name: 'ID' },
|
||||
{ key: 'firstName', name: 'First name' },
|
||||
{ key: 'lastName', name: 'Last name' },
|
||||
{ key: 'age', name: 'Age' }
|
||||
]} data={[
|
||||
{ id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 },
|
||||
{ id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 },
|
||||
{ id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 },
|
||||
{ id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 },
|
||||
{ id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 },
|
||||
{ id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 },
|
||||
{ id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
<Table cols={[
|
||||
{ key: 'id', name: 'ID' },
|
||||
{ key: 'firstName', name: 'First name' },
|
||||
{ key: 'lastName', name: 'Last name' },
|
||||
{ key: 'age', name: 'Age' }
|
||||
]} data={[
|
||||
{ id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 },
|
||||
{ id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 },
|
||||
{ id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 },
|
||||
{ id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 },
|
||||
{ id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 },
|
||||
{ id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 },
|
||||
{ id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Table from Raw CSV
|
||||
|
||||
You can also pass raw CSV as the content ...
|
||||
|
||||
```
|
||||
<Table csv={`
|
||||
Year,Temp Anomaly
|
||||
1850,-0.418
|
||||
2020,0.923
|
||||
`} />
|
||||
```
|
||||
|
||||
<Table csv={`
|
||||
Year,Temp Anomaly,
|
||||
1850,-0.418
|
||||
2020,0.923
|
||||
`} />
|
||||
|
||||
### Table from a URL
|
||||
|
||||
<Table url='https://raw.githubusercontent.com/datopian/data-literate/main/public/_files/HadCRUT.5.0.1.0.analysis.summary_series.global.annual.csv' />
|
||||
|
||||
```
|
||||
<Table url='https://raw.githubusercontent.com/datopian/data-literate/main/public/_files/HadCRUT.5.0.1.0.analysis.summary_series.global.annual.csv' />
|
||||
```
|
||||
|
||||
## Charts
|
||||
|
||||
You can create charts using a simple syntax.
|
||||
|
||||
### Line Chart
|
||||
|
||||
<LineChart data={
|
||||
[
|
||||
["1850",-0.41765878],
|
||||
["1851",-0.2333498],
|
||||
["1852",-0.22939907],
|
||||
["1853",-0.27035445],
|
||||
["1854",-0.29163003]
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
```
|
||||
<LineChart data={
|
||||
[
|
||||
["1850",-0.41765878],
|
||||
["1851",-0.2333498],
|
||||
["1852",-0.22939907],
|
||||
["1853",-0.27035445],
|
||||
["1854",-0.29163003]
|
||||
]
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
NB: we have quoted years as otherwise not interpreted as dates but as integers ...
|
||||
|
||||
|
||||
### Vega and Vega Lite
|
||||
|
||||
You can using vega or vega-lite. Here's an example using vega-lite:
|
||||
|
||||
<VegaLite data={ { "table": [
|
||||
{
|
||||
"y": -0.418,
|
||||
"x": 1850
|
||||
},
|
||||
{
|
||||
"y": 0.923,
|
||||
"x": 2020
|
||||
}
|
||||
]
|
||||
}
|
||||
} spec={
|
||||
{
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
|
||||
"mark": "bar",
|
||||
"data": {
|
||||
"name": "table"
|
||||
},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "x",
|
||||
"type": "ordinal"
|
||||
},
|
||||
"y": {
|
||||
"field": "y",
|
||||
"type": "quantitative"
|
||||
}
|
||||
}
|
||||
}
|
||||
} />
|
||||
|
||||
|
||||
```jsx
|
||||
<VegaLite data={ { "table": [
|
||||
{
|
||||
"y": -0.418,
|
||||
"x": 1850
|
||||
},
|
||||
{
|
||||
"y": 0.923,
|
||||
"x": 2020
|
||||
}
|
||||
]
|
||||
}
|
||||
} spec={
|
||||
{
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
|
||||
"mark": "bar",
|
||||
"data": {
|
||||
"name": "table"
|
||||
},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "x",
|
||||
"type": "ordinal"
|
||||
},
|
||||
"y": {
|
||||
"field": "y",
|
||||
"type": "quantitative"
|
||||
}
|
||||
}
|
||||
}
|
||||
} />
|
||||
|
||||
```
|
||||
|
||||
#### Line Chart from URL with Tooltip
|
||||
|
||||
https://vega.github.io/vega-lite/examples/interactive_multi_line_pivot_tooltip.html
|
||||
|
||||
<VegaLite spec={
|
||||
{
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"data": {"url": "/_files/HadCRUT.5.0.1.0.analysis.summary_series.global.annual.csv"},
|
||||
"width": 600,
|
||||
"height": 250,
|
||||
"mark": "line",
|
||||
"encoding": {
|
||||
"x": {"field": "Time", "type": "temporal"},
|
||||
"y": {"field": "Anomaly (deg C)", "type": "quantitative"},
|
||||
"tooltip": {"field": "Anomaly (deg C)", "type": "quantitative"}
|
||||
}
|
||||
}
|
||||
} />
|
||||
|
||||
## Display Excel Files
|
||||
|
||||
Local file ...
|
||||
|
||||
```
|
||||
<Excel src='/_files/eight-centuries-of-global-real-interest-rates-r-g-and-the-suprasecular-decline-1311-2018-data.xlsx' />
|
||||
```
|
||||
|
||||
<Excel src='/_files/eight-centuries-of-global-real-interest-rates-r-g-and-the-suprasecular-decline-1311-2018-data.xlsx' />
|
||||
|
||||
Remote files work too (even without CORS) thanks to proxying:
|
||||
|
||||
```
|
||||
<Excel src='https://github.com/datasets/awesome-data/files/6604635/eight-centuries-of-global-real-interest-rates-r-g-and-the-suprasecular-decline-1311-2018-data.xlsx' />
|
||||
```
|
||||
|
||||
<Excel src='https://github.com/datasets/awesome-data/files/6604635/eight-centuries-of-global-real-interest-rates-r-g-and-the-suprasecular-decline-1311-2018-data.xlsx' />
|
||||
@@ -1,9 +0,0 @@
|
||||
# Components
|
||||
|
||||
PortalJS comes with a library of components that provides essential pieces for your data portal. The best way to explore the components is to look at our [Storybook](https://storybook.portaljs.org/) that contains all the details on how to use them. Below is a quick summary of existing components today:
|
||||
|
||||
- `<Catalog />` A searchable catalog that will index a list of datasets and allow for contextual searching + filters.
|
||||
- `<LineChart />` A simple component that allows the creation of an opinionated line chart without the need to go deep into charting standards
|
||||
- `<Vega />` A wrapper around the [Vega specification](https://vega.github.io/vega/) that allows you to build pretty much any kind of chart imaginable
|
||||
- `<VegaLite />` A wrapper around the [Vega Lite specification](https://vega.github.io/vega-lite/) which allows for a more concise grammar than Vega around the building of charts.
|
||||
- `<Table />` is an easy-to-use table component with built-in pagination, search, and sorting.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Creating new datasets
|
||||
|
||||
So far, the PortalJS app we created only has a single page displaying a dataset. Data catalogs and data portals generally showcase many different datasets.
|
||||
|
||||
Let's explore how to add and display more datasets to our portal.
|
||||
|
||||
## Pages in PortalJS
|
||||
|
||||
As you have seen, in this example a dataset page is just a markdown file on disk plus a data file.
|
||||
|
||||
To create a new data showcase page we just create a new markdown file in the `content/` folder and a new data file in the `public/` folder.
|
||||
|
||||
Let's do that now. Create a `content/my-incredible-dataset` folder, and inside this new folder create a `index.md` file with the following content:
|
||||
|
||||
```markdown
|
||||
# My Incredible Dataset
|
||||
|
||||
This is my incredible dataset.
|
||||
|
||||
## Chart
|
||||
|
||||
<LineChart
|
||||
title="US Population By Decade"
|
||||
xAxis="Year"
|
||||
yAxis="Population (mi)"
|
||||
data="my-incredible-data.csv"
|
||||
/>
|
||||
```
|
||||
|
||||
Now, create a file in `public/` named `my-incredible-data.csv` and put the following content inside it:
|
||||
|
||||
```bash
|
||||
Year,Population (mi)
|
||||
1980,227
|
||||
1990,249
|
||||
2000,281
|
||||
2010,309
|
||||
2020,331
|
||||
```
|
||||
|
||||
Note that pages are associated with a route based on their pathname, so, to see the new data page, access http://localhost:3000/my-incredible-dataset from the browser. You should see the following:
|
||||
|
||||
<img src="/assets/docs/my-incredible-dataset.png" />
|
||||
|
||||
> [!tip]
|
||||
> In this tutorial we opted for storing content as markdown files and data as CSV files in the app, but PortalJS can have metadata, data and content stored anywhere.
|
||||
|
||||
## Create an index page
|
||||
|
||||
Now, let's create an index page. First, create a new folder `content/my-awesome-dataset/` and move `content/index.md` to it. Then, create a new file `content/index.md` and put the following content inside it:
|
||||
|
||||
```markdown
|
||||
# Welcome to my data portal!
|
||||
|
||||
List of available datasets:
|
||||
|
||||
- [My Awesome Dataset](/my-awesome-dataset)
|
||||
- [My Incredible Dataset](/my-incredible-dataset)
|
||||
|
||||
```
|
||||
|
||||
From the browser, access http://localhost:3000. You should see the following:
|
||||
|
||||
<img src="/assets/docs/datasets-index-page.png" />
|
||||
|
||||
<DocsPagination prev="/docs" next="/docs/searching-datasets" />
|
||||
@@ -1,55 +0,0 @@
|
||||
# Deploying your PortalJS app
|
||||
|
||||
Finally, let's learn how to deploy PortalJS apps to Vercel or Cloudflare Pages.
|
||||
|
||||
> [!tip]
|
||||
> Although we are using Vercel and Cloudflare Pages in this tutorial, you can deploy apps in any hosting solution you want as a static website by running `npm run export` and distributing the contents of the `out/` folder.
|
||||
|
||||
## Push to a GitHub repo
|
||||
|
||||
The PortalJS app we built up to this point is stored locally. To allow Vercel or Cloudflare Pages to deploy it, we have to push it to GitHub (or another SCM supported by these hosting solutions).
|
||||
|
||||
- Create a new repository under your GitHub account
|
||||
- Add the new remote origin to your PortalJS app
|
||||
- Push the app to the repository
|
||||
|
||||
If you are not sure about how to do it, follow this guide: https://nextjs.org/learn/basics/deploying-nextjs-app/github
|
||||
|
||||
> [!tip]
|
||||
> You can also deploy using our Vercel deploy button. In this case, a new repository will be created under your GitHub account automatically.
|
||||
> [Click here](#one-click-deploy) to scroll to the deploy button.
|
||||
|
||||
## Deploy to Vercel
|
||||
|
||||
The easiest way to deploy a PortalJS app is to use Vercel, a serverless platform for static and hybrid applications developed by the creators of Next.js.
|
||||
|
||||
To deploy your PortalJS app:
|
||||
|
||||
- Create a Vercel account by going to https://vercel.com/signup and choosing "Continue with GitHub"
|
||||
- Import the repository you created for the PortalJS app at https://vercel.com/new
|
||||
- During the setup process you can use the default settings - no need to change anything.
|
||||
|
||||
When you deploy, your PortalJS app will start building. It should finish in under a minute.
|
||||
|
||||
When it’s done, you’ll get deployment URLs. Click on one of the URLs and you should see your PortaJS app live.
|
||||
|
||||
>[!tip]
|
||||
> You can find a more in-depth explanation about this process at https://nextjs.org/learn/basics/deploying-nextjs-app/deploy
|
||||
|
||||
### One-Click Deploy
|
||||
|
||||
You can instantly deploy our example app to your Vercel account by clicking the button below:
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fdatopian%2Fportaljs%2Ftree%2Fmain%2Fexamples%2Flearn-example&project-name=my-data-portal&repository-name=my-data-portal&demo-title=PortalJS%20Learn%20Example&demo-description=PortalJS%20Learn%20Example%20-%20https%3A%2F%2Fportaljs.org%2Fdocs&demo-url=learn-example.portaljs.org&demo-image=https%3A%2F%2Fportaljs.org%2Fassets%2Fexamples%2Fbasic-example.png)
|
||||
|
||||
This will create a new repository on your GitHub account and deploy it to Vercel. If you are following the tutorial, you can replicate the changes done on your local app to this new repository.
|
||||
|
||||
## Deploy to Cloudflare Pages
|
||||
|
||||
To deploy your PortalJS app to Cloudflare Pages, follow this guide:
|
||||
|
||||
https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/#deploy-with-cloudflare-pages-1
|
||||
|
||||
Note that you don't have to change anything - just follow the steps, choosing the repository you created.
|
||||
|
||||
<DocsPagination prev="/docs/showing-metadata" />
|
||||
@@ -8,7 +8,7 @@ If you have questions about anything related to PortalJS, you're always welcome
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node v16.20.0 LTS or Node.js 18.16.0 LTS (recommended)
|
||||
- Node.js 14.18.0 or newer
|
||||
- MacOS, Windows (including WSL), and Linux are supported
|
||||
|
||||
### Create a PortalJS app
|
||||
@@ -20,7 +20,7 @@ npx create-next-app my-data-portal --example https://github.com/datopian/portalj
|
||||
```
|
||||
|
||||
> [!tip]
|
||||
> You may have noticed we used the command create-next-app. That’s because PortalJS is built on the awesome NextJS react javascript framework. This means you can do everything you do with NextJS with PortalJS. Check out their docs to learn more.
|
||||
> You may have noticed we used the command create-next-app. That’s because PortalJS is built on the awesome NextJS react javascript framework. That’s mean you can do everything you do with NextJS with PortalJS. Check out their docs to learn more.
|
||||
|
||||
### Run the development server
|
||||
|
||||
@@ -47,8 +47,172 @@ Let’s try editing the starter page.
|
||||
- Find the text that says “My Dataset” and change it to “My Awesome Dataset”.
|
||||
- Save the file.
|
||||
|
||||
After refreshing the page, you should see the new text:
|
||||
As soon as you save the file, the browser automatically updates the page with the new text:
|
||||
|
||||
<img src="/assets/docs/editing-the-page-1.png" />
|
||||
|
||||
<DocsPagination next="/docs/creating-new-datasets" />
|
||||
## Displaying data
|
||||
|
||||
So far, the PortalJS app we created only has a single page displaying a dataset. Data catalogs and data portals generally showcase many different datasets.
|
||||
|
||||
Let's explore how to add and display more datasets to our portal.
|
||||
|
||||
### Pages in PortalJS
|
||||
|
||||
As you have seen, in this example a dataset page is just a markdown file on disk plus a data file.
|
||||
|
||||
To create a new data showcase page we just create a new markdown file in the `content/` folder and a new data file in the `public/` folder.
|
||||
|
||||
Let's do that now. Create a `content/my-incredible-dataset` folder, and inside this new folder create a `index.md` file with the following content:
|
||||
|
||||
```markdown
|
||||
# My Incredible Dataset
|
||||
|
||||
This is my incredible dataset.
|
||||
|
||||
## Chart
|
||||
|
||||
<LineChart
|
||||
title="US Population By Decade"
|
||||
xAxis="Year"
|
||||
yAxis="Population (mi)"
|
||||
data="my-incredible-data.csv"
|
||||
/>
|
||||
```
|
||||
|
||||
Now, create a file in `public/` named `my-incredible-data.csv` and put the following content inside it:
|
||||
|
||||
```bash
|
||||
Year,Population (mi)
|
||||
1980,227
|
||||
1990,249
|
||||
2000,281
|
||||
2010,309
|
||||
2020,331
|
||||
```
|
||||
|
||||
Note that pages are associated with a route based on their pathname, so, to see the new data page, access http://localhost:3000/my-incredible-dataset from the browser. You should see the following:
|
||||
|
||||
<img src="/assets/docs/my-incredible-dataset.png" />
|
||||
|
||||
> [!tip]
|
||||
> In this tutorial we opted for storing content as markdown files and data as CSV files in the app, but PortalJS can have metadata, data and content stored anywhere.
|
||||
|
||||
### Create an index page
|
||||
|
||||
Now, let's create an index page. First, create a new folder `content/my-awesome-dataset/` and move `content/index.md` to it. Then, create a new file `content/index.md` and put the following content inside it:
|
||||
|
||||
```markdown
|
||||
# Welcome to my data portal!
|
||||
|
||||
List of available datasets:
|
||||
|
||||
- [My Awesome Dataset](/my-awesome-dataset)
|
||||
- [My Incredible Dataset](/my-incredible-dataset)
|
||||
|
||||
```
|
||||
|
||||
From the browser, access http://localhost:3000. You should see the following:
|
||||
|
||||
<img src="/assets/docs/datasets-index-page.png" />
|
||||
|
||||
### Creating a search page
|
||||
|
||||
Typing out every link in the index page will get cumbersome eventually, and as the portal grows, finding the datasets you are looking for on the index page will become harder and harder. Luckily we have a component for that. Change your `content/index.md` file to this:
|
||||
|
||||
```
|
||||
# Welcome to my data portal!
|
||||
|
||||
List of available datasets:
|
||||
|
||||
<Catalog datasets={datasets} />
|
||||
```
|
||||
|
||||
Before you refresh the page, however, you will need to run the following command:
|
||||
|
||||
```
|
||||
npm run mddb
|
||||
```
|
||||
|
||||
This example makes use of the [markdowndb](https://github.com/datopian/markdowndb) library. For now the only thing you need to know is that you should run the command above everytime you make some change to `/content`.
|
||||
|
||||
From the browser, access http://localhost:3000. You should see the following, you now have a searchable automatic list of your datasets:
|
||||
|
||||

|
||||
|
||||
To make this catalog look even better, we can change the text that is being displayed for each dataset to a title. Let's do that by adding the "title" [frontmatter field](https://daily-dev-tips.com/posts/what-exactly-is-frontmatter/) to the first dataset in the list. Change `content/my-awesome-dataset/index.md` to the following:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'My awesome dataset'
|
||||
---
|
||||
|
||||
# My Awesome Dataset
|
||||
|
||||
Built with PortalJS
|
||||
|
||||
## Table
|
||||
|
||||
<Table url="data.csv" />
|
||||
```
|
||||
|
||||
Rerun `npm run mddb` and, from the browser, access http://localhost:3000. You should see the title appearing instead of the folder name:
|
||||
|
||||

|
||||
|
||||
Any frontmatter attribute that you add will automatically get indexed and be usable in the search box.
|
||||
|
||||
## Deploying your PortalJS app
|
||||
|
||||
Finally, let's learn how to deploy PortalJS apps to Vercel or Cloudflare Pages.
|
||||
|
||||
> [!tip]
|
||||
> Although we are using Vercel and Cloudflare Pages in this tutorial, you can deploy apps in any hosting solution you want as a static website by running `npm run export` and distributing the contents of the `out/` folder.
|
||||
|
||||
### Push to a GitHub repo
|
||||
|
||||
The PortalJS app we built up to this point is stored locally. To allow Vercel or Cloudflare Pages to deploy it, we have to push it to GitHub (or another SCM supported by these hosting solutions).
|
||||
|
||||
- Create a new repository under your GitHub account
|
||||
- Add the new remote origin to your PortalJS app
|
||||
- Push the app to the repository
|
||||
|
||||
If you are not sure about how to do it, follow this guide: https://nextjs.org/learn/basics/deploying-nextjs-app/github
|
||||
|
||||
> [!tip]
|
||||
> You can also deploy using our Vercel deploy button. In this case, a new repository will be created under your GitHub account automatically.
|
||||
> [Click here](#one-click-deploy) to scroll to the deploy button.
|
||||
|
||||
### Deploy to Vercel
|
||||
|
||||
The easiest way to deploy a PortalJS app is to use Vercel, a serverless platform for static and hybrid applications developed by the creators of Next.js.
|
||||
|
||||
To deploy your PortalJS app:
|
||||
|
||||
- Create a Vercel account by going to https://vercel.com/signup and choosing "Continue with GitHub"
|
||||
- Import the repository you created for the PortalJS app at https://vercel.com/new
|
||||
- During the setup process you can use the default settings - no need to change anything.
|
||||
|
||||
When you deploy, your PortalJS app will start building. It should finish in under a minute.
|
||||
|
||||
When it’s done, you’ll get deployment URLs. Click on one of the URLs and you should see your PortaJS app live.
|
||||
|
||||
>[!tip]
|
||||
> You can find a more in-depth explanation about this process at https://nextjs.org/learn/basics/deploying-nextjs-app/deploy
|
||||
|
||||
#### One-Click Deploy
|
||||
|
||||
You can instantly deploy our example app to your Vercel account by clicking the button below:
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fdatopian%2Fportaljs%2Ftree%2Fmain%2Fexamples%2Flearn-example&project-name=my-data-portal&repository-name=my-data-portal&demo-title=PortalJS%20Learn%20Example&demo-description=PortalJS%20Learn%20Example%20-%20https%3A%2F%2Fportaljs.org%2Fdocs&demo-url=learn-example.portaljs.org&demo-image=https%3A%2F%2Fportaljs.org%2Fassets%2Fexamples%2Fbasic-example.png)
|
||||
|
||||
This will create a new repository on your GitHub account and deploy it to Vercel. If you are following the tutorial, you can replicate the changes done on your local app to this new repository.
|
||||
|
||||
### Deploy to Cloudflare Pages
|
||||
|
||||
To deploy your PortalJS app to Cloudflare Pages, follow this guide:
|
||||
|
||||
https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/#deploy-with-cloudflare-pages-1
|
||||
|
||||
Note that you don't have to change anything - just follow the steps, choosing the repository you created.
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# Searching datasets
|
||||
|
||||
Typing out every link in the index page will get cumbersome eventually, and as the portal grows, finding the datasets you are looking for on the index page will become harder and harder, for that we will need search functionality.
|
||||
|
||||
## Creating a search page
|
||||
|
||||
Luckily we have a component for that. Change your `content/index.md` file to this:
|
||||
|
||||
```
|
||||
# Welcome to my data portal!
|
||||
|
||||
List of available datasets:
|
||||
|
||||
<Catalog datasets={datasets} />
|
||||
```
|
||||
|
||||
Before you refresh the page, however, you will need to run the following command:
|
||||
|
||||
```
|
||||
npm run mddb
|
||||
```
|
||||
|
||||
This example makes use of the [markdowndb](https://github.com/datopian/markdowndb) library. For now the only thing you need to know is that you should run the command above everytime you make some change to `/content`.
|
||||
|
||||
From the browser, access http://localhost:3000. You should see the following, you now have a searchable automatic list of your datasets:
|
||||
|
||||

|
||||
|
||||
To make this catalog look even better, we can change the text that is being displayed for each dataset to a title. Let's do that by adding the "title" [frontmatter field](https://daily-dev-tips.com/posts/what-exactly-is-frontmatter/) to the first dataset in the list. Change `content/my-awesome-dataset/index.md` to the following:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'My awesome dataset'
|
||||
---
|
||||
|
||||
# My Awesome Dataset
|
||||
|
||||
Built with PortalJS
|
||||
|
||||
## Table
|
||||
|
||||
<Table url="data.csv" />
|
||||
```
|
||||
|
||||
Rerun `npm run mddb` and, from the browser, access http://localhost:3000. You should see the title appearing instead of the folder name:
|
||||
|
||||

|
||||
|
||||
Any frontmatter attribute that you add will automatically get indexed and be usable in the search box.
|
||||
|
||||
## Adding filters
|
||||
|
||||
Sometimes contextual search is not enough. Let's add a filter. To do so, lets add a new metadata field called "group", add it to your `content/my-incredible-dataset/index.md` like so:
|
||||
|
||||
```
|
||||
---
|
||||
group: 'Incredible'
|
||||
---
|
||||
|
||||
# My Incredible Dataset
|
||||
|
||||
This is my incredible dataset.
|
||||
|
||||
## Chart
|
||||
|
||||
<LineChart
|
||||
title="US Population By Decade"
|
||||
xAxis="Year"
|
||||
yAxis="Population (mi)"
|
||||
data="my-incredible-data.csv"
|
||||
/>
|
||||
```
|
||||
|
||||
Also add it to your `content/my-awesome-dataset/index.md` like so:
|
||||
|
||||
```
|
||||
---
|
||||
title: 'My awesome dataset'
|
||||
group: 'Awesome'
|
||||
---
|
||||
|
||||
# My Awesome Dataset
|
||||
|
||||
Built with PortalJS
|
||||
|
||||
## Table
|
||||
|
||||
<Table url="data.csv" />
|
||||
```
|
||||
|
||||
Now on your `content/index.md` you can add a "facet" to the `Catalog` component, like so:
|
||||
|
||||
```
|
||||
# Welcome to my data portal!
|
||||
|
||||
List of available datasets:
|
||||
|
||||
<Catalog datasets={datasets} facets={['group']}/>
|
||||
```
|
||||
|
||||
You now have a filter in your page with all possible values automatically added to it.
|
||||
|
||||

|
||||
|
||||
<DocsPagination prev="/docs/creating-new-datasets" next="/docs/showing-metadata" />
|
||||
@@ -1,36 +0,0 @@
|
||||
# Showing metadata
|
||||
|
||||
If you go now to `http://localhost:3000/my-awesome-dataset`, you will see that we now have two titles on the page. That's because `title` is one of the default metadata fields supported by PortalJS.
|
||||
|
||||

|
||||
|
||||
Change the content inside `/content/my-awesome-dataset/index.md` to this.
|
||||
|
||||
```
|
||||
---
|
||||
title: 'My awesome dataset'
|
||||
author: 'Rufus Pollock'
|
||||
description: 'An awesome dataset displaying some awesome data'
|
||||
modified: '2023-05-04'
|
||||
files: ['data.csv']
|
||||
groups: ['Awesome']
|
||||
---
|
||||
|
||||
Built with PortalJS
|
||||
|
||||
## Table
|
||||
|
||||
<Table url="data.csv" />
|
||||
```
|
||||
|
||||
Once you refresh the page at `http://localhost:3000/my-awesome-dataset` you should see something like this at the top:
|
||||
|
||||

|
||||
|
||||
These are the standard metadata fields that will be shown at the top of the page if you add them.
|
||||
|
||||
- `title` that gets displayed as a big header at the top of the page
|
||||
- `author`, `description`, and `modified` which gets displayed below the title
|
||||
- `files` that get displayed as a table with two columns: `File` which is linked directly to the file, and `Format` which show the file format.
|
||||
|
||||
<DocsPagination prev="/docs/searching-datasets" next="/docs/deploying-your-portaljs-app" />
|
||||
@@ -1,44 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "Getting started",
|
||||
"children": [
|
||||
{
|
||||
"name": "Setup",
|
||||
"href": "/docs"
|
||||
},
|
||||
{
|
||||
"name": "Creating new datasets",
|
||||
"href": "/docs/creating-new-datasets"
|
||||
},
|
||||
{
|
||||
"name": "Searching datasets",
|
||||
"href": "/docs/searching-datasets"
|
||||
},
|
||||
{
|
||||
"name": "Showing metadata",
|
||||
"href": "/docs/showing-metadata"
|
||||
},
|
||||
{
|
||||
"name": "Deploying your PortalJS app",
|
||||
"href": "/docs/deploying-your-portaljs-app"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Components",
|
||||
"href": "/docs/components"
|
||||
},
|
||||
{
|
||||
"name": "Examples",
|
||||
"children": [
|
||||
{
|
||||
"name": "Data Catalog w/ CKAN datasets",
|
||||
"href": "/docs/examples/example-ckan"
|
||||
},
|
||||
{
|
||||
"name": "Data Catalog w/ GitHub datasets",
|
||||
"href": "/docs/examples/example-data-catalog"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
5
site/content/gallery.md
Normal file
5
site/content/gallery.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Gallery
|
||||
---
|
||||
|
||||
Come back soon!
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { formatDate } from "@/lib/formatDate";
|
||||
|
||||
export const BlogLayout: React.FC<any> = ({ children, ...frontMatter }) => {
|
||||
const { title, date, authorsDetails } = frontMatter;
|
||||
return (
|
||||
<article className="prose mx-auto prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark text-primary dark:text-primary-dark prose-headings:font-headings dark:prose-invert prose-a:break-words">
|
||||
<header>
|
||||
<div className="mb-4 flex-col items-center">
|
||||
{title && <h1 className="flex justify-center">{title}</h1>}
|
||||
{date && (
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500 flex justify-center">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
</p>
|
||||
)}
|
||||
{authorsDetails && (
|
||||
<div className="flex flex-wrap not-prose items-center space-x-6 space-y-3 justify-center">
|
||||
{authorsDetails.map(({ name, avatar, isDraft, url_path }) => (
|
||||
<Avatar
|
||||
key={url_path || name}
|
||||
name={name}
|
||||
img={avatar}
|
||||
href={url_path && !isDraft ? `/${url_path}` : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<section>{children}</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
export default function DefaultLayout({ children, ...frontMatter }) {
|
||||
return (
|
||||
<div className="prose mx-auto prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark text-primary dark:text-primary-dark prose-headings:font-headings dark:prose-invert prose-a:break-words">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
{/* Default layout */}
|
||||
{!frontMatter.layout && (
|
||||
<>
|
||||
<h1>{frontMatter.title}</h1>
|
||||
{frontMatter.author && (
|
||||
<div className="-mt-6">
|
||||
<p className="opacity-60 pl-1">{frontMatter.author}</p>
|
||||
</div>
|
||||
)}
|
||||
{frontMatter.description && (
|
||||
<p className="description">{frontMatter.description}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/* eslint import/no-default-export: off */
|
||||
import { formatDate } from "@/lib/formatDate";
|
||||
|
||||
// TODO types
|
||||
export const DocsLayout: React.FC<any> = ({ children, ...frontMatter }) => {
|
||||
const { title, created } = frontMatter;
|
||||
return (
|
||||
<article className="docs prose prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark dark:prose-code:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark text-primary dark:text-primary-dark dark:prose-invert prose-headings:font-headings prose-a:break-words mx-auto">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
{created && (
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500">
|
||||
<time dateTime={created}>{formatDate(created)}</time>
|
||||
</p>
|
||||
)}
|
||||
{title && <h1>{title}</h1>}
|
||||
</div>
|
||||
</header>
|
||||
<section>{children}</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,13 @@
|
||||
import {
|
||||
SimpleLayout,
|
||||
DocsLayout,
|
||||
UnstyledLayout,
|
||||
BlogLayout,
|
||||
} from "@flowershow/core";
|
||||
import { BlogLayout } from "./blog";
|
||||
import DefaultLayout from "./default";
|
||||
import { DocsLayout } from "./docs";
|
||||
|
||||
export default {
|
||||
simple: SimpleLayout,
|
||||
docs: DocsLayout,
|
||||
unstyled: UnstyledLayout,
|
||||
blog: BlogLayout,
|
||||
default: DefaultLayout
|
||||
};
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
// This file is a temporary replacement for legacy contentlayer's computeFields + default fields values
|
||||
import { remark } from "remark";
|
||||
import stripMarkdown, { Options } from "strip-markdown";
|
||||
|
||||
import { siteConfig } from "../config/siteConfig";
|
||||
import { getAuthorsDetails } from "./getAuthorsDetails";
|
||||
import sluggify from "./sluggify";
|
||||
|
||||
// TODO return type
|
||||
|
||||
const computeFields = async ({
|
||||
frontMatter,
|
||||
urlPath,
|
||||
filePath,
|
||||
source,
|
||||
}: {
|
||||
frontMatter: Record<string, any>;
|
||||
urlPath: string;
|
||||
filePath: string;
|
||||
source: string;
|
||||
}) => {
|
||||
// Fields with corresponding config options
|
||||
// TODO see _app.tsx
|
||||
const showComments =
|
||||
frontMatter.showComments ?? siteConfig.showComments ?? false;
|
||||
const showEditLink =
|
||||
frontMatter.showEditLink ?? siteConfig.showEditLink ?? false;
|
||||
// TODO take config into accout
|
||||
const showLinkPreviews =
|
||||
frontMatter.showLinkPreviews ?? siteConfig.showLinkPreviews ?? false;
|
||||
const showToc = frontMatter.showToc ?? siteConfig.showToc ?? false;
|
||||
const showSidebar =
|
||||
frontMatter.showSidebar ?? siteConfig.showSidebar ?? false;
|
||||
const sidebarTreeFile = frontMatter.sidebarTreeFile ?? null;
|
||||
|
||||
// Computed fields
|
||||
// const title = frontMatter.title ?? (await extractTitle(source));
|
||||
const title = frontMatter.title ?? null;
|
||||
const description =
|
||||
frontMatter.description ?? (await extractDescription(source));
|
||||
const date = frontMatter.date ?? frontMatter.created ?? null;
|
||||
const layout = (() => {
|
||||
if (frontMatter.layout) return frontMatter.layout;
|
||||
if (urlPath.startsWith("blog/")) return "blog";
|
||||
// if (urlPath.startsWith("docs/")) return "docs";
|
||||
return "docs"; // TODO default layout from config?
|
||||
})();
|
||||
|
||||
// TODO Temporary, should probably be a column in the database
|
||||
const slug = sluggify(urlPath);
|
||||
// TODO take into accout include/exclude fields in config
|
||||
const isDraft = frontMatter.isDraft ?? false;
|
||||
const editUrl =
|
||||
(siteConfig.editLinkRoot && `${siteConfig.editLinkRoot}/${filePath}`) ||
|
||||
null;
|
||||
const authors = await getAuthorsDetails(frontMatter.authors);
|
||||
|
||||
return {
|
||||
...frontMatter,
|
||||
authors,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
layout,
|
||||
slug,
|
||||
urlPath, // extra for blogs index page; temporary here
|
||||
isDraft,
|
||||
editUrl,
|
||||
showComments,
|
||||
showEditLink,
|
||||
showLinkPreviews,
|
||||
showToc,
|
||||
showSidebar,
|
||||
sidebarTreeFile
|
||||
};
|
||||
};
|
||||
|
||||
const extractTitle = async (source: string) => {
|
||||
const heading = source.trim().match(/^#\s+(.*)/);
|
||||
if (heading) {
|
||||
const title = heading[1]
|
||||
// replace wikilink with only text value
|
||||
.replace(/\[\[([\S]*?)]]/, "$1");
|
||||
|
||||
const stripTitle = await remark().use(stripMarkdown).process(title);
|
||||
return stripTitle.toString().trim();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractDescription = async (source: string) => {
|
||||
const content = source
|
||||
// remove commented lines
|
||||
.replace(/{\/\*.*\*\/}/g, "")
|
||||
// remove import statements
|
||||
.replace(
|
||||
/^import\s*(?:\{\s*[\w\s,\n]+\s*\})?(\s*(\w+))?\s*from\s*("|')[^"]+("|');?$/gm,
|
||||
""
|
||||
)
|
||||
// remove youtube links
|
||||
.replace(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/gm, "")
|
||||
// replace wikilinks with only text
|
||||
.replace(/([^!])\[\[(\S*?)\]]/g, "$1$2")
|
||||
// remove wikilink images
|
||||
.replace(/!\[[\S]*?]]/g, "");
|
||||
|
||||
// remove markdown formatting
|
||||
const stripped = await remark()
|
||||
.use(stripMarkdown, {
|
||||
remove: ["heading", "blockquote", "list", "image", "html", "code"],
|
||||
} as Options)
|
||||
.process(content);
|
||||
|
||||
if (stripped.value) {
|
||||
const description: string = stripped.value.toString().slice(0, 200);
|
||||
return description + "...";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default computeFields;
|
||||
35
site/lib/data-literate/markdown.js
Normal file
35
site/lib/data-literate/markdown.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Used by Data Literate
|
||||
|
||||
import matter from 'gray-matter'
|
||||
import toc from 'remark-toc'
|
||||
import slug from 'remark-slug'
|
||||
import gfm from 'remark-gfm'
|
||||
import footnotes from 'remark-footnotes'
|
||||
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
|
||||
/**
|
||||
* Parse a markdown or MDX file to an MDX source form + front matter data
|
||||
*
|
||||
* @source: the contents of a markdown or mdx file
|
||||
* @returns: { mdxSource: mdxSource, frontMatter: ...}
|
||||
*/
|
||||
const parse = async function(source) {
|
||||
const { content, data } = matter(source)
|
||||
|
||||
const mdxSource = await serialize(content, {
|
||||
// Optionally pass remark/rehype plugins
|
||||
mdxOptions: {
|
||||
remarkPlugins: [gfm, toc, slug, footnotes],
|
||||
rehypePlugins: [],
|
||||
},
|
||||
scope: data,
|
||||
})
|
||||
|
||||
return {
|
||||
mdxSource: mdxSource,
|
||||
frontMatter: data
|
||||
}
|
||||
}
|
||||
|
||||
export default parse
|
||||
@@ -1,8 +0,0 @@
|
||||
export const formatDate = (date: string, locales = "en-US") => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
return new Date(date).toLocaleDateString(locales, options);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
const sluggify = (urlPath: string) => {
|
||||
return urlPath.replace(/^(.+?\/)*/, "");
|
||||
};
|
||||
|
||||
export default sluggify;
|
||||
3305
site/package-lock.json
generated
3305
site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@
|
||||
"mddb": "mddb content"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowershow/core": "^0.4.11",
|
||||
"@flowershow/markdowndb": "^0.1.1",
|
||||
"@flowershow/core": "^0.4.9",
|
||||
"@flowershow/markdowndb": "^0.1.0",
|
||||
"@flowershow/remark-callouts": "^1.0.0",
|
||||
"@flowershow/remark-embed": "^1.0.0",
|
||||
"@flowershow/remark-wiki-link": "^1.0.1",
|
||||
@@ -47,7 +47,7 @@
|
||||
"remark-toc": "^7.2.0",
|
||||
"vega": "^5.20.2",
|
||||
"vega-lite": "^5.1.0",
|
||||
"strip-markdown": "^5.0.0"
|
||||
"xlsx": "^0.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
@@ -55,7 +55,6 @@
|
||||
"postcss": "^8.4.22",
|
||||
"prettier": "^2.8.7",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "^5.0.4",
|
||||
"remark": "^14.0.2"
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,15 @@ import parse from '../lib/markdown.mjs';
|
||||
|
||||
import MDXPage from '../components/MDXPage';
|
||||
import clientPromise from '@/lib/mddb';
|
||||
import { getAuthorsDetails } from 'lib/getAuthorsDetails';
|
||||
import Layout from 'components/Layout';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router.js';
|
||||
import { NavGroup, NavItem, collectHeadings } from '@flowershow/core';
|
||||
import { GetStaticProps, GetStaticPropsResult } from 'next';
|
||||
import { CustomAppProps } from './_app.jsx';
|
||||
import computeFields from '@/lib/computeFields';
|
||||
import { getAuthorsDetails } from '@/lib/getAuthorsDetails';
|
||||
import { collectHeadings } from '@flowershow/core';
|
||||
|
||||
export default function Page({ source, meta, sidebarTree }) {
|
||||
export default function DRDPage({ source, frontMatter }) {
|
||||
source = JSON.parse(source);
|
||||
frontMatter = JSON.parse(frontMatter);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -29,81 +27,50 @@ export default function Page({ source, meta, sidebarTree }) {
|
||||
}, [router.asPath]); // update table of contents on route change with next/link
|
||||
|
||||
return (
|
||||
<Layout
|
||||
tableOfContents={tableOfContents}
|
||||
title={meta.title}
|
||||
sidebarTree={sidebarTree}
|
||||
urlPath={meta.urlPath}
|
||||
>
|
||||
<MDXPage source={source} frontMatter={meta} />
|
||||
<Layout tableOfContents={tableOfContents} title={frontMatter.title}>
|
||||
<MDXPage source={source} frontMatter={frontMatter} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlugPageProps extends CustomAppProps {
|
||||
source: any;
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({
|
||||
params,
|
||||
}): Promise<GetStaticPropsResult<SlugPageProps>> => {
|
||||
const urlPath = params?.slug ? (params.slug as string[]).join('/') : '/';
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const urlPath = params.slug ? params.slug.join('/') : '';
|
||||
|
||||
const mddb = await clientPromise;
|
||||
const dbFile = await mddb.getFileByUrl(urlPath);
|
||||
const filePath = dbFile!.file_path;
|
||||
const frontMatter = dbFile!.metadata ?? {};
|
||||
|
||||
const dbBacklinks = await mddb.getLinks({
|
||||
fileId: dbFile._id,
|
||||
direction: 'backward',
|
||||
});
|
||||
// TODO temporary solution, we will have a method on MddbFile to get these links
|
||||
const dbBacklinkFilesPromises = dbBacklinks.map((link) =>
|
||||
mddb.getFileById(link.from)
|
||||
);
|
||||
const dbBacklinkFiles = await Promise.all(dbBacklinkFilesPromises);
|
||||
const dbBacklinkUrls = dbBacklinkFiles.map(
|
||||
(file) => file.toObject().url_path
|
||||
);
|
||||
|
||||
// TODO we can already get frontmatter from dbFile.metadata
|
||||
// so parse could only return mdxSource
|
||||
const source = fs.readFileSync(dbFile.file_path, { encoding: 'utf-8' });
|
||||
const { mdxSource, frontMatter } = await parse(source, 'mdx', {
|
||||
backlinks: dbBacklinkUrls,
|
||||
});
|
||||
|
||||
// Temporary, so that blogs work properly
|
||||
if (dbFile.metadata.filetype === 'blog') {
|
||||
if (dbFile.url_path.startsWith('blog/')) {
|
||||
frontMatter.layout = 'blog';
|
||||
frontMatter.authorsDetails = await getAuthorsDetails(
|
||||
dbFile.metadata.authors
|
||||
);
|
||||
}
|
||||
|
||||
// Temporary, docs pages should present the LHS sidebar
|
||||
if (dbFile.url_path.startsWith('docs')) {
|
||||
frontMatter.showSidebar = true;
|
||||
frontMatter.sidebarTreeFile = 'content/docs/sidebar.json';
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(filePath, { encoding: 'utf-8' });
|
||||
const { mdxSource } = await parse(source, 'mdx', {});
|
||||
|
||||
// TODO temporary replacement for contentlayer's computedFields
|
||||
const frontMatterWithComputedFields = await computeFields({
|
||||
frontMatter,
|
||||
urlPath,
|
||||
filePath,
|
||||
source,
|
||||
});
|
||||
|
||||
let sidebarTree: Array<NavGroup | NavItem> = [];
|
||||
|
||||
if (frontMatterWithComputedFields?.showSidebar) {
|
||||
let sidebarTreeFile = frontMatterWithComputedFields?.sidebarTreeFile;
|
||||
|
||||
// Added this file funcionality so that we can control
|
||||
// which items appear in the sidebar and the order via
|
||||
// a json file
|
||||
if (sidebarTreeFile) {
|
||||
const tree = fs.readFileSync(sidebarTreeFile, { encoding: 'utf-8' });
|
||||
sidebarTree = JSON.parse(tree);
|
||||
} else {
|
||||
const allPages = await mddb.getFiles({ extensions: ['md', 'mdx'] });
|
||||
const pages = allPages.filter((p) => !p.metadata?.isDraft);
|
||||
pages.forEach((page) => {
|
||||
addPageToSitemap(page, sidebarTree);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
source: JSON.stringify(mdxSource),
|
||||
meta: frontMatterWithComputedFields,
|
||||
sidebarTree,
|
||||
frontMatter: JSON.stringify(frontMatter),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -112,10 +79,13 @@ export async function getStaticPaths() {
|
||||
const mddb = await clientPromise;
|
||||
let allDocuments = await mddb.getFiles({ extensions: ['md', 'mdx'] });
|
||||
|
||||
const paths = allDocuments
|
||||
.filter((page) => page.metadata?.isDraft !== true)
|
||||
.map((page) => {
|
||||
const parts = page.url_path!.split('/');
|
||||
// Avoid duplicate path
|
||||
allDocuments = allDocuments.filter(
|
||||
(doc) => !doc.url_path.startsWith('data-literate/')
|
||||
);
|
||||
|
||||
const paths = allDocuments.map((page) => {
|
||||
const parts = page.url_path.split('/');
|
||||
return { params: { slug: parts } };
|
||||
});
|
||||
|
||||
@@ -124,58 +94,3 @@ export async function getStaticPaths() {
|
||||
fallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
function capitalize(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/* function addPageToGroup(page: MddbFile, sitemap: Array<NavGroup>) { */
|
||||
function addPageToSitemap(page: any, sitemap: Array<NavGroup | NavItem>) {
|
||||
const urlParts = page.url_path!.split('/').filter((part) => part);
|
||||
// don't add home page to the sitemap
|
||||
if (urlParts.length === 0) return;
|
||||
// top level, root pages
|
||||
if (urlParts.length === 1) {
|
||||
sitemap.push({
|
||||
name: page.metadata?.title || urlParts[0],
|
||||
href: page.url_path,
|
||||
});
|
||||
} else {
|
||||
// /blog/blogtest
|
||||
const nestingLevel = urlParts.length - 1; // 1
|
||||
let currArray: Array<NavItem | NavGroup> = sitemap;
|
||||
|
||||
for (let level = 0; level <= nestingLevel; level++) {
|
||||
if (level === nestingLevel) {
|
||||
currArray.push({
|
||||
name: urlParts[level],
|
||||
href: page.url_path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingGroup = currArray
|
||||
.filter(isNavGroup)
|
||||
.find(
|
||||
(group) =>
|
||||
group.path !== undefined && page.url_path.startsWith(group.path)
|
||||
);
|
||||
if (!matchingGroup) {
|
||||
const newGroup: NavGroup = {
|
||||
name: capitalize(urlParts[level]),
|
||||
path: urlParts.slice(0, level + 1).join('/'),
|
||||
level,
|
||||
children: [],
|
||||
};
|
||||
currArray.push(newGroup);
|
||||
currArray = newGroup.children;
|
||||
} else {
|
||||
currArray = matchingGroup.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNavGroup(item: NavItem | NavGroup): item is NavGroup {
|
||||
return (item as NavGroup).children !== undefined;
|
||||
}
|
||||
|
||||
@@ -5,25 +5,11 @@ import Script from "next/script";
|
||||
|
||||
import { DefaultSeo } from "next-seo";
|
||||
|
||||
import { NavGroup, NavItem, pageview, ThemeProvider } from "@flowershow/core";
|
||||
import { pageview, ThemeProvider } from "@flowershow/core";
|
||||
import { siteConfig } from "../config/siteConfig";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/dist/client/router";
|
||||
|
||||
export interface CustomAppProps {
|
||||
meta: {
|
||||
showToc: boolean;
|
||||
showEditLink: boolean;
|
||||
showSidebar: boolean;
|
||||
showComments: boolean;
|
||||
urlPath: string; // not sure what's this for
|
||||
editUrl?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
siteMap?: Array<NavItem | NavGroup>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Layout from '@/components/Layout';
|
||||
import computeFields from '@/lib/computeFields';
|
||||
import clientPromise from '@/lib/mddb';
|
||||
import { BlogsList, SimpleLayout } from '@flowershow/core';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export default function Blog({ blogs }) {
|
||||
return (
|
||||
@@ -34,29 +32,19 @@ export async function getStaticProps() {
|
||||
|
||||
blogs = [...blogs, ...docs];
|
||||
|
||||
const blogsWithComputedFields = blogs.map(async (blog) => {
|
||||
const source = fs.readFileSync(blog.file_path, { encoding: 'utf-8' });
|
||||
|
||||
return await computeFields({
|
||||
frontMatter: blog.metadata,
|
||||
urlPath: blog.url_path,
|
||||
filePath: blog.file_path,
|
||||
source,
|
||||
});
|
||||
});
|
||||
|
||||
const blogList = await Promise.all(blogsWithComputedFields);
|
||||
|
||||
const blogsSorted = blogList.sort(
|
||||
const blogsSorted = blogs.sort(
|
||||
(a, b) =>
|
||||
new Date(b?.date).getTime() -
|
||||
new Date(a?.date).getTime()
|
||||
new Date(b.metadata.date).getTime() - new Date(a.metadata.date).getTime()
|
||||
);
|
||||
|
||||
// Temporary, flowershow/BlogsList expects the contentlayer fields
|
||||
const blogsObjects = blogsSorted.map((b) => {
|
||||
return { ...b, ...b.metadata };
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
blogs: blogsSorted,
|
||||
blogs: blogsObjects,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
23
site/pages/data-literate/demo.tsx
Normal file
23
site/pages/data-literate/demo.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import fs from "fs";
|
||||
|
||||
import parse from "../../lib/data-literate/markdown";
|
||||
|
||||
import DataLiterate from "../../components/data-literate/DataLiterate";
|
||||
|
||||
export default function PostPage({ source, frontMatter }) {
|
||||
return <DataLiterate source={source} frontMatter={frontMatter} />;
|
||||
}
|
||||
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const mdxPath = "content/data-literate/demo.mdx";
|
||||
const source = fs.readFileSync(mdxPath);
|
||||
|
||||
const { mdxSource, frontMatter } = await parse(source);
|
||||
|
||||
return {
|
||||
props: {
|
||||
source: mdxSource,
|
||||
frontMatter: frontMatter,
|
||||
},
|
||||
};
|
||||
};
|
||||
12
site/pages/excel-viewer.js
Normal file
12
site/pages/excel-viewer.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Head from 'next/head'
|
||||
import SheetJSApp from '../components/ExcelViewerApp.js'
|
||||
import Layout from '../components/Layout'
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<Layout title='Excel Viewer'>
|
||||
<h1>Excel Viewer</h1>
|
||||
<SheetJSApp />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user