Compare commits
7 Commits
table-prev
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ba5b11f3 | ||
|
|
bedc9a8d33 | ||
|
|
418b4bfe52 | ||
|
|
ee6efc7431 | ||
|
|
a62addbfbb | ||
|
|
adb6d1bb0e | ||
|
|
2115a3fdb3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ node_modules
|
|||||||
*.launch
|
*.launch
|
||||||
.settings/
|
.settings/
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
.obsidian
|
||||||
|
|
||||||
# IDE - VSCode
|
# IDE - VSCode
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -1,45 +1,138 @@
|
|||||||
import { Octokit } from 'octokit';
|
import { expect, test } from 'vitest';
|
||||||
import { assert, expect, test } from 'vitest'
|
import { getAllProjectsFromOrg, getProjectDataPackage } from '../lib/project';
|
||||||
import { getProjectDataPackage } from '../lib/octokit';
|
import { loadDataPackage } from '../lib/loader';
|
||||||
|
import { getProjectMetadata } from '../lib/project';
|
||||||
|
import { getCsv, parseCsv } from '../components/Table';
|
||||||
|
|
||||||
export async function getAllDataPackagesFromOrg(
|
test(
|
||||||
org: string,
|
'Test OS-Data',
|
||||||
branch?: string,
|
async () => {
|
||||||
github_pat?: string
|
const repos = await getAllProjectsFromOrg(
|
||||||
) {
|
'os-data',
|
||||||
const octokit = new Octokit({ auth: github_pat });
|
'main',
|
||||||
const repos = await octokit.rest.repos.listForOrg({ org, type: 'public', per_page: 100 });
|
process.env.VITE_GITHUB_PAT
|
||||||
let failedDataPackages = [];
|
);
|
||||||
const datapackages = await Promise.all(
|
if (repos.failed.length > 0) console.log(repos.failed);
|
||||||
repos.data.map(async (_repo) => {
|
expect(repos.failed.length).toBe(0);
|
||||||
const datapackage = await getProjectDataPackage(
|
},
|
||||||
org,
|
{ timeout: 100000 }
|
||||||
_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 OS-Data', async () => {
|
test(
|
||||||
const repos = await getAllDataPackagesFromOrg('os-data', 'main', process.env.VITE_GITHUB_PAT)
|
'Test Gift-Data',
|
||||||
if (repos.failedDataPackages.length > 0) console.log(repos.failedDataPackages)
|
async () => {
|
||||||
expect(repos.failedDataPackages.length).toBe(0)
|
const repos = await getAllProjectsFromOrg(
|
||||||
}, {timeout: 100000})
|
'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 Gift-Data', async () => {
|
test(
|
||||||
const repos = await getAllDataPackagesFromOrg('gift-data', 'main', process.env.VITE_GITHUB_PAT)
|
'Test getting one dataset from github',
|
||||||
if (repos.failedDataPackages.length > 0) console.log(repos.failedDataPackages)
|
async () => {
|
||||||
expect(repos.failedDataPackages.length).toBe(0)
|
const datapackage = await getProjectDataPackage(
|
||||||
}, {timeout: 100000})
|
'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 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 }
|
||||||
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function DatasetCard({ dataset }: { dataset: Project }) {
|
|||||||
className="overflow-hidden rounded-xl border border-gray-200"
|
className="overflow-hidden rounded-xl border border-gray-200"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href=""
|
href={`/@${dataset.owner.name}/${dataset.repo.name}`}
|
||||||
className="flex items-center gap-x-4 border-b border-gray-900/5 bg-gray-50 p-6"
|
className="flex items-center gap-x-4 border-b border-gray-900/5 bg-gray-50 p-6"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -60,8 +60,8 @@ export default function DatasetCard({ dataset }: { dataset: Project }) {
|
|||||||
<dd className="flex items-start gap-x-2">
|
<dd className="flex items-start gap-x-2">
|
||||||
<div className="font-medium text-gray-900">
|
<div className="font-medium text-gray-900">
|
||||||
<Link
|
<Link
|
||||||
// TODO: where do we get the info needed for this link?
|
// TODO: this link may be incorrect for some datasets
|
||||||
href=""
|
href={`https://github.com/${dataset.owner.name}/${dataset.repo.name}/blob/main/datapackage.json`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex items-center hover:text-gray-700"
|
className="flex items-center hover:text-gray-700"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,9 +2,26 @@ import { useForm } from 'react-hook-form';
|
|||||||
import DatasetsGrid from './DatasetsGrid';
|
import DatasetsGrid from './DatasetsGrid';
|
||||||
import { Project } from '../lib/project.interface';
|
import { Project } from '../lib/project.interface';
|
||||||
import { Index } from 'flexsearch';
|
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' });
|
const index = new Index({ tokenize: 'full' });
|
||||||
|
|
||||||
datasets.forEach((dataset: Project) =>
|
datasets.forEach((dataset: Project) =>
|
||||||
index.add(
|
index.add(
|
||||||
dataset.name,
|
dataset.name,
|
||||||
@@ -21,12 +38,53 @@ export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const allCountries = datasets
|
const filteredDatasets = datasets
|
||||||
.map((item) => item.countryCode)
|
.filter((dataset: Project) =>
|
||||||
.filter((v) => v) // Filters false values
|
watch().searchTerm && watch().searchTerm !== ''
|
||||||
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
|
? index.search(watch().searchTerm).includes(dataset.name)
|
||||||
// TODO: title should be the full name
|
: true
|
||||||
.map((code) => ({ code, title: code }));
|
)
|
||||||
|
.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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -37,7 +95,7 @@ export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
|
|||||||
<input
|
<input
|
||||||
placeholder="Search datasets"
|
placeholder="Search datasets"
|
||||||
aria-label="Search datasets"
|
aria-label="Search datasets"
|
||||||
{...register('searchTerm')}
|
{...register('searchTerm', { onChange: () => setPage(1) })}
|
||||||
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"
|
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 !== '' && (
|
{watch().searchTerm !== '' && (
|
||||||
@@ -55,10 +113,10 @@ export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
|
|||||||
<label className="text-sm text-gray-600 font-medium">Country</label>
|
<label className="text-sm text-gray-600 font-medium">Country</label>
|
||||||
<select
|
<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"
|
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')}
|
{...register('country', { onChange: () => setPage(1) })}
|
||||||
>
|
>
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
{allCountries.map((country) => {
|
{availableCountries.map((country) => {
|
||||||
return (
|
return (
|
||||||
<option key={country.code} value={country.code}>
|
<option key={country.code} value={country.code}>
|
||||||
{country.title}
|
{country.title}
|
||||||
@@ -68,72 +126,76 @@ export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:basis-1/6">
|
<div className="sm:basis-1/6">
|
||||||
<label className="text-sm text-gray-600 font-medium">Min. date</label>
|
<label className="text-sm text-gray-600 font-medium">
|
||||||
|
Fiscal Period Start
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
aria-label="Min. date"
|
aria-label="Min. date"
|
||||||
type="date"
|
type="date"
|
||||||
{...register('minDate')}
|
{...register('minDate', { onChange: () => setPage(1) })}
|
||||||
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"
|
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>
|
</div>
|
||||||
<div className="sm:basis-1/6">
|
<div className="sm:basis-1/6">
|
||||||
<label className="text-sm text-gray-600 font-medium">Max. date</label>
|
<label className="text-sm text-gray-600 font-medium">
|
||||||
|
Fiscal Period End
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
aria-label="Max. date"
|
aria-label="Max. date"
|
||||||
type="date"
|
type="date"
|
||||||
{...register('maxDate')}
|
{...register('maxDate', { onChange: () => setPage(1) })}
|
||||||
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-full mt-10 align-middle">
|
<div className="mt-5 mb-5">
|
||||||
<DatasetsGrid
|
<span className="text-lg font-medium">
|
||||||
datasets={datasets
|
{filteredDatasets.length} datasets found
|
||||||
.filter((dataset: Project) =>
|
</span>
|
||||||
watch().searchTerm && watch().searchTerm !== ''
|
</div>
|
||||||
? index.search(watch().searchTerm).includes(dataset.name)
|
<div className="min-w-full align-middle">
|
||||||
: true
|
<DatasetsGrid datasets={paginatedDatasets} />
|
||||||
)
|
<div className="w-full flex justify-center mt-10">
|
||||||
.filter((dataset) =>
|
<button
|
||||||
watch().country && watch().country !== ''
|
onClick={() => setPage(1)}
|
||||||
? dataset.countryCode === watch().country
|
disabled={page <= 1}
|
||||||
: true
|
className="disabled:text-gray-400"
|
||||||
)
|
>
|
||||||
// TODO: Does that really makes sense?
|
<ChevronDoubleLeftIcon className="w-6 h-6" />
|
||||||
// What if the fiscalPeriod is 2015-2017 and inputs are
|
</button>
|
||||||
// set to 2015-2016. It's going to be filtered out but
|
<button
|
||||||
// it shouldn't.
|
onClick={() => {
|
||||||
.filter((dataset) =>
|
if (page > 1) setPage((prev) => --prev);
|
||||||
watch().minDate && watch().minDate !== ''
|
}}
|
||||||
? dataset.fiscalPeriod?.start >= watch().minDate
|
disabled={page <= 1}
|
||||||
: true
|
className="disabled:text-gray-400"
|
||||||
)
|
>
|
||||||
.filter((dataset) =>
|
<ChevronLeftIcon className="w-6 h-6" />
|
||||||
watch().maxDate && watch().maxDate !== ''
|
</button>
|
||||||
? dataset.fiscalPeriod?.end <= watch().maxDate
|
<span className="mx-5">
|
||||||
: true
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -153,9 +215,9 @@ const CloseIcon = () => {
|
|||||||
id="Vector"
|
id="Vector"
|
||||||
d="M18 18L12 12M12 12L6 6M12 12L18 6M12 12L6 18"
|
d="M18 18L12 12M12 12L6 6M12 12L18 6M12 12L6 18"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
strokeWidth="2"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke-linejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Grid } from '@githubocto/flat-ui';
|
|||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
async function getCsv(url: string) {
|
export async function getCsv(url: string) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Range: 'bytes=0-5132288',
|
Range: 'bytes=0-5132288',
|
||||||
@@ -62,7 +62,7 @@ const Spinning = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Table: React.FC<{ url: string }> = ({ url }) => {
|
export const FlatUiTable: React.FC<{ url: string }> = ({ url }) => {
|
||||||
return (
|
return (
|
||||||
// Provide the client to your App
|
// Provide the client to your App
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -87,7 +87,7 @@ const TableInner: React.FC<{ url: string }> = ({ url }) => {
|
|||||||
</div>;
|
</div>;
|
||||||
if (parsedData)
|
if (parsedData)
|
||||||
return (
|
return (
|
||||||
<div className="h-[500px] overflow-scroll">
|
<div className="h-[500px] w-full">
|
||||||
<Grid data={parsedData.data} />
|
<Grid data={parsedData.data} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1,53 +1,82 @@
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
import { Button } from './Button'
|
import { Container } from './Container';
|
||||||
import { Container } from './Container'
|
import logo from '../public/logo.svg';
|
||||||
import logo from "../public/logo.svg"
|
import Link from 'next/link';
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/router';
|
||||||
import { useRouter } from 'next/router'
|
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const isActive = (navLink) => {
|
const isActive = (navLink) => {
|
||||||
return router.asPath.split("?")[0] == navLink.href;
|
return router.asPath.split('?')[0] == navLink.href;
|
||||||
}
|
};
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{
|
{
|
||||||
title: "Home",
|
title: 'Home',
|
||||||
href: "/#header"
|
href: '/',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Datasets",
|
title: 'Datasets',
|
||||||
href: "/#datasets"
|
href: '/#datasets',
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
title: "Community",
|
// title: "Community",
|
||||||
href: "https://community.openspending.org/"
|
// href: "https://community.openspending.org/"
|
||||||
}
|
// }
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="z-50 pb-5 lg:pt-11 sticky top-0 backdrop-blur" id="header">
|
<header className="relative z-50 pb-11 lg:pt-11">
|
||||||
<Container className="flex flex-wrap items-center justify-center sm:justify-between lg:flex-nowrap">
|
<Container className="flex flex-wrap items-center justify-between lg:flex-nowrap mt-10 lg:mt-0">
|
||||||
<div className="mt-10 lg:mt-0 lg:grow lg:basis-0 flex items-center">
|
<Link href="/" className="lg:mt-0 lg:grow lg:basis-0 flex items-center">
|
||||||
<Image src={logo} alt="OpenSpending" className="h-12 w-auto" />
|
<Image src={logo} alt="OpenSpending" className="h-12 w-auto" />
|
||||||
</div>
|
</Link>
|
||||||
<ul className='list-none flex gap-x-5 text-base font-medium'>
|
<ul className="hidden list-none sm:flex gap-x-5 text-base font-medium">
|
||||||
{navLinks.map((link, i) => (
|
{navLinks.map((link, i) => (
|
||||||
<li key={`nav-link-${i}`}>
|
<li key={`nav-link-${i}`}>
|
||||||
<Link
|
<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}
|
href={link.href}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
>
|
>
|
||||||
{link.title}
|
{link.title}
|
||||||
</Link>
|
</Link>
|
||||||
</li>))}
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="hidden sm:mt-10 sm:flex lg:mt-0 lg:grow lg:basis-0 lg:justify-end">
|
<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>
|
</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>
|
</Container>
|
||||||
</header >
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Button } from './Button'
|
import { Button } from './Button';
|
||||||
import { Container } from './Container'
|
import { Container } from './Container';
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero({ countriesCount, datasetsCount, filesCount }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative pb-20 pt-10 sm:py-40">
|
<div className="relative pb-20 pt-10 sm:py-40" id="hero">
|
||||||
<div className="absolute inset-x-0 -bottom-14 -top-48 overflow-hidden bg-green-50 bg-opacity-50">
|
<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 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" />
|
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-white" />
|
||||||
@@ -15,12 +15,13 @@ export function Hero() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="mt-6 space-y-6 font-display text-2xl tracking-tight text-emerald-900">
|
<div className="mt-6 space-y-6 font-display text-2xl tracking-tight text-emerald-900">
|
||||||
<p>
|
<p>
|
||||||
By understanding how governments spend money in our name can we have a say
|
By understanding how governments spend money in our name can we
|
||||||
in how that money will affect our own lives. The journey starts here.
|
have a say in how that money will affect our own lives. The
|
||||||
|
journey starts here.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
OpenSpending is a free, open and global platform to search, visualise and analyse
|
OpenSpending is a free, open and global platform to search,
|
||||||
fiscal data in the public sphere.
|
visualise and analyse fiscal data in the public sphere.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button href="#datasets" className="mt-10">
|
<Button href="#datasets" className="mt-10">
|
||||||
@@ -28,9 +29,11 @@ export function Hero() {
|
|||||||
</Button>
|
</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">
|
<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">
|
||||||
{[
|
{[
|
||||||
['Countries', '75'],
|
// Added the plus sign because some datasets do not
|
||||||
['Datasets', '2091'],
|
// contain defined countries
|
||||||
['Files', '9230'],
|
['Countries', '+' + countriesCount],
|
||||||
|
['Datasets', datasetsCount],
|
||||||
|
['Files', filesCount],
|
||||||
].map(([name, value]) => (
|
].map(([name, value]) => (
|
||||||
<div key={name}>
|
<div key={name}>
|
||||||
<dt className="font-mono text-sm text-emerald-600">{name}</dt>
|
<dt className="font-mono text-sm text-emerald-600">{name}</dt>
|
||||||
@@ -43,5 +46,5 @@ export function Hero() {
|
|||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
10
examples/openspending/components/_shared/Layout.tsx
Normal file
10
examples/openspending/components/_shared/Layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Header } from '../Header';
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen pb-32">
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -98,6 +98,7 @@ export interface TabularDataResource {
|
|||||||
key?: string;
|
key?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
bytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Field {
|
export interface Field {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ export function loadDataPackage(datapackage: FiscalDataPackage, repo): Project {
|
|||||||
return {
|
return {
|
||||||
name: datapackage.name,
|
name: datapackage.name,
|
||||||
title: datapackage.title,
|
title: datapackage.title,
|
||||||
|
description: datapackage.description || null,
|
||||||
owner: {
|
owner: {
|
||||||
name: repo.owner.login,
|
name: repo.owner.login,
|
||||||
logo: repo.owner.avatar_url,
|
logo: repo.owner.avatar_url,
|
||||||
// TODO: make this title work
|
// TODO: make this title work
|
||||||
title: repo.owner.login,
|
title: repo.owner.login,
|
||||||
},
|
},
|
||||||
repo: { name: repo, full_name: repo.full_name },
|
repo: { name: repo.name, full_name: repo.full_name, url: repo.html_url },
|
||||||
files: datapackage.resources,
|
files: datapackage.resources,
|
||||||
author: datapackage.author ? datapackage.author : null,
|
author: datapackage.author ? datapackage.author : null,
|
||||||
cityCode: datapackage.cityCode ? datapackage.cityCode : null,
|
cityCode: datapackage.cityCode ? datapackage.cityCode : null,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
owner: { name: string; logo?: string; title?: string }; // Info about the owner of the data repo
|
owner: { name: string; logo?: string; title?: string }; // Info about the owner of the data repo
|
||||||
repo: { name: string; full_name: string }; // Info about the the data repo
|
repo: { name: string; full_name: string; url: string }; // Info about the the data repo
|
||||||
files: TabularDataResource[];
|
files: TabularDataResource[];
|
||||||
name: string;
|
name: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
cityCode?: string;
|
cityCode?: string;
|
||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export interface GithubProject {
|
|||||||
export async function getProjectReadme(
|
export async function getProjectReadme(
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
branch: string,
|
branch: string = 'main',
|
||||||
readme: string,
|
|
||||||
github_pat?: string
|
github_pat?: string
|
||||||
) {
|
) {
|
||||||
const octokit = new Octokit({ auth: github_pat });
|
const octokit = new Octokit({ auth: github_pat });
|
||||||
@@ -22,7 +21,7 @@ export async function getProjectReadme(
|
|||||||
const response = await octokit.rest.repos.getContent({
|
const response = await octokit.rest.repos.getContent({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
path: readme,
|
path: 'README.md',
|
||||||
ref: branch,
|
ref: branch,
|
||||||
});
|
});
|
||||||
const data = response.data as { content?: string };
|
const data = response.data as { content?: string };
|
||||||
@@ -125,7 +124,6 @@ export async function getProject(project: GithubProject, github_pat?: string) {
|
|||||||
project.owner,
|
project.owner,
|
||||||
project.repo,
|
project.repo,
|
||||||
project.branch,
|
project.branch,
|
||||||
project.readme,
|
|
||||||
github_pat
|
github_pat
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,8 +183,43 @@ export async function getProjectDataPackage(
|
|||||||
}
|
}
|
||||||
const decodedContent = Buffer.from(fileContent, 'base64').toString();
|
const decodedContent = Buffer.from(fileContent, 'base64').toString();
|
||||||
const datapackage = JSON.parse(decodedContent);
|
const datapackage = JSON.parse(decodedContent);
|
||||||
return {...datapackage, repo };
|
|
||||||
|
return { ...datapackage, repo };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
examples/openspending/orgs.json
Normal file
3
examples/openspending/orgs.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"os-data"
|
||||||
|
]
|
||||||
3116
examples/openspending/package-lock.json
generated
3116
examples/openspending/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@
|
|||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@flowershow/core": "^0.4.13",
|
||||||
"@githubocto/flat-ui": "^0.14.1",
|
"@githubocto/flat-ui": "^0.14.1",
|
||||||
|
"@heroicons/react": "^2.0.18",
|
||||||
"@octokit/plugin-throttling": "^5.2.2",
|
"@octokit/plugin-throttling": "^5.2.2",
|
||||||
"@types/flexsearch": "^0.7.3",
|
"@types/flexsearch": "^0.7.3",
|
||||||
"@types/node": "18.16.0",
|
"@types/node": "18.16.0",
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
"eslint": "8.39.0",
|
"eslint": "8.39.0",
|
||||||
"eslint-config-next": "13.3.1",
|
"eslint-config-next": "13.3.1",
|
||||||
"flexsearch": "0.7.21",
|
"flexsearch": "0.7.21",
|
||||||
"next": "13.3.1",
|
"next": "13.3.0",
|
||||||
"next-seo": "^6.0.0",
|
"next-seo": "^6.0.0",
|
||||||
"octokit": "^2.0.14",
|
"octokit": "^2.0.14",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
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 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
275
examples/openspending/pages/@org/[org]/[project].tsx
Normal file
275
examples/openspending/pages/@org/[org]/[project].tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,53 @@
|
|||||||
import { AppProps } from 'next/app';
|
import { AppProps } from 'next/app';
|
||||||
import Head from 'next/head';
|
|
||||||
import './styles.css';
|
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';
|
||||||
|
|
||||||
function CustomApp({ Component, pageProps }: AppProps) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>GitHub Datasets</title>
|
<link rel="shortcut icon" href="/squared_logo.png" />
|
||||||
</Head>
|
</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">
|
<main className="app">
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,65 +1,58 @@
|
|||||||
import { promises as fs } from 'fs';
|
import { getAllProjectsFromOrg } from '../lib/project';
|
||||||
import path from 'path';
|
|
||||||
import {
|
|
||||||
GithubProject,
|
|
||||||
getProjectDataPackage,
|
|
||||||
getProjectMetadata,
|
|
||||||
} from '../lib/octokit';
|
|
||||||
import getConfig from 'next/config';
|
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 { Hero } from '../components/Hero';
|
||||||
import { Header } from '../components/Header';
|
|
||||||
import { Container } from '../components/Container';
|
import { Container } from '../components/Container';
|
||||||
import { FiscalDataPackage } from '../lib/datapackage.interface';
|
import { FiscalDataPackage } from '../lib/datapackage.interface';
|
||||||
import { loadDataPackage } from '../lib/loader';
|
import { loadDataPackage } from '../lib/loader';
|
||||||
import DatasetsSearch from '../components/DatasetsSearch';
|
import DatasetsSearch from '../components/DatasetsSearch';
|
||||||
|
import Layout from '../components/_shared/Layout';
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
const jsonDirectory = path.join(process.cwd(), '/datasets.json');
|
// TODO: support other orgs
|
||||||
const repos = await fs.readFile(jsonDirectory, 'utf8');
|
// const orgsListPath = path.join(process.cwd(), '/orgs.json');
|
||||||
const github_pat = getConfig().serverRuntimeConfig.github_pat;
|
// const orgs = await fs.readFile(orgsListPath, 'utf8');
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
const github_pat = getConfig().serverRuntimeConfig.github_pat;
|
||||||
datapackage,
|
|
||||||
repo,
|
const allProjects = await getAllProjectsFromOrg(
|
||||||
};
|
'os-data',
|
||||||
})
|
'main',
|
||||||
|
github_pat
|
||||||
);
|
);
|
||||||
|
|
||||||
const projects = datapackages.map(
|
const projects = allProjects.results.map(
|
||||||
(item: { datapackage: FiscalDataPackage & { repo: string }; repo: any }) =>
|
(item: { datapackage: FiscalDataPackage & { repo: string }; repo: any }) =>
|
||||||
loadDataPackage(item.datapackage, item.repo)
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
projects: JSON.stringify(projects),
|
projects: JSON.stringify(projects),
|
||||||
|
availableCountries,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Datasets({ projects }) {
|
export function Home({ projects, availableCountries }) {
|
||||||
projects = JSON.parse(projects);
|
projects = JSON.parse(projects);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white min-h-screen">
|
<Layout>
|
||||||
<Header />
|
<Hero
|
||||||
<Hero />
|
countriesCount={availableCountries.length}
|
||||||
|
datasetsCount={projects.length}
|
||||||
|
filesCount={projects.reduce(
|
||||||
|
(partialSum, a) => partialSum + a.files.length,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<section className="py-20 sm:py-32">
|
<section className="py-20 sm:py-32">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||||
@@ -74,12 +67,15 @@ export function Datasets({ projects }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<DatasetsSearch datasets={projects} />
|
<DatasetsSearch
|
||||||
|
datasets={projects}
|
||||||
|
availableCountries={availableCountries}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Datasets;
|
export default Home;
|
||||||
|
|||||||
BIN
examples/openspending/public/squared_logo.png
Normal file
BIN
examples/openspending/public/squared_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -13,7 +13,10 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true
|
"incremental": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
BIN
site/content/assets/howto/catalog-example.png
Normal file
BIN
site/content/assets/howto/catalog-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
site/content/assets/howto/catalog-facets-example.png
Normal file
BIN
site/content/assets/howto/catalog-facets-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
site/content/assets/howto/comments-example.png
Normal file
BIN
site/content/assets/howto/comments-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
site/content/assets/howto/linechart-example.png
Normal file
BIN
site/content/assets/howto/linechart-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
site/content/assets/howto/table-example.png
Normal file
BIN
site/content/assets/howto/table-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -20,7 +20,9 @@ const config = {
|
|||||||
// { name: "Components", href: "/docs/components" },
|
// { name: "Components", href: "/docs/components" },
|
||||||
{ name: "Blog", href: "/blog" },
|
{ name: "Blog", href: "/blog" },
|
||||||
{ name: "Showcases", href: "/#showcases" },
|
{ name: "Showcases", href: "/#showcases" },
|
||||||
|
{ name: "Howtos", href: "/howto" },
|
||||||
{ name: "Examples", href: "https://github.com/datopian/portaljs/tree/main/examples", target: "_blank" },
|
{ 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: "DL Demo", href: "/data-literate/demo" },
|
||||||
// { name: "Excel Viewer", href: "/excel-viewer" },
|
// { name: "Excel Viewer", href: "/excel-viewer" },
|
||||||
],
|
],
|
||||||
|
|||||||
77
site/content/howto/analytics.md
Normal file
77
site/content/howto/analytics.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 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).
|
||||||
75
site/content/howto/blog.md
Normal file
75
site/content/howto/blog.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 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
|
||||||
|
}
|
||||||
|
```
|
||||||
106
site/content/howto/comments.md
Normal file
106
site/content/howto/comments.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# 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]].
|
||||||
334
site/content/howto/drd.md
Normal file
334
site/content/howto/drd.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# 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:
|
||||||
|

|
||||||
|
|
||||||
|
```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)
|
||||||
12
site/content/howto/index.md
Normal file
12
site/content/howto/index.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
169
site/content/howto/markdown.md
Normal file
169
site/content/howto/markdown.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
79
site/content/howto/seo.md
Normal file
79
site/content/howto/seo.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 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)
|
||||||
94
site/content/howto/sitemap.md
Normal file
94
site/content/howto/sitemap.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 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",
|
||||||
|
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user