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 ( +
+

{error}

+
+ ); + } + + if (isLoading) { + return ( +
+

Loading articles…

+
+ ); + } + + 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 ( +
void handleSubmit(e)} + style={{ + marginTop: "1.5rem", + display: "grid", + gap: "0.75rem", + maxWidth: 480, + }} + > +

Add a comment

+ + +