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/components/CommentsPageView.tsx b/demo-app/src/components/CommentsPageView.tsx
new file mode 100644
index 0000000..2888d51
--- /dev/null
+++ b/demo-app/src/components/CommentsPageView.tsx
@@ -0,0 +1,301 @@
+import { useCallback, useEffect, useState } from "react";
+import ReactMarkdown from "react-markdown";
+import {
+ fetchCommentsFlat,
+ postComment,
+ reportCommentAbuse,
+} from "../lib/strapi-comments";
+import { useArticles } from "../hooks/useArticles";
+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, isLoading, error } = useArticles();
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (articles.length === 0) {
+ return (
+
+
No articles in Strapi.
+
+ );
+ }
+
+ 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 (
+
+ );
+}
+
+export default CommentsPageView;
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/components/ReactionsPageView.tsx b/demo-app/src/components/ReactionsPageView.tsx
new file mode 100644
index 0000000..6fdd345
--- /dev/null
+++ b/demo-app/src/components/ReactionsPageView.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useState } from "react";
+import { fetchEmojis, setReaction } from "../lib/strapi-reactions";
+import ReactMarkdown from "react-markdown";
+import { useArticles } from "../hooks/useArticles";
+import type { Reaction } from "../types/reactions";
+
+function ReactionsPageView() {
+ const { articles, isLoading, error } = useArticles();
+ const [emojis, setEmojis] = useState([]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ fetchEmojis(signal)
+ .then((data) => {
+ setEmojis(data);
+ })
+ .catch((error: unknown) => {
+ if (signal.aborted) return;
+ console.error(error);
+ });
+
+ return () => {
+ controller.abort();
+ };
+ }, []);
+
+ if (error) {
+ return {error}
;
+ }
+
+ if (isLoading) {
+ return Loading articles…
;
+ }
+
+ return (
+ <>
+ {articles.map((article) => {
+ return (
+
+ {article.title}
+
+ {article.blocks[0].body}
+
+
+ {emojis.map((emoji) => {
+ return (
+
+ );
+ })}
+
+ );
+ })}
+ >
+ );
+}
+
+export default ReactionsPageView;
diff --git a/demo-app/src/hooks/useArticles.ts b/demo-app/src/hooks/useArticles.ts
new file mode 100644
index 0000000..c11febe
--- /dev/null
+++ b/demo-app/src/hooks/useArticles.ts
@@ -0,0 +1,45 @@
+import { useCallback, useEffect, useState } from "react";
+import { fetchArticles } from "../lib/strapi-reactions";
+import type { Article } from "../types/articles";
+
+export function useArticles() {
+ const [articles, setArticles] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refetch = useCallback(async (signal: AbortSignal) => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const data = await fetchArticles(signal);
+ setArticles(data);
+ } catch (error: unknown) {
+ if (signal.aborted) return;
+
+ setArticles([]);
+ setError(
+ error instanceof Error ? error.message : "Failed to load articles.",
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ const load = async () => {
+ await refetch(signal);
+ };
+
+ load();
+
+ return () => {
+ controller.abort();
+ };
+ }, [refetch]);
+
+ return { articles, isLoading, error, refetch };
+}
diff --git a/demo-app/src/lib/strapi-comments.ts b/demo-app/src/lib/strapi-comments.ts
new file mode 100644
index 0000000..1d22952
--- /dev/null
+++ b/demo-app/src/lib/strapi-comments.ts
@@ -0,0 +1,83 @@
+import type {
+ AbuseReportPayload,
+ CommentAuthor,
+ StrapiComment,
+} from "../types/comments";
+import { strapiOriginUrl, ARTICLE_CONTENT_RELATION } from "./utils";
+
+export const REPORT_PRESETS: Array = [
+ {
+ reason: "BAD_WORDS",
+ content: "Comment reported for containing bad words",
+ label: "Report: bad words",
+ },
+ {
+ reason: "DISCRIMINATION",
+ content: "Comment reported for being discriminative",
+ label: "Report: discrimination",
+ },
+ {
+ reason: "OTHER",
+ content: "Comment reported for unspecified reason",
+ label: "Report: other",
+ },
+];
+
+export function articleCommentsBaseUrl(documentId: string): string {
+ return `${strapiOriginUrl()}/api/comments/${ARTICLE_CONTENT_RELATION}:${documentId}`;
+}
+
+export async function fetchCommentsFlat(
+ documentId: string,
+): Promise {
+ const url = `${articleCommentsBaseUrl(documentId)}/flat`;
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(`Failed to load comments: ${res.status} ${res.statusText}`);
+ }
+ const json: { data?: StrapiComment[] } = await res.json();
+ return json.data ?? [];
+}
+
+export async function postComment(
+ documentId: string,
+ author: CommentAuthor,
+ content: string,
+): Promise {
+ const res = await fetch(articleCommentsBaseUrl(documentId), {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ author, content }),
+ });
+ if (!res.ok) {
+ const detail = await res.text();
+ throw new Error(
+ `Failed to post comment: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`,
+ );
+ }
+}
+
+export async function reportCommentAbuse(
+ documentId: string,
+ commentId: number,
+ report: AbuseReportPayload,
+): Promise {
+ const url = `${articleCommentsBaseUrl(documentId)}/comment/${commentId}/report-abuse`;
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(report),
+ });
+ if (!res.ok) {
+ const detail = await res.text();
+ throw new Error(
+ `Failed to report comment: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`,
+ );
+ }
+}
diff --git a/demo-app/src/lib/strapi-navigation.ts b/demo-app/src/lib/strapi-navigation.ts
new file mode 100644
index 0000000..6acad05
--- /dev/null
+++ b/demo-app/src/lib/strapi-navigation.ts
@@ -0,0 +1,35 @@
+import type {
+ NavigationItemTree,
+ StrapiNavigationPage,
+} from "../types/navigation";
+import { strapiOriginUrl } from "./utils";
+
+export async function fetchPageByPath(
+ pathname: string,
+ signal: AbortSignal,
+): Promise {
+ const normalized =
+ pathname === "/"
+ ? "/home"
+ : pathname.startsWith("/")
+ ? pathname
+ : `/${pathname}`;
+
+ const url = new URL(`${strapiOriginUrl()}/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/lib/strapi-reactions.ts b/demo-app/src/lib/strapi-reactions.ts
new file mode 100644
index 0000000..10130b7
--- /dev/null
+++ b/demo-app/src/lib/strapi-reactions.ts
@@ -0,0 +1,60 @@
+import { ARTICLE_CONTENT_RELATION, strapiOriginUrl } from "./utils";
+
+export async function fetchArticles(signal: AbortSignal) {
+ const url = new URL(`${strapiOriginUrl()}/api/articles?populate=*`);
+ const res = await fetch(url.toString(), { signal });
+ 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(signal: AbortSignal) {
+ const url = new URL(`${strapiOriginUrl()}/api/reactions/kinds`);
+ const res = await fetch(url.toString(), { signal });
+ 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(
+ `${strapiOriginUrl()}/api/reactions/set/${reactionType}/collection/${ARTICLE_CONTENT_RELATION}/${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,
+ signal: AbortSignal,
+) {
+ const url = new URL(
+ `${strapiOriginUrl()}/api/reactions/list/${reactionType}/collection/${ARTICLE_CONTENT_RELATION}/${articleId}`,
+ );
+ const res = await fetch(url.toString(), { signal });
+ 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/lib/utils.ts b/demo-app/src/lib/utils.ts
new file mode 100644
index 0000000..af842da
--- /dev/null
+++ b/demo-app/src/lib/utils.ts
@@ -0,0 +1,5 @@
+export function strapiOriginUrl() {
+ return import.meta.env.STRAPI_URL ?? "http://localhost:1337";
+}
+
+export const ARTICLE_CONTENT_RELATION = "api::article.article";
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/$.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/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/src/routes/comments.tsx b/demo-app/src/routes/comments.tsx
new file mode 100644
index 0000000..bc1b085
--- /dev/null
+++ b/demo-app/src/routes/comments.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import CommentsPageView from "../components/CommentsPageView";
+
+export const Route = createFileRoute("/comments")({
+ component: CommentsPageView,
+});
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/comments.ts b/demo-app/src/types/comments.ts
new file mode 100644
index 0000000..b194fce
--- /dev/null
+++ b/demo-app/src/types/comments.ts
@@ -0,0 +1,23 @@
+/** Autor komentarza (np. użytkownik generyczny spoza Strapi) */
+export type CommentAuthor = {
+ id: number | string;
+ name: string;
+ email: string;
+};
+
+export type ReportReason = "BAD_WORDS" | "OTHER" | "DISCRIMINATION";
+
+export type AbuseReportPayload = {
+ reason: ReportReason;
+ content: string;
+};
+
+/** Komentarz z endpointu `/flat` pluginu strapi-plugin-comments */
+export type StrapiComment = {
+ id: number;
+ content: string;
+ createdAt: string;
+ author: CommentAuthor;
+ blocked: boolean;
+ threadOf?: number | null;
+};
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;
+};
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
+};
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(),
+ ],
+})