diff --git a/app/[locale]/(user)/publications/[publicationId]/PublicationDetailsPage.tsx b/app/[locale]/(user)/publications/[publicationId]/PublicationDetailsPage.tsx
new file mode 100644
index 00000000..368a2655
--- /dev/null
+++ b/app/[locale]/(user)/publications/[publicationId]/PublicationDetailsPage.tsx
@@ -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 (
+ <>
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ Error loading {RESOURCE_LABEL}
+
+ {error instanceof Error ? error.message : 'Failed to fetch'}
+
+
+ ) : publication ? (
+ <>
+
+
+ >
+ ) : (
+
+ {RESOURCE_LABEL} not found
+
+ )}
+
+
+ {!isLoading && publication && }
+
+
+
+ >
+ );
+}
diff --git a/app/[locale]/(user)/publications/[publicationId]/components/Blocks.tsx b/app/[locale]/(user)/publications/[publicationId]/components/Blocks.tsx
new file mode 100644
index 00000000..257a0176
--- /dev/null
+++ b/app/[locale]/(user)/publications/[publicationId]/components/Blocks.tsx
@@ -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 (
+
+
+
+ );
+}
+
+function PdfBlock({ blockId, fileName }: { blockId: string; fileName: string }) {
+ return (
+
+ );
+}
+
+function FileCard({ block }: { block: Block }) {
+ return (
+
+
+
+ {block.fileName}
+
+
+ {block.fileFormat.toUpperCase()} {formatSize(block.fileSize)}
+
+
+
+
+
+
+ );
+}
+
+function BlockView({ block }: { block: Block }) {
+ if (block.blockType === 'YOUTUBE') {
+ return ;
+ }
+ if (block.fileFormat.toLowerCase() === 'pdf') {
+ return ;
+ }
+ return ;
+}
+
+/**
+ * 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 (
+
+
+ This {RESOURCE_LABEL} has no content yet.
+
+
+ );
+ }
+
+ return (
+
+ {ordered.map((block) => (
+
+ ))}
+
+ );
+}
diff --git a/app/[locale]/(user)/publications/[publicationId]/components/Metadata.tsx b/app/[locale]/(user)/publications/[publicationId]/components/Metadata.tsx
new file mode 100644
index 00000000..e6688270
--- /dev/null
+++ b/app/[locale]/(user)/publications/[publicationId]/components/Metadata.tsx
@@ -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 (
+
+
+ {label}
+
+ {value}
+
+ );
+}
+
+/** 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 (
+
+
+
+ {data.sectors?.length > 0 && (
+ s.name).join(', ')} />
+ )}
+ {data.geographies?.length > 0 && (
+ g.name).join(', ')}
+ />
+ )}
+
+
+ );
+}
diff --git a/app/[locale]/(user)/publications/[publicationId]/components/PrimaryData.tsx b/app/[locale]/(user)/publications/[publicationId]/components/PrimaryData.tsx
new file mode 100644
index 00000000..2db4c239
--- /dev/null
+++ b/app/[locale]/(user)/publications/[publicationId]/components/PrimaryData.tsx
@@ -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 (
+
+ {data.resourceType?.name && (
+
+ {data.resourceType.name}
+
+ )}
+
{data.title}
+ {data.authors?.length > 0 && (
+
+ By {data.authors.join(', ')}
+
+ )}
+ {data.publicationDate && (
+
+ {data.publicationDate}
+
+ )}
+ {data.description && (
+
+ {data.description}
+
+ )}
+ {data.externalSourceLink && (
+
+ External source
+
+ )}
+
+ );
+}
diff --git a/app/[locale]/(user)/publications/[publicationId]/page.tsx b/app/[locale]/(user)/publications/[publicationId]/page.tsx
new file mode 100644
index 00000000..e64eb607
--- /dev/null
+++ b/app/[locale]/(user)/publications/[publicationId]/page.tsx
@@ -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 ;
+}
diff --git a/app/[locale]/(user)/publications/architecture.md b/app/[locale]/(user)/publications/architecture.md
new file mode 100644
index 00000000..6beb0452
--- /dev/null
+++ b/app/[locale]/(user)/publications/architecture.md
@@ -0,0 +1,84 @@
+# Resources (Publication) — frontend
+
+> Part of feature: **resources** · sibling (primary): `DataExBackend/api/schema/publication_architecture.md` — see it for the cross-repo overview, data model, security and API contract.
+
+## Overview
+
+The frontend for the Resource entity (backend name **Publication**). Users only ever see the word **"Resource"** — the single source for that label is `lib/constants/resourceLabel.ts` (`RESOURCE_LABEL`, `RESOURCE_LABEL_PLURAL`, `RESOURCE_PATH = '/publications'`). The FE has two halves: a public **read** side (Explore listing, detail page, global-search presence) and a dashboard **authoring** side (create / edit / publish, block editor, and a Resource picker inside the Use Case / Collaborative editors).
+
+All data goes through the shared GraphQL clients in `lib/api.ts` (`GraphQL` session-aware, entity-header scoped) and the REST search helper `fetchData(type, …)` (`@/fetch`) which hits `/api/search/publication/`. Block files download through the gated REST route `${NEXT_PUBLIC_BACKEND_URL}/api/publications/blocks//download/`. GraphQL documents are inline `graphql()` tags typed by codegen; the codegen `documents` glob was widened to include `components/**` so the shared components' docs are typed.
+
+## Submodule map
+
+| Submodule | Route / entry |
+|---|---|
+| Detail page | `app/[locale]/(user)/publications/[publicationId]/` |
+| Explore listing | `app/[locale]/(user)/publications/page.tsx` (reuses `ListingComponent type="publication"`) |
+| Explore nav entry | `app/[locale]/dashboard/components/main-nav.tsx` (`exploreLinks`) |
+| Unified search | `app/[locale]/(user)/search/components/UnifiedListingComponent.tsx` (`publication` type + redirect) |
+| Dashboard list + create | `app/[locale]/dashboard/[entityType]/[entitySlug]/publications/` |
+| Create / edit / publish shell | `…/publications/edit/` (context + layout + `[id]/{metadata,blocks,publish}`) |
+| Shared metadata form | `components/publications/PublicationForm.tsx` |
+| Link picker | `components/publications/PublicationLinkPicker.tsx` (embedded in UC & Collab assign pages) |
+| Dashboard sidebar entry | `app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx` (`orgSidebarNav`) |
+
+---
+
+## Submodule: Detail page
+
+### Trigger
+Route `/publications/[publicationId]` → `PublicationDetailsPage` runs the `getPublication` query.
+
+### Flow
+Fetch → loading/error/not-found branches → two-column layout: left renders `PrimaryData` (title, abstract, authors, external link) + `Blocks`; right rail renders `Metadata` (owner, license, tags, download count). `Blocks` sorts by `position` and renders each: **PDF** → inline `