From adc3b6725eea18fc7888f8441b3e5a709d9181a1 Mon Sep 17 00:00:00 2001 From: Trochonovitz Date: Fri, 15 May 2026 19:34:03 +0200 Subject: [PATCH 1/6] feat: add init demo-app boilerplate --- demo-app/.gitignore | 27 ++++++++++ demo-app/README.md | 73 ++++++++++++++++++++++++++ demo-app/eslint.config.js | 22 ++++++++ demo-app/index.html | 13 +++++ demo-app/package.json | 34 ++++++++++++ demo-app/src/main.tsx | 19 +++++++ demo-app/src/routeTree.gen.ts | 95 ++++++++++++++++++++++++++++++++++ demo-app/src/routes/__root.tsx | 12 +++++ demo-app/tsconfig.app.json | 26 ++++++++++ demo-app/tsconfig.json | 7 +++ demo-app/tsconfig.node.json | 24 +++++++++ demo-app/vite.config.ts | 14 +++++ 12 files changed, 366 insertions(+) create mode 100644 demo-app/.gitignore create mode 100644 demo-app/README.md create mode 100644 demo-app/eslint.config.js create mode 100644 demo-app/index.html create mode 100644 demo-app/package.json create mode 100644 demo-app/src/main.tsx create mode 100644 demo-app/src/routeTree.gen.ts create mode 100644 demo-app/src/routes/__root.tsx create mode 100644 demo-app/tsconfig.app.json create mode 100644 demo-app/tsconfig.json create mode 100644 demo-app/tsconfig.node.json create mode 100644 demo-app/vite.config.ts diff --git a/demo-app/.gitignore b/demo-app/.gitignore new file mode 100644 index 0000000..b50664c --- /dev/null +++ b/demo-app/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env +.env.* +!.env.example + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/demo-app/README.md b/demo-app/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/demo-app/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/demo-app/eslint.config.js b/demo-app/eslint.config.js new file mode 100644 index 0000000..52dcfb6 --- /dev/null +++ b/demo-app/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', '**/routeTree.gen.ts']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/demo-app/index.html b/demo-app/index.html new file mode 100644 index 0000000..69164bf --- /dev/null +++ b/demo-app/index.html @@ -0,0 +1,13 @@ + + + + + + + demo-app + + +
+ + + diff --git a/demo-app/package.json b/demo-app/package.json new file mode 100644 index 0000000..e39f3d2 --- /dev/null +++ b/demo-app/package.json @@ -0,0 +1,34 @@ +{ + "name": "demo-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-router": "^1.169.2", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-markdown": "^10.1.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tanstack/router-devtools": "^1.166.13", + "@tanstack/router-plugin": "^1.167.35", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/demo-app/src/main.tsx b/demo-app/src/main.tsx new file mode 100644 index 0000000..39edaff --- /dev/null +++ b/demo-app/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { createRouter, RouterProvider } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import './index.css' + +const router = createRouter({ routeTree }) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/demo-app/src/routeTree.gen.ts b/demo-app/src/routeTree.gen.ts new file mode 100644 index 0000000..e1185a3 --- /dev/null +++ b/demo-app/src/routeTree.gen.ts @@ -0,0 +1,95 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ReactionsRouteImport } from './routes/reactions' +import { Route as CommentsRouteImport } from './routes/comments' +import { Route as SplatRouteImport } from './routes/$' + +const ReactionsRoute = ReactionsRouteImport.update({ + id: '/reactions', + path: '/reactions', + getParentRoute: () => rootRouteImport, +} as any) +const CommentsRoute = CommentsRouteImport.update({ + id: '/comments', + path: '/comments', + getParentRoute: () => rootRouteImport, +} as any) +const SplatRoute = SplatRouteImport.update({ + id: '/$', + path: '/$', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/$': typeof SplatRoute + '/comments': typeof CommentsRoute + '/reactions': typeof ReactionsRoute +} +export interface FileRoutesByTo { + '/$': typeof SplatRoute + '/comments': typeof CommentsRoute + '/reactions': typeof ReactionsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/$': typeof SplatRoute + '/comments': typeof CommentsRoute + '/reactions': typeof ReactionsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/$' | '/comments' | '/reactions' + fileRoutesByTo: FileRoutesByTo + to: '/$' | '/comments' | '/reactions' + id: '__root__' | '/$' | '/comments' | '/reactions' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + SplatRoute: typeof SplatRoute + CommentsRoute: typeof CommentsRoute + ReactionsRoute: typeof ReactionsRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/reactions': { + id: '/reactions' + path: '/reactions' + fullPath: '/reactions' + preLoaderRoute: typeof ReactionsRouteImport + parentRoute: typeof rootRouteImport + } + '/comments': { + id: '/comments' + path: '/comments' + fullPath: '/comments' + preLoaderRoute: typeof CommentsRouteImport + parentRoute: typeof rootRouteImport + } + '/$': { + id: '/$' + path: '/$' + fullPath: '/$' + preLoaderRoute: typeof SplatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + SplatRoute: SplatRoute, + CommentsRoute: CommentsRoute, + ReactionsRoute: ReactionsRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/demo-app/src/routes/__root.tsx b/demo-app/src/routes/__root.tsx new file mode 100644 index 0000000..777bbb4 --- /dev/null +++ b/demo-app/src/routes/__root.tsx @@ -0,0 +1,12 @@ +import { createRootRoute, Outlet } from "@tanstack/react-router"; +// import { TanStackRouterDevtools } from "@tanstack/router-devtools"; + +export const Route = createRootRoute({ + component: () => ( + <> + + {/* Tanstack Router Devtools */} + {/* */} + + ), +}); diff --git a/demo-app/tsconfig.app.json b/demo-app/tsconfig.app.json new file mode 100644 index 0000000..d6ca4e7 --- /dev/null +++ b/demo-app/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "strict": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/demo-app/tsconfig.json b/demo-app/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/demo-app/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/demo-app/tsconfig.node.json b/demo-app/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/demo-app/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/demo-app/vite.config.ts b/demo-app/vite.config.ts new file mode 100644 index 0000000..2ba8a0c --- /dev/null +++ b/demo-app/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + }), + react(), + ], +}) From bf7721dba8ca8eb98405fb11011c774e69551808 Mon Sep 17 00:00:00 2001 From: Trochonovitz Date: Fri, 15 May 2026 19:34:59 +0200 Subject: [PATCH 2/6] feat: add Strapi Navigation plugin frontend implementation --- .../src/components/NavigationPageView.tsx | 70 +++++++++++++++++++ demo-app/src/lib/strapi-navigation.ts | 34 +++++++++ demo-app/src/routes/$.tsx | 6 ++ demo-app/src/types/navigation.ts | 44 ++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 demo-app/src/components/NavigationPageView.tsx create mode 100644 demo-app/src/lib/strapi-navigation.ts create mode 100644 demo-app/src/routes/$.tsx create mode 100644 demo-app/src/types/navigation.ts diff --git a/demo-app/src/components/NavigationPageView.tsx b/demo-app/src/components/NavigationPageView.tsx new file mode 100644 index 0000000..75ce698 --- /dev/null +++ b/demo-app/src/components/NavigationPageView.tsx @@ -0,0 +1,70 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { fetchPageByPath } from "../lib/strapi-navigation"; +import type { StrapiNavigationPage } from "../types/navigation"; + +export function NavigationPageView() { + const pathname = useRouterState({ select: (page) => page.location.pathname }); + + return ( + <> +
    + {["home", "about", "more"].map((page, index) => ( +
  • + + {page.toUpperCase()} + +
  • + ))} +
+ + + ); +} + +function NavigationPageBody({ pathname }: { pathname: string }) { + const [page, setPage] = useState( + undefined, + ); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + const signal = controller.signal; + + fetchPageByPath(pathname, signal) + .then((page) => { + setPage(page); + }) + .catch((error: unknown) => { + if (!(error instanceof Error && error.name === "AbortError")) { + setError( + error instanceof Error + ? error.message + : "Failed to load page from Strapi.", + ); + } + }); + return () => { + controller.abort(); + }; + }, [pathname]); + + if (error) { + return

{error}

; + } + if (page === undefined) { + return

Loading…

; + } + if (!page) { + return

No page is linked to this path in Strapi navigation.

; + } + + return ( +
+

{page.title}

+ {page.content ? {page.content} : null} +
+ ); +} diff --git a/demo-app/src/lib/strapi-navigation.ts b/demo-app/src/lib/strapi-navigation.ts new file mode 100644 index 0000000..f1c0c2a --- /dev/null +++ b/demo-app/src/lib/strapi-navigation.ts @@ -0,0 +1,34 @@ +import type { + NavigationItemTree, + StrapiNavigationPage, +} from "../types/navigation"; + +export async function fetchPageByPath( + pathname: string, + signal: AbortSignal, +): Promise { + const normalized = + pathname === "/" + ? "/home" + : pathname.startsWith("/") + ? pathname + : `/${pathname}`; + + const url = new URL(`http://localhost:1337/api/navigation/render/navigation`); + url.searchParams.set("path", normalized); + + const res = await fetch(url.toString(), { signal }); + if (!res.ok) { + throw new Error( + `Failed to load page from Strapi navigation: ${res.status} ${res.statusText}`, + ); + } + + const data = (await res.json()) as NavigationItemTree[]; + const first = data[0]; + if (!first || first.type !== "INTERNAL") { + return null; + } + const related = first.related as StrapiNavigationPage | null | undefined; + return related ?? null; +} diff --git a/demo-app/src/routes/$.tsx b/demo-app/src/routes/$.tsx new file mode 100644 index 0000000..36b6678 --- /dev/null +++ b/demo-app/src/routes/$.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { NavigationPageView } from "../components/NavigationPageView"; + +export const Route = createFileRoute("/$")({ + component: NavigationPageView, +}); diff --git a/demo-app/src/types/navigation.ts b/demo-app/src/types/navigation.ts new file mode 100644 index 0000000..e1a7af9 --- /dev/null +++ b/demo-app/src/types/navigation.ts @@ -0,0 +1,44 @@ +export type NavigationItemInternalFlat = { + order: number; + id: number; + title: string; + type: "INTERNAL"; + path: string; + uiRouterKey: string; + menuAttached: boolean; + related: unknown; +}; + +export type NavigationItemExternalFlat = { + order: number; + id: number; + title: string; + type: "EXTERNAL"; + externalPath: string; + uiRouterKey: string; + menuAttached: boolean; +}; + +export type NavigationItemFlat = + | NavigationItemExternalFlat + | NavigationItemInternalFlat; + +export type NavigationItemTree = { + order: number; + id: number; + title: string; + type: "INTERNAL" | "EXTERNAL"; + path: string; + uiRouterKey: string; + menuAttached: boolean; + related: unknown; + items: NavigationItemTree[] | null; +}; + +export type StrapiNavigationPage = { + id: number; + documentId: string; + title: string; + name: string; + content: string | null; +}; From fd8c2cd279b58b84391a7ec1e91aebe4061ee11f Mon Sep 17 00:00:00 2001 From: Trochonovitz Date: Fri, 15 May 2026 19:35:49 +0200 Subject: [PATCH 3/6] feat: add Strapi Reactions plugin frontend implementation --- demo-app/src/components/ReactionsPageView.tsx | 64 +++++++++++++ demo-app/src/lib/strapi-reactions.ts | 57 +++++++++++ demo-app/src/routes/reactions.tsx | 6 ++ demo-app/src/types/articles.ts | 95 +++++++++++++++++++ demo-app/src/types/reactions.ts | 24 +++++ 5 files changed, 246 insertions(+) create mode 100644 demo-app/src/components/ReactionsPageView.tsx create mode 100644 demo-app/src/lib/strapi-reactions.ts create mode 100644 demo-app/src/routes/reactions.tsx create mode 100644 demo-app/src/types/articles.ts create mode 100644 demo-app/src/types/reactions.ts diff --git a/demo-app/src/components/ReactionsPageView.tsx b/demo-app/src/components/ReactionsPageView.tsx new file mode 100644 index 0000000..4be8a69 --- /dev/null +++ b/demo-app/src/components/ReactionsPageView.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { + fetchArticleReactionsCount, + fetchArticles, + fetchEmojis, + setReaction, +} from "../lib/strapi-reactions"; +import ReactMarkdown from "react-markdown"; +import type { Article } from "../types/articles"; +import type { Reaction } from "../types/reactions"; + +function ReactionsPageView() { + const [articles, setArticles] = useState([]); + const [emojis, setEmojis] = useState([]); + + useEffect(() => { + fetchArticles() + .then((data) => setArticles(data)) + .catch((error: unknown) => { + console.error(error); + }); + fetchEmojis() + .then((data) => { + setEmojis(data); + }) + .catch((error: unknown) => { + console.error(error); + }); + }, []); + + return ( + <> + {articles.map((article) => { + return ( +
+

{article.title}

+ + {article.blocks[0].body} + + + {emojis.map((emoji) => { + // const reactionsCount = fetchArticleReactionsCount( + // emoji.name, + // article.documentId, + // ); + return ( + + ); + })} +
+ ); + })} + + ); +} + +export default ReactionsPageView; diff --git a/demo-app/src/lib/strapi-reactions.ts b/demo-app/src/lib/strapi-reactions.ts new file mode 100644 index 0000000..d8e1f4f --- /dev/null +++ b/demo-app/src/lib/strapi-reactions.ts @@ -0,0 +1,57 @@ +export async function fetchArticles() { + const url = new URL("http://localhost:1337/api/articles?populate=*"); + const res = await fetch(url.toString()); + if (!res.ok) { + throw new Error( + `Failed to fetch articles: ${res.status} ${res.statusText}`, + ); + } + const data = await res.json(); + return data.data; +} + +export async function fetchEmojis() { + const url = new URL(`http://localhost:1337/api/reactions/kinds`); + const res = await fetch(url.toString()); + if (!res.ok) { + throw new Error(`Failed to fetch emoji: ${res.status} ${res.statusText}`); + } + const data = await res.json(); + return data; +} + +export async function setReaction(reactionType: string, articleId: string) { + const url = new URL( + `http://localhost:1337/api/reactions/set/${reactionType}/collection/api::article.article/${articleId}`, + ); + const res = await fetch(url.toString(), { + method: "POST", + headers: { + "X-Reactions-Author": `user-${Math.random().toString(36).substring(2, 15)}`, + }, + }); + if (!res.ok) { + throw new Error( + `Failed to fetch reactions: ${res.status} ${res.statusText}`, + ); + } + const data = await res.json(); + return data.data; +} + +export async function fetchArticleReactionsCount( + reactionType: string, + articleId: string, +) { + const url = new URL( + `http://localhost:1337/api/reactions/list/${reactionType}/collection/api::article.article/${articleId}`, + ); + const res = await fetch(url.toString()); + if (!res.ok) { + throw new Error( + `Failed to fetch reactions: ${res.status} ${res.statusText}`, + ); + } + const data = await res.json(); + return data.length; +} diff --git a/demo-app/src/routes/reactions.tsx b/demo-app/src/routes/reactions.tsx new file mode 100644 index 0000000..f34e891 --- /dev/null +++ b/demo-app/src/routes/reactions.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import ReactionsPageView from "../components/ReactionsPageView"; + +export const Route = createFileRoute("/reactions")({ + component: ReactionsPageView, +}); diff --git a/demo-app/src/types/articles.ts b/demo-app/src/types/articles.ts new file mode 100644 index 0000000..acf192e --- /dev/null +++ b/demo-app/src/types/articles.ts @@ -0,0 +1,95 @@ +/** Pola dokumentu Strapi v5 */ +type StrapiDocumentMeta = { + id: number; + documentId: string; + createdAt: string; + updatedAt: string; + publishedAt: string | null; +}; + +type StrapiImageFormat = { + name: string; + hash: string; + ext: string; + mime: string; + path: string | null; + width: number; + height: number; + size: number; // KB w API Strapi + sizeInBytes: number; + url: string; +}; + +/** Plik z biblioteki mediów (upload) */ +type StrapiUploadFile = StrapiDocumentMeta & { + name: string; + alternativeText: string | null; + caption: string | null; + width: number; + height: number; + formats: { + thumbnail?: StrapiImageFormat; + small?: StrapiImageFormat; + medium?: StrapiImageFormat; + large?: StrapiImageFormat; + [key: string]: StrapiImageFormat | undefined; + } | null; + hash: string; + ext: string; + mime: string; + size: number; + url: string; + previewUrl: string | null; + provider: string; + provider_metadata: Record | null; + /** W zależności od wersji / konfiguracji może być też obiekt; w Twoim przykładzie null */ + focalPoint: unknown; +}; + +type StrapiAuthor = StrapiDocumentMeta & { + name: string; + email: string; +}; + +type StrapiCategory = StrapiDocumentMeta & { + name: string; + slug: string; + description: string | null; +}; + +/** Bloki dynamic zone — discriminated union po `__component` */ +type ArticleBlock = + | { + __component: "shared.rich-text"; + id: number; + body: string; + } + | { + __component: "shared.quote"; + id: number; + title: string; + body: string; + } + | { + __component: "shared.media"; + id: number; + /** Po `populate` w query — sama struktura jak cover lub null */ + file?: StrapiUploadFile | null; + } + | { + __component: "shared.slider"; + id: number; + /** Po populate — tablica plików */ + files?: StrapiUploadFile[] | null; + }; + +/** Artykuł jak w Twoim obiekcie (relacje rozwinęte) */ +export type Article = StrapiDocumentMeta & { + title: string; + description: string; + slug: string; + cover: StrapiUploadFile | null; + author: StrapiAuthor | null; + category: StrapiCategory | null; + blocks: ArticleBlock[]; +}; diff --git a/demo-app/src/types/reactions.ts b/demo-app/src/types/reactions.ts new file mode 100644 index 0000000..448c6fd --- /dev/null +++ b/demo-app/src/types/reactions.ts @@ -0,0 +1,24 @@ +type StrapiDocumentMeta = { + id: number; + documentId: string; + createdAt: string; + updatedAt: string; + publishedAt: string | null; +}; + +/** + * Rekord reakcji (np. z pluginu reactions) — odpowiednik Twojego obiektu. + */ +export type Reaction = StrapiDocumentMeta & { + name: string; + slug: string; + emoji: string; + emojiFallbackUrl: string; + /** i18n: brak wersji językowej lub wyłączone */ + locale: string | null; + /** + * Ikona z biblioteki mediów po populate; bez populate często null lub samo id. + * Tu: null. + */ + icon: unknown | null; // albo: StrapiUploadFile | null — gdy masz typ pliku z uploadu +}; From 12c45453a1e3023cb899817e09496eba7e1944a8 Mon Sep 17 00:00:00 2001 From: Trochonovitz Date: Mon, 18 May 2026 12:09:39 +0200 Subject: [PATCH 4/6] feat: add Strapi Comments plugin frontend implementation --- demo-app/src/components/CommentsPageView.tsx | 304 +++++++++++++++++++ demo-app/src/lib/strapi-comments.ts | 88 ++++++ demo-app/src/routes/comments.tsx | 6 + demo-app/src/types/comments.ts | 23 ++ 4 files changed, 421 insertions(+) create mode 100644 demo-app/src/components/CommentsPageView.tsx create mode 100644 demo-app/src/lib/strapi-comments.ts create mode 100644 demo-app/src/routes/comments.tsx create mode 100644 demo-app/src/types/comments.ts diff --git a/demo-app/src/components/CommentsPageView.tsx b/demo-app/src/components/CommentsPageView.tsx new file mode 100644 index 0000000..320df8d --- /dev/null +++ b/demo-app/src/components/CommentsPageView.tsx @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { fetchArticles } from "../lib/strapi-reactions"; +import { + fetchCommentsFlat, + postComment, + reportCommentAbuse, +} from "../lib/strapi-comments"; +import type { Article } from "../types/articles"; +import type { + AbuseReportPayload, + CommentAuthor, + StrapiComment, +} from "../types/comments"; +import { REPORT_PRESETS } from "../lib/strapi-comments"; + +function CommentsPageView() { + const [articles, setArticles] = useState([]); + const [listError, setListError] = useState(null); + + useEffect(() => { + fetchArticles() + .then((data) => setArticles(data)) + .catch((err: unknown) => { + setListError( + err instanceof Error ? err.message : "Failed to load articles.", + ); + }); + }, []); + + if (listError) { + return ( +
+

{listError}

+
+ ); + } + + if (articles.length === 0) { + return ( +
+

Loading articles…

+
+ ); + } + + return ( +
+ {articles.map((article) => ( + + ))} +
+ ); +} + +function ArticleWithComments({ article }: { article: Article }) { + const firstRich = article.blocks.find( + (b): b is Extract => + b.__component === "shared.rich-text", + ); + + return ( +
+

{article.title}

+

{article.description}

+ {firstRich ? ( +
+ {firstRich.body} +
+ ) : null} + +
+ ); +} + +function CommentsBlock({ documentId }: { documentId: string }) { + const [comments, setComments] = useState(null); + const [error, setError] = useState(null); + + const reload = useCallback(() => { + fetchCommentsFlat(documentId) + .then((data) => { + setComments(data); + setError(null); + }) + .catch((err: unknown) => { + setError( + err instanceof Error ? err.message : "Failed to load comments.", + ); + }); + }, [documentId]); + + useEffect(() => { + reload(); + }, [reload]); + + const handleReport = async ( + commentId: number, + report: AbuseReportPayload, + ) => { + try { + await reportCommentAbuse(documentId, commentId, report); + reload(); + } catch (e: unknown) { + console.error(e); + setError(e instanceof Error ? e.message : "Report failed."); + } + }; + + const handlePost = async (author: CommentAuthor, content: string) => { + await postComment(documentId, author, content); + reload(); + }; + + if (error) { + return

{error}

; + } + if (comments === null) { + return

Loading comments…

; + } + + return ( +
+

Comments

+
    + {comments.map((comment) => ( +
  • +
    +
    + + {comment.blocked ? "Comment blocked" : comment.author.name} + +

    + {comment.blocked ? "—" : comment.content} +

    +
    + + {!comment.blocked ? ( + handleReport(comment.id, report)} + /> + ) : null} +
    +
    +
  • + ))} +
+ {comments.length === 0 ? ( +

No comments yet.

+ ) : null} + +
+ ); +} + +function ReportCommentMenu({ + onReport, +}: { + onReport: (report: AbuseReportPayload) => void | Promise; +}) { + return ( + + ); +} + +function NewCommentForm({ + onSubmit, +}: { + onSubmit: (author: CommentAuthor, content: string) => void | Promise; +}) { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [content, setContent] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + try { + const author: CommentAuthor = { + id: email.replace(/@/g, "_"), + name, + email, + }; + await onSubmit(author, content); + setName(""); + setEmail(""); + setContent(""); + } catch (err: unknown) { + console.error(err); + } finally { + setSubmitting(false); + } + }; + + return ( +
void handleSubmit(e)} + style={{ + marginTop: "1.5rem", + display: "grid", + gap: "0.75rem", + maxWidth: 480, + }} + > +

Add a comment

+ + +