Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use client';

import { graphql } from '@/gql';
import { useQuery } from '@tanstack/react-query';
import { Spinner, Text } from 'opub-ui';

import BreadCrumbs from '@/components/BreadCrumbs';
import JsonLd from '@/components/JsonLd';
import { RESOURCE_LABEL, RESOURCE_PATH } from '@/lib/constants/resourceLabel';
import { GraphQL } from '@/lib/api';
import { generateJsonLd } from '@/lib/utils';
import { Blocks } from './components/Blocks';
import { Metadata } from './components/Metadata';
import { PrimaryData } from './components/PrimaryData';

const publicationQuery = graphql(`
query getPublication($publicationId: UUID!) {
getPublication(publicationId: $publicationId) {
id
title
description
slug
status
authors
publicationDate
license
externalSourceLink
downloadCount
created
modified
resourceType {
id
name
}
organization {
id
name
logo {
url
}
}
user {
id
fullName
profilePicture {
url
}
}
sectors {
id
name
}
geographies {
id
name
}
blocks {
id
position
blockType
fileName
fileFormat
fileSize
youtubeUrl
youtubeVideoId
}
}
}
`);

export default function PublicationDetailsPage({
publicationId,
}: {
publicationId: string;
}) {
const { data, isLoading, error } = useQuery(
['publication_details', publicationId],
() => GraphQL(publicationQuery, {}, { publicationId }),
{ retry: false }
);

const publication = data?.getPublication;

const jsonLd = generateJsonLd({
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: publication?.title,
url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}${RESOURCE_PATH}/${publicationId}`,
description: publication?.description,
publisher: {
'@type': 'Organization',
name: 'CivicDataSpace',
url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}`,
},
});

return (
<>
<JsonLd json={jsonLd} />
<main className="bg-surfaceDefault">
<BreadCrumbs
data={[
{ href: '/', label: 'Home' },
{ href: RESOURCE_PATH, label: RESOURCE_LABEL + 's' },
{ href: '#', label: `${RESOURCE_LABEL} Details` },
]}
/>
<div className="flex">
<div className="w-full gap-10 border-r-2 border-solid border-greyExtralight p-6 lg:w-3/4 lg:p-10">
{isLoading ? (
<div className="mt-8 flex justify-center">
<Spinner />
</div>
) : error ? (
<div className="mt-8 flex flex-col items-center gap-4">
<Text variant="heading2xl">Error loading {RESOURCE_LABEL}</Text>
<Text variant="bodyMd" className="text-textSubdued">
{error instanceof Error ? error.message : 'Failed to fetch'}
</Text>
</div>
) : publication ? (
<>
<PrimaryData data={publication} />
<Blocks blocks={publication.blocks} />
</>
) : (
<div className="mt-8 flex justify-center">
<Text variant="heading2xl">{RESOURCE_LABEL} not found</Text>
</div>
)}
</div>
<div className="hidden w-1/4 gap-10 px-7 py-10 lg:block">
{!isLoading && publication && <Metadata data={publication} />}
</div>
</div>
</main>
</>
);
}
113 changes: 113 additions & 0 deletions app/[locale]/(user)/publications/[publicationId]/components/Blocks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use client';

import { Button, Text } from 'opub-ui';

import { RESOURCE_LABEL } from '@/lib/constants/resourceLabel';

type Block = {
id: string;
position: number;
blockType: string;
fileName: string;
fileFormat: string;
fileSize?: number | null;
youtubeUrl?: string | null;
youtubeVideoId: string;
};

/** The gated download URL for a block's file (draft files are access-checked). */
function blockDownloadUrl(blockId: string): string {
return `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/publications/blocks/${blockId}/download/`;
}

/** Human-readable file size. */
function formatSize(bytes?: number | null): string {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}

