Compare commits

..

2 Commits

Author SHA1 Message Date
Luccas Mateus de Medeiros Gomes
879c079274 [examples/openspending][xs] - fix typo 2023-05-18 15:07:57 -03:00
Luccas Mateus de Medeiros Gomes
e1d38374c2 [examples/openspending][m] - table preview 2023-05-18 15:06:34 -03:00
36 changed files with 399 additions and 4765 deletions

1
.gitignore vendored
View File

@@ -16,7 +16,6 @@ node_modules
*.launch
.settings/
*.sublime-workspace
.obsidian
# IDE - VSCode
.vscode/*

View File

@@ -1,138 +1,45 @@
import { expect, test } from 'vitest';
import { getAllProjectsFromOrg, getProjectDataPackage } from '../lib/project';
import { loadDataPackage } from '../lib/loader';
import { getProjectMetadata } from '../lib/project';
import { getCsv, parseCsv } from '../components/Table';
import { Octokit } from 'octokit';
import { assert, expect, test } from 'vitest'
import { getProjectDataPackage } from '../lib/octokit';
test(
'Test OS-Data',
async () => {
const repos = await getAllProjectsFromOrg(
'os-data',
'main',
process.env.VITE_GITHUB_PAT
);
if (repos.failed.length > 0) console.log(repos.failed);
expect(repos.failed.length).toBe(0);
},
{ timeout: 100000 }
);
export async function getAllDataPackagesFromOrg(
org: string,
branch?: string,
github_pat?: string
) {
const octokit = new Octokit({ auth: github_pat });
const repos = await octokit.rest.repos.listForOrg({ org, type: 'public', per_page: 100 });
let failedDataPackages = [];
const datapackages = await Promise.all(
repos.data.map(async (_repo) => {
const datapackage = await getProjectDataPackage(
org,
_repo.name,
branch ? branch : 'main',
github_pat
);
if (!datapackage) {
failedDataPackages.push(_repo.name)
return null
};
return {...datapackage, repo: _repo.name};
})
);
return {
datapackages: datapackages.filter((item) => item !== null),
failedDataPackages,
};
}
test(
'Test Gift-Data',
async () => {
const repos = await getAllProjectsFromOrg(
'gift-data',
'main',
process.env.VITE_GITHUB_PAT
);
if (repos.failed.length > 0) console.log(repos.failed);
expect(repos.failed.length).toBe(0);
},
{ timeout: 100000 }
);
test('Test OS-Data', async () => {
const repos = await getAllDataPackagesFromOrg('os-data', 'main', process.env.VITE_GITHUB_PAT)
if (repos.failedDataPackages.length > 0) console.log(repos.failedDataPackages)
expect(repos.failedDataPackages.length).toBe(0)
}, {timeout: 100000})
test(
'Test getting one dataset from github',
async () => {
const datapackage = await getProjectDataPackage(
'os-data',
'berlin-berlin',
'main',
process.env.VITE_GITHUB_PAT
);
const repo = await getProjectMetadata(
'os-data',
'berlin-berlin',
process.env.VITE_GITHUB_PAT
);
const project = loadDataPackage(datapackage, repo);
delete project['datapackage'];
delete project.files[0]['dialect'];
delete project.files[0]['schema'];
expect(project).toStrictEqual({
name: 'berlin-berlin',
title: 'Berlin-Berlin',
description: null,
owner: {
name: 'os-data',
logo: 'https://avatars.githubusercontent.com/u/13695166?v=4',
title: 'os-data',
},
repo: {
name: 'berlin-berlin',
full_name: 'os-data/berlin-berlin',
url: 'https://github.com/os-data/berlin-berlin',
},
files: [
{
name: 'berlin-gesamt',
format: 'csv',
path: 'https://storage.openspending.org/berlin-berlin/berlin-gesamt.csv',
mediatype: 'text/csv',
bytes: 81128743,
encoding: 'utf-8',
},
],
author: 'Michael Peters <michael.peters@okfn.de>',
cityCode: 'Berlin',
countryCode: 'DE',
fiscalPeriod: { start: '2014-01-01', end: '2019-12-31' },
readme: '',
});
},
{ timeout: 100000 }
);
test('Test Gift-Data', async () => {
const repos = await getAllDataPackagesFromOrg('gift-data', 'main', process.env.VITE_GITHUB_PAT)
if (repos.failedDataPackages.length > 0) console.log(repos.failedDataPackages)
expect(repos.failedDataPackages.length).toBe(0)
}, {timeout: 100000})
test(
'Test getting one section of csv from R2',
async () => {
const rawCsv = await getCsv(
'https://storage.openspending.org/state-of-minas-gerais-brazil-planned-budget/__os_imported__br-mg-ppagloc.csv'
);
const parsedCsv = await parseCsv(rawCsv);
expect(parsedCsv.errors.length).toBe(1);
expect(parsedCsv.data.length).toBe(10165);
expect(parsedCsv.meta.fields).toStrictEqual([
'function_name',
'function_label',
'product_name',
'product_label',
'area_name',
'area_label',
'subaction_name',
'subaction_label',
'region_label_map',
'region_reg_map',
'region_name',
'region_label',
'municipality_map_id',
'municipality_name',
'municipality_map_code',
'municipality_label',
'municipality_map_name_simple',
'municipality_map_name',
'cofog1_label_en',
'cofog1_name',
'cofog1_label',
'amount',
'subprogramme_name',
'subprogramme_label',
'time_name',
'time_year',
'time_month',
'time_day',
'time_week',
'time_yearmonth',
'time_quarter',
'time',
'action_name',
'action_label',
'subfunction_name',
'subfunction_label',
'programme_name',
'programme_label',
]);
},
{ timeout: 100000 }
);

View File

@@ -9,7 +9,7 @@ export default function DatasetCard({ dataset }: { dataset: Project }) {
className="overflow-hidden rounded-xl border border-gray-200"
>
<Link
href={`/@${dataset.owner.name}/${dataset.repo.name}`}
href=""
className="flex items-center gap-x-4 border-b border-gray-900/5 bg-gray-50 p-6"
>
<img
@@ -60,8 +60,8 @@ export default function DatasetCard({ dataset }: { dataset: Project }) {
<dd className="flex items-start gap-x-2">
<div className="font-medium text-gray-900">
<Link
// TODO: this link may be incorrect for some datasets
href={`https://github.com/${dataset.owner.name}/${dataset.repo.name}/blob/main/datapackage.json`}
// TODO: where do we get the info needed for this link?
href=""
target="_blank"
className="flex items-center hover:text-gray-700"
>

View File

@@ -2,26 +2,9 @@ import { useForm } from 'react-hook-form';
import DatasetsGrid from './DatasetsGrid';
import { Project } from '../lib/project.interface';
import { Index } from 'flexsearch';
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/react/24/solid';
import { useState } from 'react';
export default function DatasetsSearch({
datasets,
availableCountries,
}: {
datasets: Project[];
availableCountries;
}) {
const itemsPerPage = 6;
const [page, setPage] = useState(1);
export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
const index = new Index({ tokenize: 'full' });
datasets.forEach((dataset: Project) =>
index.add(
dataset.name,
@@ -38,53 +21,12 @@ export default function DatasetsSearch({
},
});
const filteredDatasets = datasets
.filter((dataset: Project) =>
watch().searchTerm && watch().searchTerm !== ''
? index.search(watch().searchTerm).includes(dataset.name)
: true
)
.filter((dataset) =>
watch().country && watch().country !== ''
? dataset.countryCode === watch().country
: true
)
.filter((dataset) => {
const filterMinDate = watch().minDate;
const filterMaxDate = watch().maxDate;
const datasetMinDate = dataset.fiscalPeriod?.start;
const datasetMaxDate = dataset.fiscalPeriod?.end;
let datasetStartOverlaps = false;
if (datasetMinDate) {
datasetStartOverlaps =
datasetMinDate >= filterMinDate && datasetMinDate <= filterMaxDate;
}
let datasetEndOverlaps = false;
if (datasetMaxDate) {
datasetEndOverlaps =
datasetMaxDate >= filterMinDate && datasetMaxDate <= filterMaxDate;
}
if (filterMinDate && filterMaxDate) {
return datasetStartOverlaps || datasetEndOverlaps;
} else if (filterMinDate) {
return datasetMinDate >= filterMinDate;
} else if (filterMaxDate) {
return datasetMinDate <= filterMaxDate;
}
return true;
});
const paginatedDatasets = filteredDatasets.slice(
(page - 1) * itemsPerPage,
(page - 1) * itemsPerPage + itemsPerPage
);
const pageCount = Math.ceil(filteredDatasets.length / itemsPerPage) || 1;
const allCountries = datasets
.map((item) => item.countryCode)
.filter((v) => v) // Filters false values
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
// TODO: title should be the full name
.map((code) => ({ code, title: code }));
return (
<>
@@ -95,7 +37,7 @@ export default function DatasetsSearch({
<input
placeholder="Search datasets"
aria-label="Search datasets"
{...register('searchTerm', { onChange: () => setPage(1) })}
{...register('searchTerm')}
className="h-[3em] relative w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
/>
{watch().searchTerm !== '' && (
@@ -113,10 +55,10 @@ export default function DatasetsSearch({
<label className="text-sm text-gray-600 font-medium">Country</label>
<select
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
{...register('country', { onChange: () => setPage(1) })}
{...register('country')}
>
<option value="">All</option>
{availableCountries.map((country) => {
{allCountries.map((country) => {
return (
<option key={country.code} value={country.code}>
{country.title}
@@ -126,76 +68,72 @@ export default function DatasetsSearch({
</select>
</div>
<div className="sm:basis-1/6">
<label className="text-sm text-gray-600 font-medium">
Fiscal Period Start
</label>
<label className="text-sm text-gray-600 font-medium">Min. date</label>
<div className="relative">
<input
aria-label="Min. date"
type="date"
{...register('minDate', { onChange: () => setPage(1) })}
{...register('minDate')}
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
/>
{watch().minDate !== '' && (
<button
onClick={() => resetField('minDate')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"
>
<CloseIcon />
</button>
)}
</div>
</div>
<div className="sm:basis-1/6">
<label className="text-sm text-gray-600 font-medium">
Fiscal Period End
</label>
<label className="text-sm text-gray-600 font-medium">Max. date</label>
<div className="relative">
<input
aria-label="Max. date"
type="date"
{...register('maxDate', { onChange: () => setPage(1) })}
{...register('maxDate')}
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
/>
{watch().maxDate !== '' && (
<button
onClick={() => resetField('maxDate')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"
>
<CloseIcon />
</button>
)}
</div>
</div>
</div>
<div className="mt-5 mb-5">
<span className="text-lg font-medium">
{filteredDatasets.length} datasets found
</span>
</div>
<div className="min-w-full align-middle">
<DatasetsGrid datasets={paginatedDatasets} />
<div className="w-full flex justify-center mt-10">
<button
onClick={() => setPage(1)}
disabled={page <= 1}
className="disabled:text-gray-400"
>
<ChevronDoubleLeftIcon className="w-6 h-6" />
</button>
<button
onClick={() => {
if (page > 1) setPage((prev) => --prev);
}}
disabled={page <= 1}
className="disabled:text-gray-400"
>
<ChevronLeftIcon className="w-6 h-6" />
</button>
<span className="mx-5">
Page {page} of {pageCount}
</span>
<button
onClick={() => {
if (page < pageCount) setPage((prev) => ++prev);
}}
disabled={page >= pageCount}
className="disabled:text-gray-400"
>
<ChevronRightIcon className="w-6 h-6" />
</button>
<button
onClick={() => setPage(pageCount)}
disabled={page >= pageCount}
className="disabled:text-gray-400"
>
<ChevronDoubleRightIcon className="w-6 h-6" />
</button>
</div>
<div className="min-w-full mt-10 align-middle">
<DatasetsGrid
datasets={datasets
.filter((dataset: Project) =>
watch().searchTerm && watch().searchTerm !== ''
? index.search(watch().searchTerm).includes(dataset.name)
: true
)
.filter((dataset) =>
watch().country && watch().country !== ''
? dataset.countryCode === watch().country
: true
)
// TODO: Does that really makes sense?
// What if the fiscalPeriod is 2015-2017 and inputs are
// set to 2015-2016. It's going to be filtered out but
// it shouldn't.
.filter((dataset) =>
watch().minDate && watch().minDate !== ''
? dataset.fiscalPeriod?.start >= watch().minDate
: true
)
.filter((dataset) =>
watch().maxDate && watch().maxDate !== ''
? dataset.fiscalPeriod?.end <= watch().maxDate
: true
)}
/>
</div>
</>
);
@@ -215,9 +153,9 @@ const CloseIcon = () => {
id="Vector"
d="M18 18L12 12M12 12L6 6M12 12L18 6M12 12L6 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>

View File

@@ -1,82 +1,53 @@
import Image from 'next/image';
import { Container } from './Container';
import logo from '../public/logo.svg';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Bars3Icon } from '@heroicons/react/24/outline';
import { useState } from 'react';
import Image from 'next/image'
import { Button } from './Button'
import { Container } from './Container'
import logo from "../public/logo.svg"
import Link from 'next/link'
import { useRouter } from 'next/router'
export function Header() {
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const router = useRouter();
const isActive = (navLink) => {
return router.asPath.split('?')[0] == navLink.href;
};
return router.asPath.split("?")[0] == navLink.href;
}
const navLinks = [
{
title: 'Home',
href: '/',
title: "Home",
href: "/#header"
},
{
title: 'Datasets',
href: '/#datasets',
title: "Datasets",
href: "/#datasets"
},
// {
// title: "Community",
// href: "https://community.openspending.org/"
// }
];
{
title: "Community",
href: "https://community.openspending.org/"
}
]
return (
<header className="relative z-50 pb-11 lg:pt-11">
<Container className="flex flex-wrap items-center justify-between lg:flex-nowrap mt-10 lg:mt-0">
<Link href="/" className="lg:mt-0 lg:grow lg:basis-0 flex items-center">
<header className="z-50 pb-5 lg:pt-11 sticky top-0 backdrop-blur" id="header">
<Container className="flex flex-wrap items-center justify-center sm:justify-between lg:flex-nowrap">
<div className="mt-10 lg:mt-0 lg:grow lg:basis-0 flex items-center">
<Image src={logo} alt="OpenSpending" className="h-12 w-auto" />
</Link>
<ul className="hidden list-none sm:flex gap-x-5 text-base font-medium">
</div>
<ul className='list-none flex gap-x-5 text-base font-medium'>
{navLinks.map((link, i) => (
<li key={`nav-link-${i}`}>
<Link
className={`text-emerald-900 hover:text-emerald-600 ${
isActive(link) ? 'text-emerald-600' : ''
}`}
className={`text-emerald-900 hover:text-emerald-600 ${isActive(link) ? "text-emerald-600" : ""}`}
href={link.href}
scroll={false}
>
{link.title}
</Link>
</li>
))}
</li>))}
</ul>
<div className="hidden xl:block xl:grow"></div>
<div className="sm:hidden sm:mt-10 lg:mt-0 lg:grow lg:basis-0 lg:justify-end">
<button onClick={() => setMenuOpen(!menuOpen)}>
<Bars3Icon className="w-8 h-8" />
</button>
<div className="hidden sm:mt-10 sm:flex lg:mt-0 lg:grow lg:basis-0 lg:justify-end">
</div>
{menuOpen && (
<div className={`sm:hidden basis-full mt-5 text-center`}>
<ul className="gap-x-5 text-base font-medium">
{navLinks.map((link, i) => (
<li key={`nav-link-${i}`}>
<Link
className={`text-emerald-900 hover:text-emerald-600 ${
isActive(link) ? 'text-emerald-600' : ''
}`}
href={link.href}
scroll={false}
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
)}
</Container>
</header>
);
</header >
)
}

View File

@@ -1,9 +1,9 @@
import { Button } from './Button';
import { Container } from './Container';
import { Button } from './Button'
import { Container } from './Container'
export function Hero({ countriesCount, datasetsCount, filesCount }) {
export function Hero() {
return (
<div className="relative pb-20 pt-10 sm:py-40" id="hero">
<div className="relative pb-20 pt-10 sm:py-40">
<div className="absolute inset-x-0 -bottom-14 -top-48 overflow-hidden bg-green-50 bg-opacity-50">
<div className="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white" />
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-white" />
@@ -15,13 +15,12 @@ export function Hero({ countriesCount, datasetsCount, filesCount }) {
</h1>
<div className="mt-6 space-y-6 font-display text-2xl tracking-tight text-emerald-900">
<p>
By understanding how governments spend money in our name can we
have a say in how that money will affect our own lives. The
journey starts here.
By understanding how governments spend money in our name can we have a say
in how that money will affect our own lives. The journey starts here.
</p>
<p>
OpenSpending is a free, open and global platform to search,
visualise and analyse fiscal data in the public sphere.
OpenSpending is a free, open and global platform to search, visualise and analyse
fiscal data in the public sphere.
</p>
</div>
<Button href="#datasets" className="mt-10">
@@ -29,11 +28,9 @@ export function Hero({ countriesCount, datasetsCount, filesCount }) {
</Button>
<dl className="mt-10 grid grid-cols-2 gap-x-10 gap-y-6 sm:mt-16 sm:gap-x-16 sm:gap-y-10 sm:text-center lg:auto-cols-auto lg:grid-flow-col lg:grid-cols-none lg:justify-start lg:text-left">
{[
// Added the plus sign because some datasets do not
// contain defined countries
['Countries', '+' + countriesCount],
['Datasets', datasetsCount],
['Files', filesCount],
['Countries', '75'],
['Datasets', '2091'],
['Files', '9230'],
].map(([name, value]) => (
<div key={name}>
<dt className="font-mono text-sm text-emerald-600">{name}</dt>
@@ -46,5 +43,5 @@ export function Hero({ countriesCount, datasetsCount, filesCount }) {
</div>
</Container>
</div>
);
)
}

View File

@@ -9,7 +9,7 @@ import { Grid } from '@githubocto/flat-ui';
const queryClient = new QueryClient();
export async function getCsv(url: string) {
async function getCsv(url: string) {
const response = await fetch(url, {
headers: {
Range: 'bytes=0-5132288',
@@ -62,7 +62,7 @@ const Spinning = () => {
);
};
export const FlatUiTable: React.FC<{ url: string }> = ({ url }) => {
export const Table: React.FC<{ url: string }> = ({ url }) => {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
@@ -87,7 +87,7 @@ const TableInner: React.FC<{ url: string }> = ({ url }) => {
</div>;
if (parsedData)
return (
<div className="h-[500px] w-full">
<div className="h-[500px] overflow-scroll">
<Grid data={parsedData.data} />
</div>
);

View File

@@ -1,10 +0,0 @@
import { Header } from '../Header';
export default function Layout({ children }) {
return (
<div className="bg-white min-h-screen pb-32">
<Header />
{children}
</div>
);
}

View File

@@ -0,0 +1,27 @@
[
{
"owner": "os-data",
"branch": "main",
"name": "mongolia-budget-2016-2017"
},
{
"owner": "os-data",
"branch": "main",
"name": "gb-country-regional-analysis"
},
{
"owner": "os-data",
"branch": "main",
"name": "berlin-berlin"
},
{
"owner": "os-data",
"branch": "main",
"name": "state-of-minas-gerais-brazil-planned-budget"
},
{
"owner": "os-data",
"branch": "main",
"name": "wesel"
}
]

View File

@@ -98,7 +98,6 @@ export interface TabularDataResource {
key?: string;
path?: string;
size?: number;
bytes?: number;
}
export interface Field {

View File

@@ -5,14 +5,13 @@ export function loadDataPackage(datapackage: FiscalDataPackage, repo): Project {
return {
name: datapackage.name,
title: datapackage.title,
description: datapackage.description || null,
owner: {
name: repo.owner.login,
logo: repo.owner.avatar_url,
// TODO: make this title work
title: repo.owner.login,
},
repo: { name: repo.name, full_name: repo.full_name, url: repo.html_url },
repo: { name: repo, full_name: repo.full_name },
files: datapackage.resources,
author: datapackage.author ? datapackage.author : null,
cityCode: datapackage.cityCode ? datapackage.cityCode : null,

View File

@@ -13,7 +13,8 @@ export interface GithubProject {
export async function getProjectReadme(
owner: string,
repo: string,
branch: string = 'main',
branch: string,
readme: string,
github_pat?: string
) {
const octokit = new Octokit({ auth: github_pat });
@@ -21,7 +22,7 @@ export async function getProjectReadme(
const response = await octokit.rest.repos.getContent({
owner,
repo,
path: 'README.md',
path: readme,
ref: branch,
});
const data = response.data as { content?: string };
@@ -124,6 +125,7 @@ export async function getProject(project: GithubProject, github_pat?: string) {
project.owner,
project.repo,
project.branch,
project.readme,
github_pat
);
@@ -183,43 +185,8 @@ export async function getProjectDataPackage(
}
const decodedContent = Buffer.from(fileContent, 'base64').toString();
const datapackage = JSON.parse(decodedContent);
return { ...datapackage, repo };
return {...datapackage, repo };
} catch (error) {
return null;
}
}
export async function getAllProjectsFromOrg(
org: string,
branch?: string,
github_pat?: string
) {
const octokit = new Octokit({ auth: github_pat });
const repos = await octokit.rest.repos.listForOrg({
org,
type: 'public',
per_page: 100,
});
let failedProjects = [];
const projects = await Promise.all(
repos.data.map(async (_repo) => {
const project = await getProjectDataPackage(
org,
_repo.name,
branch ? branch : 'main',
github_pat
);
if (!project) {
failedProjects.push(_repo.name);
return null;
}
return { datapackage: project, repo: _repo };
})
);
return {
results: projects.filter((item) => item !== null),
failed: failedProjects,
};
}

View File

@@ -5,11 +5,10 @@ import {
export interface Project {
owner: { name: string; logo?: string; title?: string }; // Info about the owner of the data repo
repo: { name: string; full_name: string; url: string }; // Info about the the data repo
repo: { name: string; full_name: string }; // Info about the the data repo
files: TabularDataResource[];
name: string;
title?: string;
description?: string;
author?: string;
cityCode?: string;
countryCode?: string;

View File

@@ -1,3 +0,0 @@
[
"os-data"
]

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,7 @@
"test": "vitest"
},
"dependencies": {
"@flowershow/core": "^0.4.13",
"@githubocto/flat-ui": "^0.14.1",
"@heroicons/react": "^2.0.18",
"@octokit/plugin-throttling": "^5.2.2",
"@types/flexsearch": "^0.7.3",
"@types/node": "18.16.0",
@@ -23,7 +21,7 @@
"eslint": "8.39.0",
"eslint-config-next": "13.3.1",
"flexsearch": "0.7.21",
"next": "13.3.0",
"next": "13.3.1",
"next-seo": "^6.0.0",
"octokit": "^2.0.14",
"papaparse": "^5.4.1",

View File

@@ -0,0 +1,126 @@
import { NextSeo } from 'next-seo';
import { promises as fs } from 'fs';
import path from 'path';
import getConfig from 'next/config';
import { getProject, GithubProject } from '../../../lib/octokit';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Breadcrumbs from '../../../components/_shared/Breadcrumbs';
export default function ProjectPage({ project }) {
const repoId = `@${project.repo_config.owner}/${project.repo_config.repo}`
return (
<>
<NextSeo title={`${repoId}${project.base_path !== '/' ? '/' + project.base_path : ''} - GitHub Datasets`} />
<main className="prose mx-auto my-8">
<Breadcrumbs links={[{ title: repoId, href: "" }]} />
<h1 className="mb-0 mt-16">{project.repo_config.name || repoId}</h1>
<p className='mb-8'><span className='font-semibold'>Repository:</span> <a target="_blank" href={project.html_url}>{project.html_url}</a></p>
<h2 className="mb-0 mt-10">Files</h2>
<div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Name
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Size
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{project.files?.map((file) => (
<tr key={file.download_url}>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<a href={file.download_url}>{file.name}</a>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{file.size} Bytes
</td>
</tr>
))}
</tbody>
</table>
</div>
{project.readmeContent && <>
<hr />
<h2 className='uppercase font-black'>Readme</h2>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{project.readmeContent}
</ReactMarkdown>
</>}
</main>
</>
);
}
// Generates `/posts/1` and `/posts/2`
export async function getStaticPaths() {
const jsonDirectory = path.join(
process.cwd(),
'datasets.json'
);
const repos = await fs.readFile(jsonDirectory, 'utf8');
return {
paths: JSON.parse(repos).map((repo) => {
const projectPath =
repo.readme && repo.readme.split('/').length > 1
? repo.readme.split('/').slice(0, -1)
: null;
let path = [repo.name];
if (projectPath) {
projectPath.forEach((element) => {
path.push(element);
});
}
return {
params: { org: repo.owner, path },
};
}),
fallback: false, // can also be true or 'blocking'
};
}
export async function getStaticProps({ params }) {
const jsonDirectory = path.join(
process.cwd(),
'datasets.json'
);
const reposFile = await fs.readFile(jsonDirectory, 'utf8');
const repos: GithubProject[] = JSON.parse(reposFile);
const repo = repos.find((_repo) => {
const projectPath =
_repo.readme && _repo.readme.split('/').length > 1
? _repo.readme.split('/').slice(0, -1)
: null;
let path = [_repo.name];
if (projectPath) {
projectPath.forEach((element) => {
path.push(element);
});
}
return (
_repo.owner == params.org &&
JSON.stringify(path) === JSON.stringify(params.path)
);
});
const github_pat = getConfig().serverRuntimeConfig.github_pat;
const project = await getProject(repo, github_pat);
return {
props: {
project: { ...project, repo_config: repo },
},
};
}

View File

@@ -1,275 +0,0 @@
import { NextSeo } from 'next-seo';
import getConfig from 'next/config';
import {
getAllProjectsFromOrg,
getProjectDataPackage,
getProjectMetadata,
getProjectReadme,
} from '../../../lib/project';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { loadDataPackage } from '../../../lib/loader';
import Layout from '../../../components/_shared/Layout';
import Link from 'next/link';
import { Project } from '../../../lib/project.interface';
import ExternalLinkIcon from '../../../components/icons/ExternalLinkIcon';
import { FlatUiTable } from '@/components/FlatUiTable';
export default function ProjectPage({
project,
readme,
}: {
project: Project;
readme: string;
}) {
// Get description from datapackage or calculate
// excerpt from README by getting all the content
// up to the first dot.
const description =
project.description || (readme && readme.slice(0, readme.indexOf('.') + 1));
return (
<Layout>
<NextSeo title={`${project.title} - OpenSpending`} />
<main className="prose mx-auto my-8">
<h1 className="mb-1 mt-16">{project.title || project.name}</h1>
<Link target="_blank" href={project.repo.url}>
@{project.repo.full_name}
</Link>
{description && (
<div className="inline-block min-w-full py-2 align-middle mt-5">
{description}
</div>
)}
<div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Name
</th>
{project.datapackage.countryCode && (
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Country
</th>
)}
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Metadata
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{project.name}
</td>
{project.datapackage.countryCode && (
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{project.datapackage.countryCode}
</td>
)}
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<Link
// TODO: this link may be incorrect for some datasets
href={`https://github.com/${project.owner.name}/${project.repo.name}/blob/main/datapackage.json`}
target="_blank"
className="flex items-center hover:text-gray-700"
>
datapackage.json <ExternalLinkIcon className="ml-1" />
</Link>
</td>
</tr>
</tbody>
</table>
</div>
<h3 className="mb-1 mt-10">Data files</h3>
<p>
This dataset contains {project.files.length} file
{project.files.length == 1 ? '' : 's'}
</p>
<div className="inline-block min-w-full py-2 align-middle">
<table className="mt-0 min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Name
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Format
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Size
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{project.files?.map((file) => {
let size: number | string = file.size;
if (!size) {
if (file.bytes) {
if (file.bytes > 1000000) {
size = (file.bytes / 1000000).toFixed(2) + ' MB';
} else {
size = (file.bytes / 1000).toFixed(2) + ' kB';
}
}
}
return (
<tr key={file.name}>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{file.name}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{file.format}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{size}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<Link
target="_blank"
href={
file.path.startsWith('http')
? file.path
: `https://raw.githubusercontent.com/${project.owner.name}/${project.repo.name}/main/${file.path}`
}
>
Download
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-col gap-y-16 ">
{project.files?.map((file) => {
let size: number | string = file.size;
if (!size) {
if (file.bytes) {
if (file.bytes > 1000000) {
size = (file.bytes / 1000000).toFixed(2) + ' MB';
} else {
size = (file.bytes / 1000).toFixed(2) + ' kB';
}
}
}
return (
<div key={file.name}>
{file.path && (
<>
<h4>
{file.name}
{file.format ? `.${file.format}` : ''}
</h4>
{file.bytes >= 5132288 && (
<span>Previewing 5MB out of {size}</span>
)}
<FlatUiTable
url={
file.path.startsWith('http')
? file.path
: `https://raw.githubusercontent.com/${project.owner.name}/${project.repo.name}/main/${file.path}`
}
/>
</>
)}
</div>
);
})}
</div>
{readme && (
<>
<hr />
<h2 className="uppercase font-black">Readme</h2>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readme}</ReactMarkdown>
</>
)}
</main>
</Layout>
);
}
// Generates `/posts/1` and `/posts/2`
export async function getStaticPaths() {
const github_pat = getConfig().serverRuntimeConfig.github_pat;
const allProjects = await getAllProjectsFromOrg(
'os-data',
'main',
github_pat
);
const paths = allProjects.results.map((project) => ({
params: {
// TODO: dynamize the org
org: 'os-data',
project: project.repo.name,
},
}));
return {
paths,
fallback: false, // can also be true or 'blocking'
};
}
export async function getStaticProps({ params }) {
const { org: orgName, project: projectName } = params;
const github_pat = getConfig().serverRuntimeConfig.github_pat;
const datapackage = await getProjectDataPackage(
orgName,
projectName,
'main',
github_pat
);
const repo = await getProjectMetadata(orgName, projectName, github_pat);
const project = loadDataPackage(datapackage, repo);
// TODO: should this be moved to the loader?
const readme = await getProjectReadme(
orgName,
projectName,
'main',
github_pat
);
return {
props: {
project,
readme,
},
};
}

View File

@@ -1,53 +1,13 @@
import { AppProps } from 'next/app';
import './styles.css';
import { NextSeo } from 'next-seo';
import { useEffect } from 'react';
import { pageview } from '@flowershow/core';
import Script from 'next/script';
import Head from 'next/head';
import { useRouter } from 'next/router';
import './styles.css';
function CustomApp({ Component, pageProps }: AppProps) {
const router = useRouter();
const GA_TOKEN = 'G-GXZF7NRXX6';
useEffect(() => {
const handleRouteChange = (url) => {
pageview(url);
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events]);
return (
<>
<Head>
<link rel="shortcut icon" href="/squared_logo.png" />
<title>GitHub Datasets</title>
</Head>
<NextSeo title="OpenSpending" />
<Script
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${GA_TOKEN}`}
/>
<Script
id="gtag-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', ${GA_TOKEN}, {
page_path: window.location.pathname,
});
`,
}}
/>
<main className="app">
<Component {...pageProps} />
</main>

View File

@@ -1,58 +1,65 @@
import { getAllProjectsFromOrg } from '../lib/project';
import { promises as fs } from 'fs';
import path from 'path';
import {
GithubProject,
getProjectDataPackage,
getProjectMetadata,
} from '../lib/octokit';
import getConfig from 'next/config';
import ExternalLinkIcon from '../components/icons/ExternalLinkIcon';
import TimeAgo from 'react-timeago';
import Link from 'next/link';
import { Hero } from '../components/Hero';
import { Header } from '../components/Header';
import { Container } from '../components/Container';
import { FiscalDataPackage } from '../lib/datapackage.interface';
import { loadDataPackage } from '../lib/loader';
import DatasetsSearch from '../components/DatasetsSearch';
import Layout from '../components/_shared/Layout';
export async function getStaticProps() {
// TODO: support other orgs
// const orgsListPath = path.join(process.cwd(), '/orgs.json');
// const orgs = await fs.readFile(orgsListPath, 'utf8');
const jsonDirectory = path.join(process.cwd(), '/datasets.json');
const repos = await fs.readFile(jsonDirectory, 'utf8');
const github_pat = getConfig().serverRuntimeConfig.github_pat;
const datapackages = await Promise.all(
JSON.parse(repos).map(async (_repo: GithubProject) => {
const datapackage = await getProjectDataPackage(
_repo.owner,
_repo.name,
'main',
github_pat
);
const repo = await getProjectMetadata(
_repo.owner,
_repo.name,
github_pat
);
const allProjects = await getAllProjectsFromOrg(
'os-data',
'main',
github_pat
return {
datapackage,
repo,
};
})
);
const projects = allProjects.results.map(
const projects = datapackages.map(
(item: { datapackage: FiscalDataPackage & { repo: string }; repo: any }) =>
loadDataPackage(item.datapackage, item.repo)
);
const availableCountries = projects
.map((item) => item.countryCode)
.filter((v) => v) // Filters false values
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
// TODO: title should be the full name
.map((code) => ({ code, title: code }));
return {
props: {
projects: JSON.stringify(projects),
availableCountries,
},
};
}
export function Home({ projects, availableCountries }) {
export function Datasets({ projects }) {
projects = JSON.parse(projects);
return (
<Layout>
<Hero
countriesCount={availableCountries.length}
datasetsCount={projects.length}
filesCount={projects.reduce(
(partialSum, a) => partialSum + a.files.length,
0
)}
/>
<div className="bg-white min-h-screen">
<Header />
<Hero />
<section className="py-20 sm:py-32">
<Container>
<div className="mx-auto max-w-2xl lg:mx-0">
@@ -67,15 +74,12 @@ export function Home({ projects, availableCountries }) {
</p>
</div>
<div className="mt-10">
<DatasetsSearch
datasets={projects}
availableCountries={availableCountries}
/>
<DatasetsSearch datasets={projects} />
</div>
</Container>
</section>
</Layout>
</div>
);
}
export default Home;
export default Datasets;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -13,10 +13,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -20,9 +20,7 @@ const config = {
// { name: "Components", href: "/docs/components" },
{ name: "Blog", href: "/blog" },
{ name: "Showcases", href: "/#showcases" },
{ name: "Howtos", href: "/howto" },
{ name: "Examples", href: "https://github.com/datopian/portaljs/tree/main/examples", target: "_blank" },
{ name: "Components", href: "https://storybook.portaljs.org", target: "_blank" },
// { name: "DL Demo", href: "/data-literate/demo" },
// { name: "Excel Viewer", href: "/excel-viewer" },
],

View File

@@ -1,77 +0,0 @@
# How to add Google Analytics?
>[!todo] Prerequisites
>- [Google Analytics account](https://support.google.com/analytics/answer/9304153?hl=en)
>- [Google tag ID](https://support.google.com/analytics/answer/12270356?hl=en#:~:text=A%20Measurement%20ID%20is%20an,same%20as%20your%20destination%20ID.)
The following piece of code adds pageview tracking functionality to the custom Next.js App in `/pages/_app.tsx`, but you can use it only in specific pages if you want.
```tsx
// /pages/_app.tsx
import { useRouter } from "next/router";
import { useEffect } from "react";
import Script from "next/script";
// Step 0:
// Add your Google tag ID to your local .env.local file, e.g. under GA_TRACKING_ID
// Step 1: create the following pageview function
const pageview = ({
url,
analyticsID,
}: {
url: string;
analyticsID: string;
}) => {
if (typeof window.gtag !== undefined) {
window.gtag("config", analyticsID, {
page_path: url,
});
}
};
export default function MyApp({ Component, pageProps }) {
const router = useRouter();
// ... your code
// Step 3: Add the following useEffect to trigger pageview on each route change
useEffect(() => {
const handleRouteChange = (url) => {
pageview(url, GA_TRACKING_ID);
};
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, [router.events]);
return (
{/* Step 4: Install Google Analytics tag */}
<>
<Script
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
/>
<Script
id="gtag-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_TRACKING_ID}', {
page_path: window.location.pathname,
});
`,
}}
/>
<Component {...pageProps} />
</>
);
};
```
>[!info]
> For more info on measuring pageviews with Google Analytics see [Google Analytics documentation](https://developers.google.com/analytics/devguides/collection/gtagjs/pages).

View File

@@ -1,75 +0,0 @@
# How to add a simple blog?
## Setup
The following example uses components imported from the [`@flowershow/core` package](https://github.com/datopian/flowershow). If you want to follow along install it too:
```sh
npm i @flowershow/core
```
## Create home page for your blogs
Add the following code to the Next.js page that is going to be your blog home page, e.g. to `/pages/blog/index.tsx`:
```tsx
import { BlogsList, SimpleLayout } from "@flowershow/core";
// pass a list of blogs, home page title and home page description, e.g. from `getStaticProps`
export default function BlogIndex({ blogs, title, description }) {
return (
<SimpleLayout title={title} description={description}>
<BlogsList blogs={blogs} />
</SimpleLayout>
);
}
```
`BlogsList` component has the following API:
```ts
interface BlogsListProps {
blogs: Blog;
}
interface Blog {
title: string;
date: string;
urlPath: string;
description?: string;
authors?: Array<string>;
tags?: Array<string>;
}
```
## Create blog post pages
Add the following code to your blog pages, e.g. to `/pages/blog/[...slug].tsx`:
```tsx
import { BlogLayout } from "@flowershow/core";
export default BlogPost({ content, title, date, authors }) {
return (
<BlogLayout title={title} date={date} authors={authors}
{content}
</BlogLayout>
)
}
```
`BlogLayout` component has the following API:
```ts
interface BlogLayoutProps {
title?: string;
date?: string;
authors?: Array<Author>;
}
interface Author {
name: string;
avatar: string; // avatar image path
urlPath?: string; // author page
}
```

View File

@@ -1,106 +0,0 @@
# How to add user comments?
![[comments-example.png]]
## Setup
Page comments can be setup with any one of the following supported providers:
1. [giscus](https://giscus.app/)
2. [utterances](https://utteranc.es/)
3. [disqus](https://disqus.com/)
Each provider has it's own configuration options that you should add to your `.env` file.
>[!Info]
>If you are hosting your website on hosting providers like Netlify, Vercel or Cloudflare, you will also need to add the environment variables there.
### Giscus
[Giscus](https://giscus.app/) uses your github repo's discussions feature to store your comments and display them on your site.
#### Prerequisites
1. You have a public [github repository](https://docs.github.com/en/get-started/quickstart/create-a-repo) with the [discussions](https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/enabling-or-disabling-github-discussions-for-a-repository) feature activated which will be used to store your comments.
2. The [giscus app](https://github.com/apps/giscus) installed in your repo by following their configuration setup at [https://giscus.app/](https://giscus.app/)
Once the above steps are completed, head over to [https://giscus.app](https://giscus.app/) and follow the steps there by filling out the fields to get your config values.
>[!important]
> Make sure to choose `pathname` under page discussions mapping.
After filling out the fields, you will be provided with a script tag that contains your config values. Add them to your `.env` file, like so:
```
NEXT_PUBLIC_GISCUS_REPO=
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
NEXT_PUBLIC_GISCUS_CATEGORY=
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
```
When a user visits a web page, Giscus searches for a discussion whose title contains the page's pathname URL component. If there are no matches (i.e., no comments have been made about that page), then a new discussion is created for that page.
By using the pathname for mapping with a page, Giscus ensures that each discussion is unique to a particular page and that comments made on that page are kept together.
More info on how this connection is achieved can be found on their '**how it works**' section in their repo - [https://github.com/giscus/giscus#how-it-works](https://github.com/giscus/giscus#how-it-works) and also in the '**Page Discussions Mapping**' section at [https://giscus.app/](https://giscus.app/)
### Utterances
[Utterances](https://utteranc.es/) uses your github repo's issues to store comments for your pages and display them on your site. To setup comments with utterances, you would need to do the following:
#### Prerequisites
1. You have a public [github repository](https://docs.github.com/en/get-started/quickstart/create-a-repo)
2. The utterances app installed in your repo by following their configuration setup at [https://utteranc.es/](https://utteranc.es/)
Once installed fill in the required fields and you will see a script tag with your repo value which you can add to your `.env` file, like so:
```
NEXT_PUBLIC_UTTERANCES_REPO=
```
### Disqus
[Disqus](https://disqus.com/) is a feature rich provider which can be used to add comments to your site. You can configure flowershow to use disqus by creating an account on their site and following their process. You will be asked to enter a shortname for your site. Once completed, we can use this shortname in our comments configuration as below.
#### Prerequisites
1. Create an account on [Disqus](https://disqus.com/) and follow their setup process.
You will be asked to enter a shortname for your site. Add it to your `.env` file as well, like so:
```
NEXT_PUBLIC_DISQUS_SHORTNAME=
```
## Add comments to your page layout
You can use `@flowershow/core` to import the comments section component. Install it with:
```sh
npm i @flowershow/core
```
Then, add the following to your custom layout (or directly to your pages):
```tsx
import Navbar from './navbar';
import Footer from './footer';
export default function Layout({ children }) {
return (
<>
<Navbar />
<main>{children}</main>
<div>
<Comments commentsConfig={commentsConfig} slug={urlPath} />
</div>
<Footer />
</>
);
}
```
## Add user comments
Learn how to add support for user comments in [[comments|this guide]].

View File

@@ -1,334 +0,0 @@
# How to create data-rich documents with charts and tables?
> [!info] Prerequisites
> If you want to enrich your markdown content with charts and tables, you first need to add support for rendering markdown files in your PortalJS app. Follow [[markdown|this guide]] to learn how to do this.
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 an overview of available components.
You can install the library with:
```sh
npm i @portaljs/components
```
Now, in order to use these components in your markdown files, we need to pass them to our `MDXRemote` component (see [[markdown#Import, parse and render your markdown files|this section in our markdown guide]]):
```tsx
// e.g. /blog/[[...slug]].tsx
import fs from "fs";
import { LineChart, Table, Catalog, Vega, VegaLite } from "@portaljs/components";
import { MdxRemote } from "next-mdx-remote";
import clientPromise from "@/lib/mddb.mjs";
import parse from "@/lib/markdown";
const components = {
Table: Table,
Vega: Vega,
VegaLite: VegaLite,
LineChart: LineChart,
};
export default function Page({ source }) {
source = JSON.parse(source);
return (
<>
<MdxRemote source={source} components={components} />
</>
);
}
// Import metadata of a file matching the static path and return its parsed source and frontmatter object
export const getStaticProps = async ({ params }) => {
const urlPath = params?.slug ? (params.slug as string[]).join("/") : "/";
const mddb = await clientPromise;
const dbFile = await mddb.getFileByUrl(urlPath);
const filePath = dbFile!.file_path;
// const frontMatter = dbFile!.metadata ?? {};
const source = fs.readFileSync(filePath, { encoding: "utf-8" });
const { mdxSource } = await parse(source, "mdx", {});
return {
props: {
source: JSON.stringify(mdxSource),
// frontMatter
},
};
};
```
You can now use these components in your markdown, like so:
```md
### My Data Rich Note
Some markdown here.
<Table csv={`
Year,Temp Anomaly
1850,-0.418
2020,0.923
`} />
Some markdown here.
```
## PortalJS components
### Table
An easy-to-use table component with built-in pagination, search, and sorting. It can be used with raw data, raw CSV strings or with URLs to hosted CSV files.
![[table-example.png]]
Example usage:
```js
<Table
cols={[
{
key: 'id',
name: 'ID'
},
{
key: 'firstName',
name: 'First name'
},
{
key: 'lastName',
name: 'Last name'
},
{
key: 'age',
name: 'Age'
}
]}
data={[
{
age: 35,
firstName: 'Jon',
id: 1,
lastName: 'Snow'
},
{
age: 42,
firstName: 'Cersei',
id: 2,
lastName: 'Lannister'
}
]}
/>
```
>[!info]
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-table--docs)
### Linechart
A simple component that allows the creation of an opinionated line chart without the need to go deep into charting standards. It can be used with raw data, raw CSV strings or with URLs to hosted CSV files.
![[linechart-example.png]]
Example usage:
```js
<LineChart
data={[
[
'1850',
-0.41765878
],
[
'1851',
-0.2333498
],
[
'1852',
-0.22939907
],
[
'1853',
-0.27035445
],
[
'1854',
-0.29163003
]
]}
/>
```
>[!info]
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-linechart--docs)
### Vega chart
A wrapper around the [Vega specification](https://vega.github.io/vega/) that allows you to build pretty much any kind of chart imaginable.
Example usage:
![](https://hackmd.io/_uploads/ryN5mYmSh.png)
```js
<Vega
data={{
table: [
{
x: 1850,
y: -0.418
},
{
x: 2020,
y: 0.923
}
]
}}
spec={{
$schema: 'https://vega.github.io/schema/vega-lite/v4.json',
data: {
name: 'table'
},
encoding: {
x: {
field: 'x',
type: 'ordinal'
},
y: {
field: 'y',
type: 'quantitative'
}
},
mark: 'bar'
}}
/>
```
> [!info]
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-vega--docs)
### VegaLite chart
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.
Example usage:
```js
<VegaLite
data={{
table: [
{
x: 1850,
y: -0.418
},
{
x: 2020,
y: 0.923
}
]
}}
spec={{
$schema: 'https://vega.github.io/schema/vega-lite/v4.json',
data: {
name: 'table'
},
encoding: {
x: {
field: 'x',
type: 'ordinal'
},
y: {
field: 'y',
type: 'quantitative'
}
},
mark: 'bar'
}}
/>
```
>[!info]
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-vegalite--docs)
### Catalog
A searchable catalog that will index a list of datasets and allow for contextual searching + filters.
![[catalog-example.png]]
Example usage:
```js
<Catalog
datasets={[
{
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
metadata: {
'details-of-task': 'Detect and categorise abusive language in social media data',
language: 'Albanian',
'level-of-annotation': [
'Posts'
],
'link-to-data': 'https://doi.org/10.6084/m9.figshare.19333298.v1',
'link-to-publication': 'https://arxiv.org/abs/2107.13592',
medium: [
'Text'
],
'percentage-abusive': 13.2,
platform: [
'Instagram',
'Youtube'
],
reference: 'Nurce, E., Keci, J., Derczynski, L., 2021. Detecting Abusive Albanian. arXiv:2107.13592',
'size-of-dataset': 11874,
'task-description': 'Hierarchical (offensive/not; untargeted/targeted; person/group/other)',
title: 'Detecting Abusive Albanian'
},
url_path: 'dataset-4'
},
]}
/>
```
You can also add facets that are going to act as filters for your metadata.
![[catalog-facets-example.png]]
```js
<Catalog
datasets={[
{
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
metadata: {
'details-of-task': 'Detect and categorise abusive language in social media data',
language: 'Albanian',
'level-of-annotation': [
'Posts'
],
'link-to-data': 'https://doi.org/10.6084/m9.figshare.19333298.v1',
'link-to-publication': 'https://arxiv.org/abs/2107.13592',
medium: [
'Text'
],
'percentage-abusive': 13.2,
platform: [
'Instagram',
'Youtube'
],
reference: 'Nurce, E., Keci, J., Derczynski, L., 2021. Detecting Abusive Albanian. arXiv:2107.13592',
'size-of-dataset': 11874,
'task-description': 'Hierarchical (offensive/not; untargeted/targeted; person/group/other)',
title: 'Detecting Abusive Albanian'
},
url_path: 'dataset-4'
},
]}
facets={['platform', 'language']}
/>
```
>[!info]
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-catalog--docs)

View File

@@ -1,12 +0,0 @@
# Guides and tutorials
- [[howto/analytics|How to add web analytics?]]
- [[howto/seo|How to customize page metadata for SEO?]]
- [[howto/sitemap|How to build a sitemap?]]
- [[howto/markdown|How to add markdown-based content pages?]]
- [[howto/blog|How to add a simple blog?]]
- [[howto/drd|How to create data-rich documents with charts and tables?]]
- [[howto/comments|How to add user comments?]]
If you have questions about anything related to PortalJS, you're always welcome to ask our community on [GitHub Discussions](https://github.com/datopian/portaljs/discussions) or on [our chat channel on Discord](https://discord.gg/EeyfGrGu4U).

View File

@@ -1,169 +0,0 @@
# How to add markdown-based content pages?
## Add content layer to your app
Create a folder where you'll keep your markdown files and add some markdown files to it.
```sh
cd my-portaljs-project
mkdir content
# touch content/index.md ...
```
Install [MarkdownDB](https://github.com/datopian/markdowndb) package:
```
npm i @flowershow/markdowndb
```
And add the following to your `package.json`:
```json
{
"scripts": {
"mddb": "mddb <path-to-your-content-folder>",
"prebuild": "npm run mddb"
},
}
```
You can give it a go by running `npm run mddb`. You should see a `markdown.db` file created in the root of your project. You can inspect it with any SQLite viewer or in the command line. In the `files` table you should see all your markdown files from your content folder.
Now, once the data is in the database, you can add the following script to your project (e.g. in `/lib` folder). It will allow you to establish a single connection to the database and use it across your app.
```ts
// lib/mddb.ts
import { MarkdownDB } from "@flowershow/markdowndb";
// path to the markdown.db file created by the mddb script
const dbPath = "markdown.db";
const client = new MarkdownDB({
client: "sqlite3",
connection: {
filename: dbPath,
},
});
const clientPromise = client.init();
export default clientPromise;
```
Now you can import it across your project to query the database, e.g.:
```ts
import clientPromise from "@/lib/mddb";
const mddb = await clientPromise;
const blogs = await mddb.getFiles({
folder: "blog",
extensions: ["md", "mdx"],
});
```
## Write a markdown parser
Install [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) package, which we'll first use to parse markdown files and then to render them in Next.js app.
```sh
npm i next-mdx-remote
```
Create the following basic parser for your markdown files, e.g. in `/lib/markdown.ts`:
```ts
import matter from "gray-matter";
import remarkGfm from "remark-gfm";
import { serialize } from "next-mdx-remote/serialize";
const parse = async function (source) {
const { content } = matter(source);
const mdxSource = await serialize(
{ value: content },
{
mdxOptions: {
remarkPlugins: [
remarkGfm,
// ... your remark plugins
],
rehypePlugins: [
// ... your plugins
],
format,
}
}
);
return {
mdxSource
};
};
export default parse;
```
## Import, parse and render your markdown files
Create a page in the `/pages` folder that will render your markdown content, e.g. `pages/blog/[[...slug]].tsx`:
```tsx
import fs from "fs";
import { MdxRemote } from "next-mdx-remote";
import clientPromise from "@/lib/mddb.mjs";
import parse from "@/lib/markdown";
export default function Page({ source }) {
source = JSON.parse(source);
return (
<>
<MdxRemote source={source} />
</>
);
}
// Import metadata of a file matching the static path and return its parsed source and frontmatter object
export const getStaticProps = async ({ params }) => {
const urlPath = params?.slug ? (params.slug as string[]).join("/") : "/";
const mddb = await clientPromise;
const dbFile = await mddb.getFileByUrl(urlPath);
const filePath = dbFile!.file_path;
// const frontMatter = dbFile!.metadata ?? {};
const source = fs.readFileSync(filePath, { encoding: "utf-8" });
const { mdxSource } = await parse(source, "mdx", {});
return {
props: {
source: JSON.stringify(mdxSource),
// frontMatter
},
};
};
// Import metadata of your markdown files from MarkdownDB and return a list of static paths
export const getStaticPaths = async () => {
const mddb = await clientPromise;
const allDocuments = await mddb.getFiles({ extensions: ["md", "mdx"] });
const paths = allDocuments
.map((page) => {
const url = decodeURI(page.url_path);
const parts = url.split("/");
return { params: { slug: parts } };
});
return {
paths,
fallback: false,
};
};
```

View File

@@ -1,79 +0,0 @@
# How to customize page metadata for SEO?
>[!info]
>See [`next-seo` documentation](https://github.com/garmeeh/next-seo) to learn more.
## Setup
Install `next-seo` package:
```sh
npm i next-seo
```
## Default SEO configuration
Create `next-seo.config` file (e.g. in the root of your project) and add default meta tags values you want to be set up for your pages. For example:
```ts
export default {
openGraph: {
type: 'website',
locale: 'en_IE',
url: 'https://www.my.portaljs.app/',
siteName: 'MyPortalJSApp',
},
twitter: {
handle: '@handle',
site: '@site',
cardType: 'summary_large_image',
},
};
```
Then, in your custom Next.js App, add the following:
```tsx
// /pages/_app.tsx
import { DefaultSeo } from "next-seo";
// import your default seo configuration
import SEO from '../next-seo.config';
export default function MyApp({ Component, pageProps }) {
// ... your code
return (
<>
<DefaultSeo {...SEO} />
<Component {...pageProps} />
</>
);
};
```
>[!info]
>To learn more on default SEO configuration with `next-seo`, see [this docs section](https://github.com/garmeeh/next-seo#default-seo-configuration).
## Per-page SEO configuration
To add page specific meta tag values:
```tsx
import { NextSeo } from 'next-seo';
export default function Page() (
// ...
<>
<NextSeo
title="My Portal JS page"
description="A short description."
/>
<p>My Portal JS page</p>
</>
);
```
>[!info]
> To learn more on per-page SEO configuration with `next-seo`, see [this docs section](https://github.com/garmeeh/next-seo#add-seo-to-page)

View File

@@ -1,94 +0,0 @@
# How to build a sitemap?
## Setup
Install the following packages:
```sh
npm i globby
npm i -D prettier
```
## Add sitemap script
>[!info]
>The following example assumes files list for dynamic Next.js routes is imported from the database created with [MarkdownDB](https://github.com/datopian/markdowndb) package. You need to adjust it to your specific use case.
Add the following script to e.g. `/scripts/sitemap.mjs` and adjust it to work with your app:
```js
// /scripts/sitemap.mjs
import { writeFileSync } from "fs";
import { globby } from "globby";
import prettier from "prettier";
// Change this to import data from your content layer (if needed)
import clientPromise from "../lib/mddb.mjs";
export default async function sitemap() {
const prettierConfig = await prettier.resolveConfig("path-to-your-prettier-config");
// Change this to create a list of page paths
const mddb = await clientPromise;
const allFiles = await mddb.getFiles({ extensions: ["mdx", "md"] });
const contentPages = allFiles
.filter((x) => !x.metadata?.isDraft)
.map((x) => `/${x.url_path}`);
const pages = await globby([
"pages/*.(js|tsx)",
"!pages/_*.(js|tsx)",
"!pages/api",
"!pages/404.(js|tsx)",
"!pages/**/\\[\\[*\\]\\].(js|tsx)", // pages/[[...slug]].tsx
]);
const allPages = pages.concat(contentPages);
// end
// Replace this with your site domain
const siteDomain = "your-site-domain";
const sitemap = `
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allPages
.map((page) => {
const path = page
.replace("pages/", "/")
.replace("public/", "/")
.replace(/\.js|.tsx|.mdx|.md[^\/.]+$/, "")
.replace("/feed.xml", "");
const route = path === "/index" ? "" : path;
return `
<url>
<loc>${siteDomain}${route}</loc>
</url>
`;
})
.join("")}
</urlset>
`;
const formatted = prettier.format(sitemap, {
...prettierConfig,
parser: "html",
});
if (siteUrl) {
writeFileSync("public/sitemap.xml", formatted);
console.log("Sitemap generated...");
}
}
await sitemap();
process.exit();
```
## Execute script before each build
Add this to the scripts section in your `package.json` to regenerate the sitemap before each build.
```json
"prebuild": "node ./scripts/sitemap.mjs",
```