function YoutubeBlock({ videoId }: { videoId: string }) {
return (
<div className="relative w-full overflow-hidden rounded-md" style={{ paddingTop: '56.25%' }}>
<iframe
className="absolute left-0 top-0 h-full w-full"
src={`https://www.youtube.com/embed/${videoId}`}
title="YouTube video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

function PdfBlock({ blockId, fileName }: { blockId: string; fileName: string }) {
return (
<div className="flex flex-col gap-2">
<iframe
className="h-[600px] w-full rounded-md border border-solid border-greyExtralight"
src={blockDownloadUrl(blockId)}
title={fileName}
/>
<a href={blockDownloadUrl(blockId)} download>
<Button variant="basic">Download {fileName}</Button>
</a>
</div>
);
}

function FileCard({ block }: { block: Block }) {
return (
<div className="flex items-center justify-between rounded-md border border-solid border-greyExtralight p-4">
<div className="flex flex-col">
<Text variant="bodyMd" fontWeight="medium">
{block.fileName}
</Text>
<Text variant="bodySm" className="text-textSubdued">
{block.fileFormat.toUpperCase()} {formatSize(block.fileSize)}
</Text>
</div>
<a href={blockDownloadUrl(block.id)} download>
<Button variant="basic">Download</Button>
</a>
</div>
);
}

function BlockView({ block }: { block: Block }) {
if (block.blockType === 'YOUTUBE') {
return <YoutubeBlock videoId={block.youtubeVideoId} />;
}
if (block.fileFormat.toLowerCase() === 'pdf') {
return <PdfBlock blockId={block.id} fileName={block.fileName} />;
}
return <FileCard block={block} />;
}

/**
* Renders a resource's ordered content blocks. PDFs and YouTube videos play
* inline; every other file type shows a download card. A resource with no
* blocks renders an empty section (zero-block publish is allowed).
*/
export function Blocks({ blocks }: { blocks: Block[] }) {
const ordered = [...(blocks ?? [])].sort((a, b) => a.position - b.position);

if (ordered.length === 0) {
return (
<div className="mt-8">
<Text variant="bodyMd" className="text-textSubdued">
This {RESOURCE_LABEL} has no content yet.
</Text>
</div>
);
}

return (
<div className="mt-8 flex flex-col gap-6">
{ordered.map((block) => (
<BlockView key={block.id} block={block} />
))}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client';

import { Text } from 'opub-ui';

type Tag = { id: string; name: string };
type PublicationMeta = {
license: string;
downloadCount: number;
sectors: Tag[];
geographies: Tag[];
organization?: { name: string } | null;
user?: { fullName?: string | null } | null;
};

function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-1 border-b border-solid border-greyExtralight py-3">
<Text variant="bodySm" className="uppercase text-textSubdued">
{label}
</Text>
<Text variant="bodyMd">{value}</Text>
</div>
);
}

/** The right-rail metadata panel: owner, license, tags and download count. */
export function Metadata({ data }: { data: PublicationMeta }) {
const owner = data.organization?.name || data.user?.fullName || 'Individual';

return (
<div className="flex flex-col">
<Row label="Published by" value={owner} />
<Row label="License" value={data.license} />
{data.sectors?.length > 0 && (
<Row label="Sectors" value={data.sectors.map((s) => s.name).join(', ')} />
)}
{data.geographies?.length > 0 && (
<Row
label="Geographies"
value={data.geographies.map((g) => g.name).join(', ')}
/>
)}
<Row label="Downloads" value={data.downloadCount} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import { Text } from 'opub-ui';

type PublicationData = {
title: string;
description?: string | null;
authors: string[];
publicationDate?: string | null;
externalSourceLink?: string | null;
resourceType?: { name: string } | null;
};

/** Title, abstract, authors and the external source link — the resource's headline. */
export function PrimaryData({ data }: { data: PublicationData }) {
return (
<div className="flex flex-col gap-4">
{data.resourceType?.name && (
<Text variant="bodySm" className="uppercase text-textSubdued">
{data.resourceType.name}
</Text>
)}
<Text variant="heading2xl">{data.title}</Text>
{data.authors?.length > 0 && (
<Text variant="bodyMd" className="text-textSubdued">
By {data.authors.join(', ')}
</Text>
)}
{data.publicationDate && (
<Text variant="bodySm" className="text-textSubdued">
{data.publicationDate}
</Text>
)}
{data.description && (
<Text variant="bodyMd" className="whitespace-pre-line">
{data.description}
</Text>
)}
{data.externalSourceLink && (
<a
href={data.externalSourceLink}
target="_blank"
rel="noreferrer"
className="text-actionPrimaryDefault underline"
>
External source
</a>
)}
</div>
);
}
41 changes: 41 additions & 0 deletions app/[locale]/(user)/publications/[publicationId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { use } from 'react';

import { RESOURCE_LABEL, RESOURCE_PATH } from '@/lib/constants/resourceLabel';
import { generatePageMetadata } from '@/lib/utils';
import PublicationDetailsPage from './PublicationDetailsPage';

export async function generateMetadata({
params,
}: {
params: Promise<{ publicationId: string }>;
}) {
try {
const { publicationId } = await params;
return generatePageMetadata({
title: `${RESOURCE_LABEL} Details | CivicDataSpace`,
description: `Explore a ${RESOURCE_LABEL.toLowerCase()} — reports, research and findings.`,
keywords: [RESOURCE_LABEL, 'Report', 'Research', 'Publication'],
openGraph: {
type: 'website',
locale: 'en_US',
url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}${RESOURCE_PATH}/${publicationId}`,
title: `${RESOURCE_LABEL} Details`,
description: `Explore a ${RESOURCE_LABEL.toLowerCase()}.`,
siteName: 'CivicDataSpace',
image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`,
},
});
} catch (e) {
console.error('Metadata fetch error', e);
return generatePageMetadata({ title: `${RESOURCE_LABEL} Details` });
}
}

export default function Page({
params,
}: {
params: Promise<{ publicationId: string }>;
}) {
const { publicationId } = use(params);
return <PublicationDetailsPage publicationId={publicationId} />;
}
Loading
Loading