├── .all-contributorsrc ├── .env-sample ├── .env.production ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── pomys-.md │ └── zg-o--b--d.md ├── pull_request_template.md ├── semantic.yml ├── wersja.png └── workflows │ ├── codeql-analysis.yml │ ├── pull-request-stats.yml │ ├── test-pr.yml │ ├── update-blogs.yml │ └── update-feeds.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .versionrc ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DUMP.sql ├── LICENSE ├── README.md ├── api-helpers ├── api-hofs.ts ├── articles.ts ├── config.ts ├── contentCreatorFunctions.test.ts ├── contentCreatorFunctions.ts ├── errors.ts ├── external-services │ ├── mailgun.ts │ └── youtube.ts ├── feedFunctions.ts ├── general-feed.ts ├── logger.ts ├── models.ts ├── prisma │ ├── db.ts │ ├── migrations │ │ ├── 20201211172208_initial │ │ │ └── migration.sql │ │ ├── 20201223211356_develop │ │ │ └── migration.sql │ │ ├── 20201226213902_migration │ │ │ └── migration.sql │ │ ├── 20201230152803_add_last_article_published_at │ │ │ └── migration.sql │ │ ├── 20201230195946_auth │ │ │ └── migration.sql │ │ ├── 20210103110839_add_notify │ │ │ └── migration.sql │ │ ├── 20210705155436_remove_auth │ │ │ └── migration.sql │ │ ├── 20210705155617_remove_news │ │ │ └── migration.sql │ │ ├── 20210707153644_members │ │ │ └── migration.sql │ │ ├── 20210707154050_add_email_and_trigger │ │ │ └── migration.sql │ │ ├── 20210708153008_a │ │ │ └── migration.sql │ │ ├── 20210708154300_replace_triggers │ │ │ └── migration.sql │ │ ├── 20211004205041_remove_triggers │ │ │ └── migration.sql │ │ └── migration_lock.toml │ ├── prisma-errors.ts │ ├── prisma-helper.ts │ └── schema.prisma ├── rxjs-utils.ts ├── sitemap.ts └── types.ts ├── app ├── (articles) │ └── [displayStyle] │ │ ├── [page] │ │ └── page.tsx │ │ ├── head.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── (content) │ ├── admin │ │ ├── [blogsType] │ │ │ └── page.tsx │ │ ├── blogs │ │ │ └── [blogId] │ │ │ │ └── page.tsx │ │ ├── head.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── artykuly │ │ └── [slug] │ │ │ ├── head.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── login │ │ ├── head.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── o-serwisie │ │ ├── head.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── regulamin │ │ ├── head.tsx │ │ ├── layout.tsx │ │ ├── page.module.scss │ │ └── page.tsx │ └── zglos-serwis │ │ ├── head.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── head.tsx └── layout.tsx ├── components ├── AddContentCreatorForm │ ├── AddContentCreatorForm.tsx │ └── FormStatus.tsx ├── AlgoliaSearch │ ├── AlgoliaHit.tsx │ ├── AlgoliaHits.tsx │ ├── AlgoliaSearch.tsx │ ├── SearchBox.tsx │ └── useAlgolia.ts ├── Analytics.tsx ├── ArticleDate │ └── ArticleDate.tsx ├── ArticleTile │ ├── ArticleTile.tsx │ └── articleTile.module.scss ├── AuthGuard │ ├── AuthGuard.tsx │ └── useAuth.ts ├── AuthPage │ ├── AuthPage.tsx │ ├── useAuthGuard.ts │ └── useAuthListener.ts ├── BlogsGrid │ └── BlogsGrid.tsx ├── BlogsList │ └── BlogsList.tsx ├── Button │ └── Button.tsx ├── ButtonAsLink │ └── ButtonAsLink.tsx ├── ChangeBlogsList │ └── ChangeBlogsList.tsx ├── Content │ ├── Content.tsx │ ├── ContentNavigation.tsx │ └── ContentTitle.tsx ├── CookiesPopup │ ├── CookiesPopup.tsx │ └── useCookies.ts ├── Footer │ └── Footer.tsx ├── HeadTags.tsx ├── Header │ └── Header.tsx ├── LoadingScreen │ └── LoadingScreen.tsx ├── LogoutButton │ ├── LogoutButton.tsx │ └── useLogout.ts ├── Navigation │ ├── MobileNavbar │ │ ├── MobileNavbar.tsx │ │ └── useMobileNavbar.ts │ ├── NavItem.tsx │ ├── Navigation.tsx │ └── links.ts ├── Pagination │ └── Pagination.tsx ├── Providers │ ├── AuthRedirectProvider.tsx │ └── Providers.tsx ├── SwitchDisplayStyle │ ├── SwitchDisplayStyle.tsx │ └── useChangeDisplayStyle.ts ├── Table │ ├── Table.tsx │ └── table.module.scss └── UpdateBlogSection │ ├── DangerZone.tsx │ └── UpdateBlogForm.tsx ├── constants └── index.ts ├── docker-compose.yml ├── hooks ├── useDidMount.tsx ├── useDisplayPreferences.tsx ├── useLocalStorage.tsx ├── useMutation.tsx └── useQuery.tsx ├── init.sql ├── jest-setup-after.ts ├── jest-utils.ts ├── jest.config.js ├── middleware.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages └── api │ ├── auth │ └── me.ts │ ├── blogs │ ├── [blogId].ts │ └── index.ts │ ├── content-creator.ts │ ├── feed.ts │ ├── index.ts │ ├── sitemap.ts │ ├── update-algolia.ts │ ├── update-blogs.ts │ └── update-feed.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-icon-precomposed.png ├── apple-icon.png ├── background.svg ├── bg-tiles.svg ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── icons │ ├── arrow-left2.svg │ ├── arrow-right2.svg │ ├── checkmark.svg │ ├── cross.svg │ ├── discord.svg │ ├── exit.svg │ ├── facebook2.svg │ ├── github.svg │ ├── home.svg │ ├── link.svg │ ├── menu.svg │ ├── plus.svg │ ├── question.svg │ ├── rss.svg │ ├── search.svg │ └── spinner3.svg ├── logo.png ├── logo.svg ├── logo_og.png ├── manifest.json ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png ├── ms-icon-70x70.png ├── new-logo.svg ├── powered-by-vercel.svg └── robots.txt ├── stats-config.js ├── styles ├── global.scss └── tailwind.css ├── supabase ├── .gitignore ├── config.toml └── seed.sql ├── tailwind.config.js ├── tsconfig.json ├── types.ts ├── utils ├── api │ ├── addContentCreator.ts │ ├── deleteBlog.ts │ ├── getBlog.ts │ ├── getBlogs.ts │ ├── getMe.ts │ ├── resetBlog.ts │ └── updateBlog.ts ├── array-utils.ts ├── blog-schema-api.ts ├── creator-utils.ts ├── date-utils.ts ├── excerpt-utils.ts ├── fetchAdminBlogsList.tsx ├── fetchArticleBySlug.ts ├── fetchArticlesForList.ts ├── fetchBlogsForGrid.ts ├── fetcher.ts ├── link-utils.ts ├── pageValidGuard.ts └── sanitize-utils.ts └── vercel.json /.env-sample: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgres://postgres:postgres@localhost:54322/postgres" 2 | DATABASE_POOL_URL="postgres://postgres:postgres@localhost:54322/postgres" 3 | NEXT_PUBLIC_SUPABASE_URL="http://localhost:54321" 4 | NEXT_PUBLIC_SUPABASE_ANON_KEY="" 5 | 6 | YOUTUBE_API_KEY="" 7 | CLOUDINARY_URL="" 8 | 9 | FEED_UPDATE_SECRET="supertajne" 10 | 11 | CAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000 12 | NEXT_PUBLIC_CAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001 13 | NEXT_PUBLIC_URL="localhost:3000" 14 | 15 | MAILGUN_DOMAIN="domena1337.mailgun.org" 16 | MAILGUN_API_KEY="SekretnyKluczDoAPI" 17 | MAILGUN_FROM="foo@bar.com" 18 | 19 | GITHUB_ID="supertajne" 20 | GITHUB_SECRET="bardzotajne" 21 | SECRET="anothersecret" 22 | JWT_SECRET="hmmmmmm" 23 | 24 | ALGOLIA_API_SECRET="" 25 | NEXT_PUBLIC_ALGOLIA_APP_ID="" 26 | NEXT_PUBLIC_ALGOLIA_INDEX_NAME="" 27 | NEXT_PUBLIC_ALGOLIA_API_KEY="" 28 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_VERSION=${VERCEL_ENV}_${VERCEL_GIT_COMMIT_REF}_${VERCEL_GIT_COMMIT_SHA} 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | .next 3 | out 4 | previous-size-snapshot.json 5 | current-size-snapshot.json 6 | size-snapshot.json 7 | analyze.next 8 | .deployment-url 9 | .basebranch 10 | package-lock.json 11 | spmdb/ 12 | spmlogs/ 13 | newrelic.js 14 | 15 | 16 | node_modules 17 | .tmp 18 | .idea 19 | .DS_Store 20 | .version 21 | dist 22 | .history 23 | 24 | # Logs 25 | logs 26 | *.log 27 | npm-debug.log* 28 | 29 | # Runtime data 30 | pids 31 | *.pid 32 | *.seed 33 | *.pid.lock 34 | 35 | junit 36 | test-results.xml 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # Bower dependency directory (https://bower.io/) 51 | bower_components 52 | 53 | # node-waf configuration 54 | .lock-wscript 55 | 56 | # Compiled binary addons (http://nodejs.org/api/addons.html) 57 | build/Release 58 | 59 | # Dependency directories 60 | node_modules/ 61 | jspm_packages/ 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.dev 79 | apps/www/.env.staging 80 | apps/www/.env.production 81 | 82 | *.tsbuildinfo 83 | 84 | # cypress 85 | 86 | apps/types/types.ts 87 | 88 | npm-debug.log 89 | .next 90 | out 91 | previous-size-snapshot.json 92 | current-size-snapshot.json 93 | size-snapshot.json 94 | analyze.next 95 | .deployment-url 96 | .basebranch 97 | package-lock.json 98 | spmdb/ 99 | spmlogs/ 100 | newrelic.js 101 | 102 | 103 | node_modules 104 | .tmp 105 | .idea 106 | .DS_Store 107 | .version 108 | dist 109 | .history 110 | 111 | # Logs 112 | logs 113 | *.log 114 | npm-debug.log* 115 | 116 | # Runtime data 117 | pids 118 | *.pid 119 | *.seed 120 | *.pid.lock 121 | 122 | junit 123 | test-results.xml 124 | 125 | # Directory for instrumented libs generated by jscoverage/JSCover 126 | lib-cov 127 | 128 | # Coverage directory used by tools like istanbul 129 | coverage 130 | 131 | # nyc test coverage 132 | .nyc_output 133 | 134 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 135 | .grunt 136 | 137 | # Bower dependency directory (https://bower.io/) 138 | bower_components 139 | 140 | # node-waf configuration 141 | .lock-wscript 142 | 143 | # Compiled binary addons (http://nodejs.org/api/addons.html) 144 | build/Release 145 | 146 | # Dependency directories 147 | node_modules/ 148 | jspm_packages/ 149 | 150 | # Optional npm cache directory 151 | .npm 152 | 153 | # Optional eslint cache 154 | .eslintcache 155 | 156 | # Optional REPL history 157 | .node_repl_history 158 | 159 | # Output of 'npm pack' 160 | *.tgz 161 | 162 | 163 | # dotenv environment variables file 164 | .env 165 | .env.dev 166 | apps/www/.env.staging 167 | apps/www/.env.production 168 | 169 | *.tsbuildinfo 170 | 171 | cypress 172 | test 173 | *.js 174 | 175 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "sourceType": "module", 6 | "project": "tsconfig.json" 7 | }, 8 | "ignorePatterns": "next.config.js", 9 | "plugins": ["jsx-a11y", "css-modules"], 10 | "extends": [ 11 | "plugin:@typeofweb/recommended", 12 | "next/core-web-vitals", 13 | "plugin:jsx-a11y/recommended", 14 | "plugin:css-modules/recommended" 15 | ], 16 | "rules": { 17 | "react-hooks/exhaustive-deps": ["error"], 18 | "react/display-name": "error", 19 | "react/jsx-curly-brace-presence": ["error", "never"], 20 | "jsx-a11y/anchor-is-valid": "off", 21 | "jsx-a11y/no-onchange": "off", 22 | "jsx-a11y/no-redundant-roles": "off", 23 | "import/no-extraneous-dependencies": [ 24 | "error", 25 | { 26 | "devDependencies": [ 27 | "**/*.test.tsx", 28 | "**/*.spec.tsx", 29 | "**/*.test.ts", 30 | "**/*.spec.ts", 31 | "**/cypress/**/*.*", 32 | "**/jest-setup.ts", 33 | "**/jest-utils.ts", 34 | "**/next-env.d.ts", 35 | "**/global.d.ts", 36 | "jest-setup-after.ts" 37 | ] 38 | } 39 | ] 40 | }, 41 | "overrides": [ 42 | { 43 | "files": ["**/*.test.tsx", "**/*.spec.tsx", "**/*.test.ts", "**/*.spec.ts"], 44 | "rules": { 45 | "@typescript-eslint/no-unsafe-assignment": "off", 46 | "@typescript-eslint/restrict-plus-operands": "off" 47 | } 48 | }, 49 | { 50 | "files": [ 51 | "app/**/page.tsx", 52 | "app/**/layout.tsx", 53 | "app/**/loading.tsx", 54 | "app/**/head.tsx", 55 | "app/**/template.tsx", 56 | "pages/api/**/*.ts" 57 | ], 58 | "rules": { 59 | "import/no-default-export": "off" 60 | } 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [typeofweb] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: typeofweb 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pomys-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💡 Pomysł 3 | about: Zasugeruj pomysł lub nową funkcję do aplikacji 4 | title: '[Pomysł]' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## Czy Twój pomysł jest odpowiedzią na jakiś problem? 10 | 11 | Krótko opisz problem, który chciałbyś/chciałabyś rozwiązać. Przykładowo: „Zawsze frustruje mnie, gdy muszę kliknąć…” 12 | 13 | ## Opisz swoje rozwiązanie 14 | 15 | Opisz, jak wygląda Twój pomysł na rozwiązanie tego problemu. 16 | 17 | ## Jakie są alternatywy? 18 | 19 | Napisz, czy są jakieś alternatywy. Nieważne, czy są trudniejsze / wymagają więcej zmian / wydają się nie mieć sensu. Być może to, co zasugerujesz akurat będzie nam po drodze ze zmianami, które i tak planowaliśmy wdrożyć. 20 | 21 | ## Dodatkowe informacje 22 | 23 | … 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/zg-o--b--d.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 😡 Zgłoś błąd 3 | about: 'Pomóż nam udoskonalić Polski Frontend 🙂' 4 | title: '[BUG]' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Opis błędu 10 | 11 | W skrócie opisz, na czym polega problem. Jedno-dwa zdania. 12 | 13 | ## Jak powtórzyć problem 14 | 15 | Krok po kroku napisz, co należy zrobić, aby ten problem wystąpił: 16 | 17 | 1. Idź na stronę … 18 | 2. Kliknij na … 19 | 3. Przeskroluj do … 20 | 4. BŁĄD! 21 | 22 | ## Oczekiwane działanie 23 | 24 | Opisz, czego się spodziewałeś/aś zamiast tego, co się wydarzyło w aplikacji. 25 | 26 | ## Screenshoty 27 | 28 | Jeśli ma to sens, to dołącz zrzuty ekranu lub nawet nagranie. 29 | 30 | ## Twój system (uzupełnij poniżej):\*\* 31 | 32 | - System operacyjny: (np. iOS, Windows 10, MacOS Catalina etc.) 33 | - Przeglądarka (np. Chrome 74, Safari 13 etc.) 34 | - Wersja aplikacji – skopiuj ze stopki na stronie, o tutaj: 35 | https://github.com/typeofweb/polskifrontend/blob/develop/.github/wersja.png 36 | 37 | ## Dodatkowe informacje 38 | 39 | … 40 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Dzięki za pracę nad Polski Frontend! 2 | 3 | Pamiętaj, aby PR-owi nadać tytuł w języku polskim i zgodny z _Conventional Commits_. Przykłady znajdziesz tutaj: https://highlab.pl/conventional-commits/ 4 | 5 | Dzięki! 6 | 7 | 8 | 9 | Resolves #123123123 10 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Always validate the PR title, and ignore the commits 2 | titleOnly: true 3 | -------------------------------------------------------------------------------- /.github/wersja.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/.github/wersja.png -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: [develop, main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [develop] 20 | schedule: 21 | - cron: '26 1 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: ['javascript'] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-stats.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | types: [opened, synchronize] 4 | issue_comment: 5 | types: [created] 6 | 7 | name: Generate Pull Request Next Stats 8 | 9 | jobs: 10 | stats: 11 | name: PR Stats 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: khan/pull-request-comment-trigger@v1.1.0 15 | id: check 16 | with: 17 | reaction: eyes 18 | trigger: 'TypeofWebBot stats' 19 | env: 20 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 21 | 22 | - name: Is this a comment 23 | if: steps.check.outputs.triggered == 'true' 24 | uses: xt0rted/pull-request-comment-branch@v1.3.0 25 | id: comment-branch 26 | with: 27 | repo_token: ${{secrets.GITHUB_TOKEN}} 28 | 29 | - name: Checkout PR 30 | if: steps.check.outputs.triggered == 'true' 31 | uses: actions/checkout@v2 32 | with: 33 | path: pr-branch 34 | ref: ${{steps.comment-branch.outputs.head_ref}} 35 | 36 | - name: Checkout base 37 | if: steps.check.outputs.triggered == 'true' 38 | uses: actions/checkout@v2 39 | with: 40 | path: base-branch 41 | ref: develop 42 | 43 | - name: Compare sizes 44 | if: steps.check.outputs.triggered == 'true' 45 | uses: typeofweb/typeofweb-next-stats-action@pkg 46 | with: 47 | pr_directory_name: pr-branch 48 | base_directory_name: base-branch 49 | env: 50 | DATABASE_URL: ${{secrets.DATABASE_URL}} 51 | DATABASE_POOL_URL: ${{secrets.DATABASE_URL}} 52 | GITHUB_TOKEN: ${{secrets.TYPEOFWEB_BOT_GITHUB_TOKEN}} 53 | -------------------------------------------------------------------------------- /.github/workflows/test-pr.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: 4 | pull_request: 5 | branches: [develop, main] 6 | 7 | jobs: 8 | tests: 9 | if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 100 16 | 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version-file: '.nvmrc' 20 | 21 | - uses: pnpm/action-setup@v2 22 | with: 23 | run_install: false 24 | 25 | - name: Get pnpm store directory 26 | id: pnpm-cache 27 | shell: bash 28 | run: | 29 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 30 | 31 | - uses: actions/cache@v3 32 | name: Setup pnpm cache 33 | with: 34 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 35 | key: node-cache-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 36 | restore-keys: | 37 | node-cache-${{ runner.os }}-pnpm- 38 | 39 | - name: Install dependencies 40 | run: pnpm --version && pnpm install --frozen-lockfile 41 | 42 | - name: Copy .env 43 | run: cp .env-sample .env 44 | 45 | - name: Run tests 46 | run: | 47 | pnpm tsc 48 | pnpm eslint 49 | pnpm test:ci 50 | -------------------------------------------------------------------------------- /.github/workflows/update-blogs.yml: -------------------------------------------------------------------------------- 1 | name: Update Blogs 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' # codziennie o północy 5 | jobs: 6 | cron: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Update blogs 10 | env: 11 | FEED_UPDATE_SECRET: ${{ secrets.FEED_UPDATE_SECRET }} 12 | run: | 13 | curl 'https://polskifrontend.vercel.app/api/update-blogs' \ 14 | -X 'PATCH' \ 15 | -H 'content-type: text/plain;charset=UTF-8' \ 16 | --data-binary '{"secret":"'$FEED_UPDATE_SECRET'"}' \ 17 | --compressed \ 18 | --fail 19 | -------------------------------------------------------------------------------- /.github/workflows/update-feeds.yml: -------------------------------------------------------------------------------- 1 | name: Update Feeds 2 | on: 3 | schedule: 4 | - cron: '*/15 * * * *' # co 15 minut 5 | jobs: 6 | cron: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Update feed 10 | env: 11 | FEED_UPDATE_SECRET: ${{ secrets.FEED_UPDATE_SECRET }} 12 | run: | 13 | curl 'https://polskifrontend.pl/api/update-feed' \ 14 | -X 'PATCH' \ 15 | -H 'content-type: application/json;charset=UTF-8' \ 16 | --data-binary '{"secret":"'$FEED_UPDATE_SECRET'"}' \ 17 | --compressed \ 18 | --fail 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .env.dev 4 | .eslintcache 5 | .grunt 6 | .history 7 | .idea/ 8 | .lock-wscript 9 | .node_repl_history 10 | .npm 11 | .nyc_output 12 | .pnp.js 13 | .tmp 14 | .version 15 | *.log 16 | *.pid 17 | *.pid.lock 18 | *.seed 19 | *.tgz 20 | *.tsbuildinfo 21 | /.next/ 22 | /.pnp 23 | /build 24 | /coverage 25 | /node_modules 26 | /out/ 27 | api-helpers/migrate-mongodb.ts 28 | bower_components 29 | build/Release 30 | coverage 31 | dist 32 | jspm_packages/ 33 | junit 34 | lib-cov 35 | logs 36 | node_modules 37 | npm-debug.log* 38 | package-lock.json 39 | pids 40 | test-results.xml 41 | 42 | key 43 | key.pub 44 | 45 | .vercel 46 | 47 | # Supabase 48 | .supabase 49 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-tailwindcss"] 7 | } 8 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | {"type":"feat","section":"Nowe funkcje"}, 4 | {"type":"fix","section":"Naprawione błędy"}, 5 | {"type":"docs","section":"Dokumentacja"}, 6 | {"type":"chore","section":"Inne zmiany"} 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "prisma.prisma", 6 | "redhat.vscode-yaml" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib", 5 | "eslint.packageManager": "pnpm", 6 | "eslint.run": "onSave", 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll": true 9 | }, 10 | "typescript.enablePromptUseWorkspaceTsdk": true 11 | } 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Przymierze Współpracy — Kodeks Postępowania 2 | 3 | ## Nasza Przysięga 4 | 5 | W celu wspierania otwartego i przyjaznego środowiska, my — jako współpracownicy i 6 | opiekunowie projektu — zobowiązujemy się dopilnować, aby uczestnictwo w naszym projekcie 7 | oraz przynależność do jego społeczności były pozbawione nękania bez względu na budowę 8 | ciała, doświadczenie, kolor skóry, narodowość, niepełnosprawność, orientację i 9 | identyfikację seksualną, pochodzenie etniczne, religię, wiek czy wygląd zewnętrzny. 10 | 11 | ## Nasze Standardy 12 | 13 | Wśród przykładów zachowania, które przyczyniają się do tworzenia pozytywnego 14 | środowiska, są: 15 | 16 | - Używanie języka, który jest przyjazny i nie wyklucza innych uczestników projektu, 17 | - Okazywanie szacunku dla różnych punktów widzenia i osobistych doświadczeń, 18 | - Przyjmowanie konstruktywnej krytyki z wdzięcznością, 19 | - Skupianie się na tym, co dobre dla społeczności projektu, 20 | - Wykazywanie empatii wobec innych członków społeczności. 21 | 22 | Wśród przykładów zachowania, którego nie będziemy akceptować, są: 23 | 24 | - Używanie języka i grafik o podtekście seksualnym, okazywanie 25 | niepożądanego zainteresowania seksualnego, a także zaloty, 26 | - Trollowanie, obraźliwe bądź urągające komentarze oraz ataki osobiste bądź 27 | polityczne, 28 | - Nękanie, zarówno na forum publicznym jak i prywatnym, 29 | - Publikowanie danych osobistych innych osób — takich jak 30 | adres fizyczny czy elektroniczny — bez ich wyraźnej zgody, 31 | - Inne zachowania, które mogłyby zostać uznane za nieodpowiednie w kontekście 32 | profesjonalnym. 33 | 34 | ## Nasza Odpowiedzialność 35 | 36 | Opiekunowie projektu są odpowiedzialni za objaśnianie standardów akceptowalnego 37 | zachowania i reagowanie na wszelkie przypadki nieodpowiedniego zachowania, w sposób 38 | sprawiedliwy i adekwatny do sytuacji. 39 | 40 | Opiekunowie projektu mają prawo do i są odpowiedzialni za usuwanie, edycję oraz odrzucanie 41 | komentarzy, zmian w kodzie (ang. _commit_), edycji Wiki, oraz innych treści, które 42 | łamią niniejszy Kodeks Postępowania, oraz mają prawo tymczasowo lub na stałe zablokować 43 | dostęp do projektu każdemu ze współpracowników: w przypadku gróźb bądź obraźliwych 44 | komentarzy pod adresem innych współpracowników, a także innych zachowań, które uznają 45 | za nieodpowiednie lub szkodliwe. 46 | 47 | ## Zakres działania 48 | 49 | Ten Kodeks Postępowania ma zastosowanie wewnątrz projektu, a w przypadku 50 | reprezentowania projektu lub jego społeczności przez współpracownika — także na zewnątrz 51 | projektu. Do przykładów reprezentowania projektu zalicza się używanie oficjalnego adresu 52 | e-mail projektu, publikowanie na oficjalnym koncie w mediach społecznościowych lub branie 53 | udziału w wydarzeniu (zarówno w Internecie, jak i poza nim) jako oficjalny przedstawiciel 54 | projektu. Dokładny zakres pojęcia reprezentowania projektu może zostać objaśniony 55 | w bardziej szczegółowy sposób przez opiekunów projektu. 56 | 57 | ## Egzekwowanie 58 | 59 | Przypadki nękania, gróźb oraz innych form nieakceptowalnego zachowania mogą 60 | być zgłaszane do zespołu projektu pod adresem hi@typeofweb.com. Wszystkie 61 | skargi zostaną odpowiednio rozpatrzone oraz podjęte zostaną działania uznane za 62 | konieczne i odpowiednie do sytuacji. Zespół projektu ma obowiązek zachowania tożsamości 63 | osoby zgłaszającej incydent w tajemnicy. Bardziej szczegółowe zasady egzekwowania Kodeksu 64 | Postępowania mogą zostać udostępnione w osobnym dokumencie. 65 | 66 | Opiekunowie projektu, którzy nie respektują i nie egzekwują w dobrej wierze Kodeksu Postępowania, 67 | mogą zostać na stałe lub tymczasowo ukarani, zgodnie z decyzją innych liderów projektu. 68 | 69 | ## Autorstwo 70 | 71 | Ten Kodeks Postępowania został zaadaptowany z [Contributor Covenant][homepage] w 72 | wersji 1.4, dostępnej pod adresem https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 73 | (w języku angielskim) oraz https://www.contributor-covenant.org/pl/version/1/4/code-of-conduct.html 74 | (w języku polskim). 75 | 76 | [homepage]: https://www.contributor-covenant.org/ 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Polski Frontend - pomoc mile widziana! 2 | 3 | ## Issues 4 | 5 | Zachęcamy do otwierania Issues ze znalezionymi bugami i z sugestiami tego, co można poprawić. 6 | 7 | ## Stack technologiczny 8 | 9 | - TypeScript 10 | - Next.js (SSG, ISG) 11 | - PostgreSQL (Supabase) 12 | - Prisma 13 | - Docker 14 | - Vercel 15 | 16 | ## Wymagania 17 | 18 | - `pnpm` w wersji co najmniej 7.17.0 19 | - Docker i polecenie `docker compose` 20 | 21 | ## Praca lokalna 22 | 23 | Przed przystąpieniem do pracy należy skopiować plik `.env-sample` do `.env`. 24 | Projekt uruchamiamy jednym poleceniem `pnpm dev`. Spowoduje ono instalację wszystkich potrzebnych zależności, uruchomienie kontenera w Dockerze oraz naszej aplikacji. 25 | Po chwili powinna być gotowa pod adresem http://localhost:3000/ 26 | 27 | ### Uwaga co do Supabase 28 | 29 | Przy pierwszym uruchomieniu, możesz zobaczyć komunikat mówiąc o braku Supabase. W takim przypadku uruchom polecenie `pnpm supabase init`. Następnie uruchom `pnpm supabase start`. 30 | 31 | Użyj komendy `pnpm supabase status`, aby uzyskać dostęp do wartości, które następnie musisz skopiować do swojego pliku `.env`. Odpowiednio: 32 | 33 | - `API URL` jako `NEXT_PUBLIC_SUPABASE_URL` 34 | - `anon key` jako `NEXT_PUBLIC_SUPABASE_ANON_KEY` 35 | - `DB URL` jako `DATABASE_URL` oraz `DATABASE_POOL_URL` 36 | 37 | Następnie odpal `pnpm dev`. 38 | 39 | ## Przywracanie danych z DUMP.sql 40 | 41 | Gdy wszystko będzie już gotowe, to w drugim oknie terminala należy wpisać polecenie: 42 | 43 | ```bash 44 | docker exec -i supabase_db_polskifrontend psql postgres -U postgres < DUMP.sql 45 | ``` 46 | 47 | Spowoduje to załadowanie danych do bazy z pliku DUMP.sql. 48 | 49 | ## Konwencje 50 | 51 | Większość konwencji w projekcie jest wymuszona przez `prettier` i/lub `eslint` oraz TypeScripta. 52 | 53 | Ważna uwaga odnośnie tworzenia Pull Requestów: korzystamy z "Conventional Commits", aby łatwiej nam było generować CHANGELOG. **Nazwy commitów mogą być dowolne**, ale **tytuł samego PR-a musi spełniać określone wymagania**! Więcej informacji oraz przykłady można znaleźć tutaj: https://highlab.pl/conventional-commits/ 54 | 55 | **Tytuły i opisy PR-ów piszemy w języku polskim!** 56 | -------------------------------------------------------------------------------- /api-helpers/config.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './logger'; 2 | 3 | type Nil = T | null | undefined; 4 | 5 | type NameToType = { 6 | readonly DATABASE_URL: string; 7 | readonly DATABASE_POOL_URL: string; 8 | readonly ENV: 'production' | 'staging' | 'development' | 'test'; 9 | readonly FEED_UPDATE_SECRET: string; 10 | readonly NODE_ENV: 'production' | 'development'; 11 | readonly YOUTUBE_API_KEY: string; 12 | readonly CAPTCHA_SECRET_KEY: string; 13 | readonly ALGOLIA_API_SECRET: string; 14 | 15 | readonly NEXT_PUBLIC_URL: string; 16 | readonly NEXT_PUBLIC_SUPABASE_URL: string; 17 | readonly NEXT_PUBLIC_SUPABASE_ANON_KEY: string; 18 | readonly NEXT_PUBLIC_ALGOLIA_APP_ID: string; 19 | readonly NEXT_PUBLIC_ALGOLIA_API_KEY: string; 20 | readonly NEXT_PUBLIC_ALGOLIA_INDEX_NAME: string; 21 | readonly NEXT_PUBLIC_CAPTCHA_SITE_KEY: string; 22 | }; 23 | 24 | function getConfigForName(name: T): Nil; 25 | function getConfigForName(name: keyof NameToType): Nil { 26 | switch (name) { 27 | case 'NODE_ENV': 28 | return process.env.NODE_ENV || 'development'; 29 | case 'ENV': 30 | return process.env.ENV || 'development'; 31 | case 'DATABASE_POOL_URL': 32 | return process.env.DATABASE_POOL_URL || getConfigForName('DATABASE_URL'); 33 | 34 | case 'DATABASE_URL': 35 | return process.env.DATABASE_URL; 36 | case 'FEED_UPDATE_SECRET': 37 | return process.env.FEED_UPDATE_SECRET; 38 | case 'YOUTUBE_API_KEY': 39 | return process.env.YOUTUBE_API_KEY; 40 | case 'CAPTCHA_SECRET_KEY': 41 | return process.env.CAPTCHA_SECRET_KEY; 42 | case 'ALGOLIA_API_SECRET': 43 | return process.env.ALGOLIA_API_SECRET; 44 | 45 | case 'NEXT_PUBLIC_URL': 46 | return process.env.NEXT_PUBLIC_URL; 47 | case 'NEXT_PUBLIC_SUPABASE_URL': 48 | return process.env.NEXT_PUBLIC_SUPABASE_URL; 49 | case 'NEXT_PUBLIC_SUPABASE_ANON_KEY': 50 | return process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; 51 | case 'NEXT_PUBLIC_ALGOLIA_APP_ID': 52 | return process.env.NEXT_PUBLIC_ALGOLIA_APP_ID; 53 | case 'NEXT_PUBLIC_ALGOLIA_API_KEY': 54 | return process.env.NEXT_PUBLIC_ALGOLIA_API_KEY; 55 | case 'NEXT_PUBLIC_ALGOLIA_INDEX_NAME': 56 | return process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME; 57 | case 'NEXT_PUBLIC_CAPTCHA_SITE_KEY': 58 | return process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY; 59 | default: 60 | return undefined; 61 | } 62 | } 63 | 64 | export function getConfig(name: T): NameToType[T]; 65 | export function getConfig(name: keyof NameToType): NameToType[keyof NameToType] { 66 | const val = getConfigForName(name); 67 | 68 | if (val == null) { 69 | throw new Error(`Cannot find environmental variable: ${name}`); 70 | } 71 | 72 | if (!val) { 73 | logger.warn(`Environmental variable ${name} is falsy: ${val === '' ? '(empty string)' : val}.`); 74 | } 75 | 76 | return val; 77 | } 78 | 79 | export const isProd = () => getConfig('ENV') === 'production'; 80 | export const isStaging = () => getConfig('ENV') === 'staging'; 81 | -------------------------------------------------------------------------------- /api-helpers/errors.ts: -------------------------------------------------------------------------------- 1 | export class HTTPNotFound extends Error { 2 | readonly statusCode = 404; 3 | } 4 | -------------------------------------------------------------------------------- /api-helpers/external-services/mailgun.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@prisma/client'; 2 | import Mailgun from 'mailgun-js'; 3 | 4 | import { getConfig } from '../config'; 5 | 6 | import type { Blog, PrismaClient } from '@prisma/client'; 7 | 8 | const sendEmail = ( 9 | recepients: readonly string[], 10 | subject: string, 11 | text: string, 12 | replyTo?: string, 13 | ) => { 14 | if (!process.env.MAILGUN_API_KEY || !process.env.MAILGUN_DOMAIN || !process.env.MAILGUN_FROM) { 15 | throw new Error(`Missing env MAILGUN_API_KEY, MAILGUN_DOMAIN or MAILGUN_FROM`); 16 | } 17 | 18 | const mg = Mailgun({ 19 | apiKey: process.env.MAILGUN_API_KEY, 20 | domain: process.env.MAILGUN_DOMAIN, 21 | host: 'api.eu.mailgun.net', 22 | }); 23 | 24 | return mg.messages().send({ 25 | from: process.env.MAILGUN_FROM, 26 | to: recepients.join(', '), 27 | subject, 28 | text, 29 | 'h:Reply-To': replyTo, 30 | }); 31 | }; 32 | 33 | const getNewCreatorEmail = (blog: { 34 | readonly id: string; 35 | readonly name: string; 36 | readonly href: string; 37 | readonly rss: string; 38 | readonly creatorEmail: string; 39 | }) => { 40 | return ` 41 | Nowy wniosek o dodanie serwisu! 42 | 43 | Dane: 44 | ${Object.entries(blog) 45 | .map(([key, value]) => `${key}: ${value.trim()}`) 46 | .join('\n') 47 | .trim()} 48 | 49 | https://${getConfig('NEXT_PUBLIC_URL')}/admin/blogs/${blog.id} 50 | `; 51 | }; 52 | 53 | export const sendNewCreatorNotification = async ( 54 | { id, name, href, rss, creatorEmail }: Blog, 55 | prisma: PrismaClient, 56 | ) => { 57 | const admins = await prisma.member.findMany({ 58 | where: { 59 | role: UserRole.ADMIN, 60 | }, 61 | select: { 62 | email: true, 63 | }, 64 | }); 65 | 66 | await sendEmail( 67 | admins.map((a) => a.email), 68 | `Polski Frontend | Nowy serwis | ${name}`, 69 | getNewCreatorEmail({ id, name, href, rss, creatorEmail: creatorEmail || '' }), 70 | creatorEmail || undefined, 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /api-helpers/external-services/youtube.ts: -------------------------------------------------------------------------------- 1 | import * as Googleapis from 'googleapis'; 2 | 3 | import { getConfig } from '../config'; 4 | 5 | const DEFAULT_AVATAR = 'https://www.youtube.com/s/desktop/d743f786/img/favicon_48.png'; 6 | 7 | export const getYouTubeChannelFavicon = async ({ 8 | channelId, 9 | username, 10 | }: { 11 | readonly channelId?: string; 12 | readonly username?: string; 13 | }) => { 14 | if (!channelId && !username) { 15 | return DEFAULT_AVATAR; 16 | } 17 | 18 | const yt = Googleapis.google.youtube('v3'); 19 | 20 | const where = channelId ? { id: [channelId] } : { forUsername: username }; 21 | 22 | const result = await yt.channels.list({ 23 | ...where, 24 | key: getConfig('YOUTUBE_API_KEY'), 25 | part: ['snippet'], 26 | }); 27 | 28 | return result.data.items?.[0].snippet?.thumbnails?.default?.url || DEFAULT_AVATAR; 29 | }; 30 | -------------------------------------------------------------------------------- /api-helpers/general-feed.ts: -------------------------------------------------------------------------------- 1 | import { Feed } from 'feed'; 2 | 3 | import { getConfig } from './config'; 4 | 5 | import type { PrismaClient } from '@prisma/client'; 6 | 7 | export const DEFAULT_ARTICLES = 30; 8 | 9 | export async function getGeneralFeed(prisma: PrismaClient) { 10 | const publicUrl = `https://${getConfig('NEXT_PUBLIC_URL')}`; 11 | 12 | const articles = await prisma.article.findMany({ 13 | where: { blog: { isPublic: true } }, 14 | take: DEFAULT_ARTICLES, 15 | orderBy: { 16 | updatedAt: 'desc', 17 | }, 18 | select: { 19 | title: true, 20 | description: true, 21 | href: true, 22 | publishedAt: true, 23 | updatedAt: true, 24 | blog: { 25 | select: { 26 | name: true, 27 | href: true, 28 | }, 29 | }, 30 | }, 31 | }); 32 | 33 | const feed = new Feed({ 34 | title: 'Polski Frontend', 35 | description: 36 | 'Ogólny RSS Polskiego Frontendu - serwisu skupiającego polskie strony, blogi i serwisy na temat frontendu!', 37 | id: publicUrl, 38 | link: publicUrl, 39 | language: 'pl', 40 | image: `${publicUrl}/logo.png`, 41 | favicon: `${publicUrl}/favicon.ico`, 42 | copyright: 'Copyright@2020 – Type of Web', 43 | updated: articles[0].updatedAt, 44 | generator: 'Polski Frontend', 45 | feedLinks: { 46 | rss: `${publicUrl}/feed`, 47 | }, 48 | author: { 49 | name: 'Polski Frontend', 50 | link: publicUrl, 51 | }, 52 | }); 53 | 54 | articles.forEach((article) => { 55 | feed.addItem({ 56 | title: article.title, 57 | description: article.description || undefined, 58 | author: [{ name: article.blog.name, link: article.blog.href }], 59 | link: article.href, 60 | date: article.publishedAt, 61 | id: article.href, 62 | }); 63 | }); 64 | 65 | return feed.rss2(); 66 | } 67 | -------------------------------------------------------------------------------- /api-helpers/logger.ts: -------------------------------------------------------------------------------- 1 | import Pino from 'pino'; 2 | 3 | export const logger = Pino({ 4 | level: 'trace', 5 | ...(process.env.NODE_ENV !== 'production' && { 6 | transport: { 7 | target: 'pino-pretty', 8 | options: { colorize: true, translateTime: true }, 9 | }, 10 | }), 11 | }); 12 | -------------------------------------------------------------------------------- /api-helpers/models.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable -- can't add readonly here */ 2 | import * as Prisma from '@prisma/client'; 3 | 4 | type PrismaDelegates = Pick>; 5 | type Awaited = T extends Promise ? R : never; 6 | 7 | export type Models = { 8 | readonly [K in keyof PrismaDelegates]: NonNullable< 9 | Awaited> 10 | >; 11 | }; 12 | 13 | // https://stackoverflow.com/questions/49579094/typescript-conditional-types-filter-out-readonly-properties-pick-only-requir 14 | type IfEquals = (() => T extends X ? 1 : 2) extends () => T extends Y 15 | ? 1 16 | : 2 17 | ? A 18 | : B; 19 | type ReadonlyKeys = { 20 | [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>; 21 | }[keyof T]; 22 | 23 | type EnumsKeys = { 24 | readonly [K in keyof typeof Prisma]: (typeof Prisma)[K] extends object 25 | ? (typeof Prisma)[K] extends Function 26 | ? never 27 | : K 28 | : never; 29 | }[keyof typeof Prisma]; 30 | 31 | export type Enums = Pick; 32 | export const Enums = Prisma as Enums; 33 | -------------------------------------------------------------------------------- /api-helpers/prisma/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | import { getConfig } from '../config'; 4 | 5 | // https://www.prisma.io/docs/support/help-articles/nextjs-prisma-client-dev-practices 6 | declare global { 7 | // allow global `var` declarations 8 | // eslint-disable-next-line no-var -- global var required 9 | var prisma: PrismaClient | undefined; 10 | // eslint-disable-next-line no-var -- global var required 11 | var prismaOpenConnections: number; 12 | } 13 | global.prismaOpenConnections = 0; 14 | 15 | export const openConnection = () => { 16 | if (!global.prisma) { 17 | // use pgbouncer 18 | global.prisma = new PrismaClient({ 19 | datasources: { 20 | db: { 21 | url: getConfig('DATABASE_POOL_URL'), 22 | }, 23 | }, 24 | }); 25 | } 26 | 27 | ++global.prismaOpenConnections; 28 | return global.prisma; 29 | }; 30 | 31 | export const closeConnection = () => { 32 | --global.prismaOpenConnections; 33 | if (global.prismaOpenConnections === 0) { 34 | return global.prisma?.$disconnect(); 35 | } 36 | return undefined; 37 | }; 38 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20201211172208_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "public"."UserRole" AS ENUM ('USER', 'ADMIN'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "User" ( 6 | "id" TEXT NOT NULL, 7 | "name" TEXT, 8 | "email" TEXT NOT NULL, 9 | "password" TEXT NOT NULL, 10 | "role" "UserRole" NOT NULL DEFAULT E'USER', 11 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | 14 | PRIMARY KEY ("id") 15 | ); 16 | 17 | -- CreateTable 18 | CREATE TABLE "Session" ( 19 | "id" TEXT NOT NULL, 20 | "validUntil" TIMESTAMP(3) NOT NULL, 21 | "userId" TEXT NOT NULL, 22 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 23 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | 25 | PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "Article" ( 30 | "id" TEXT NOT NULL, 31 | "title" TEXT NOT NULL, 32 | "href" TEXT NOT NULL, 33 | "description" TEXT, 34 | "publishedAt" TIMESTAMP(3) NOT NULL, 35 | "slug" TEXT NOT NULL, 36 | "blogId" TEXT NOT NULL, 37 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 38 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 39 | 40 | PRIMARY KEY ("id") 41 | ); 42 | 43 | -- CreateTable 44 | CREATE TABLE "Blog" ( 45 | "id" TEXT NOT NULL, 46 | "name" TEXT NOT NULL, 47 | "href" TEXT NOT NULL, 48 | "rss" TEXT NOT NULL, 49 | "slug" TEXT, 50 | "lastUpdateDate" TIMESTAMP(3) NOT NULL, 51 | "favicon" TEXT, 52 | "addedById" TEXT NOT NULL, 53 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 54 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 55 | 56 | PRIMARY KEY ("id") 57 | ); 58 | 59 | -- CreateTable 60 | CREATE TABLE "NewsItem" ( 61 | "id" TEXT NOT NULL, 62 | "title" TEXT NOT NULL, 63 | "message" TEXT NOT NULL, 64 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 65 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 66 | 67 | PRIMARY KEY ("id") 68 | ); 69 | 70 | -- CreateIndex 71 | CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); 72 | 73 | -- CreateIndex 74 | CREATE UNIQUE INDEX "Article.slug_blogId_unique" ON "Article"("slug", "blogId"); 75 | 76 | -- CreateIndex 77 | CREATE UNIQUE INDEX "Blog.href_unique" ON "Blog"("href"); 78 | 79 | -- CreateIndex 80 | CREATE UNIQUE INDEX "Blog.rss_unique" ON "Blog"("rss"); 81 | 82 | -- AddForeignKey 83 | ALTER TABLE "Session" ADD FOREIGN KEY("userId")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 84 | 85 | -- AddForeignKey 86 | ALTER TABLE "Article" ADD FOREIGN KEY("blogId")REFERENCES "Blog"("id") ON DELETE CASCADE ON UPDATE CASCADE; 87 | 88 | -- AddForeignKey 89 | ALTER TABLE "Blog" ADD FOREIGN KEY("addedById")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 90 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20201223211356_develop/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `addedById` on the `Blog` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "Blog" DROP CONSTRAINT "Blog_addedById_fkey"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "Blog" DROP COLUMN "addedById", 12 | ADD COLUMN "isPublic" BOOLEAN NOT NULL DEFAULT false; 13 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20201226213902_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Blog" ADD COLUMN "creatorEmail" TEXT; 3 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20201230152803_add_last_article_published_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE public."Blog" ADD COLUMN "lastArticlePublishedAt" TIMESTAMP(3); 3 | 4 | UPDATE public."Blog" SET "lastArticlePublishedAt" = (SELECT "publishedAt" FROM public."Article" WHERE "blogId" = public."Blog"."id" ORDER BY "publishedAt" DESC LIMIT 1); 5 | 6 | 7 | CREATE OR REPLACE FUNCTION update_last_article_published_at() RETURNS trigger AS $last_article_published_at$ 8 | BEGIN 9 | IF (TG_OP = 'INSERT') THEN 10 | UPDATE public."Blog" SET "lastArticlePublishedAt" = NEW."publishedAt" WHERE id = NEW."blogId" AND public."Blog"."lastArticlePublishedAt" < NEW."publishedAt"; 11 | RETURN NULL; 12 | END IF; 13 | RETURN NULL; 14 | END; 15 | $last_article_published_at$ LANGUAGE plpgsql; 16 | 17 | CREATE TRIGGER last_article_published_at 18 | AFTER INSERT ON public."Article" 19 | FOR EACH ROW EXECUTE PROCEDURE update_last_article_published_at(); 20 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20201230195946_auth/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey"; 10 | 11 | -- CreateTable 12 | CREATE TABLE "accounts" ( 13 | "id" SERIAL, 14 | "compound_id" TEXT NOT NULL, 15 | "user_id" INTEGER NOT NULL, 16 | "provider_type" TEXT NOT NULL, 17 | "provider_id" TEXT NOT NULL, 18 | "provider_account_id" TEXT NOT NULL, 19 | "refresh_token" TEXT, 20 | "access_token" TEXT, 21 | "access_token_expires" TIMESTAMP(3), 22 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 23 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | 25 | PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "users" ( 30 | "id" SERIAL, 31 | "name" TEXT, 32 | "email" TEXT, 33 | "email_verified" TIMESTAMP(3), 34 | "image" TEXT, 35 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 36 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 37 | "role" "UserRole" NOT NULL DEFAULT E'USER', 38 | 39 | PRIMARY KEY ("id") 40 | ); 41 | 42 | -- DropTable 43 | DROP TABLE "Session"; 44 | 45 | -- DropTable 46 | DROP TABLE "User"; 47 | 48 | -- CreateIndex 49 | CREATE UNIQUE INDEX "accounts.compound_id_unique" ON "accounts"("compound_id"); 50 | 51 | -- CreateIndex 52 | CREATE INDEX "providerAccountId" ON "accounts"("provider_account_id"); 53 | 54 | -- CreateIndex 55 | CREATE INDEX "providerId" ON "accounts"("provider_id"); 56 | 57 | -- CreateIndex 58 | CREATE INDEX "userId" ON "accounts"("user_id"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "users.email_unique" ON "users"("email"); 62 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20210103110839_add_notify/migration.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION notify_of_changes() RETURNS trigger AS $$ 2 | BEGIN 3 | IF (TG_OP = 'INSERT') THEN 4 | PERFORM ( 5 | pg_notify(TG_OP || '.' || TG_TABLE_NAME, to_json(NEW.id)::text) 6 | ); 7 | ELSIF (TG_OP = 'UPDATE') THEN 8 | PERFORM ( 9 | pg_notify(TG_OP || '.' || TG_TABLE_NAME, to_json(NEW.id)::text) 10 | ); 11 | ELSIF (TG_OP = 'DELETE') THEN 12 | PERFORM ( 13 | pg_notify(TG_OP || '.' || TG_TABLE_NAME, to_json(OLD.id)::text) 14 | ); 15 | END IF; 16 | RETURN NULL; 17 | END; 18 | $$ LANGUAGE plpgsql; 19 | 20 | CREATE TRIGGER notify_articles 21 | AFTER INSERT OR UPDATE OR DELETE ON "Article" 22 | FOR EACH ROW EXECUTE PROCEDURE notify_of_changes(); 23 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20210705155436_remove_auth/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `accounts` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `users` table. If the table is not empty, all the data it contains will be lost. 6 | 7 | */ 8 | 9 | -- DropTable 10 | DROP TABLE "accounts"; 11 | 12 | -- DropTable 13 | DROP TABLE "users"; 14 | 15 | -- DropEnum 16 | DROP TYPE "UserRole"; 17 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20210705155617_remove_news/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `NewsItem` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "NewsItem"; 9 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20210707153644_members/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Member" ( 6 | "id" TEXT NOT NULL, 7 | "role" "UserRole" NOT NULL DEFAULT E'USER', 8 | 9 | PRIMARY KEY ("id") 10 | ); 11 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20210707154050_add_email_and_trigger/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `email` to the `Member` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Member" ADD COLUMN "email" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20210708153008_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- This is an empty migration. -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20210708154300_replace_triggers/migration.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS notify_articles ON "Article"; 2 | DROP FUNCTION IF EXISTS notify_of_changes; 3 | 4 | DROP TABLE IF EXISTS "Secret"; 5 | 6 | CREATE TABLE "Secret" ( 7 | "id" TEXT NOT NULL, 8 | "name" TEXT NOT NULL, 9 | 10 | PRIMARY KEY ("id") 11 | ); 12 | 13 | CREATE OR REPLACE FUNCTION notify_of_changes() RETURNS trigger AS $$ 14 | DECLARE article_id TEXT; 15 | DECLARE payload TEXT; 16 | DECLARE signat TEXT; 17 | BEGIN 18 | SET search_path = public, extensions; 19 | 20 | IF (TG_OP = 'INSERT') THEN 21 | article_id := NEW.id::TEXT; 22 | ELSIF (TG_OP = 'UPDATE') THEN 23 | article_id := NEW.id::TEXT; 24 | ELSIF (TG_OP = 'DELETE') THEN 25 | article_id := OLD.id::TEXT; 26 | END IF; 27 | 28 | SELECT jsonb_build_object('id', article_id, 'event', TG_OP::TEXT)::TEXT INTO payload; 29 | SELECT encode( 30 | hmac( 31 | payload, 32 | (SELECT id from public."Secret" WHERE "name" = 'notification_signature' LIMIT 1), 33 | 'sha512' 34 | ), 35 | 'hex' 36 | ) INTO signat; 37 | 38 | PERFORM http( 39 | ( 40 | 'POST', 41 | 'https://polskifrontend.pl/api/update-algolia', 42 | ARRAY[http_header('X-Signature', signat)], 43 | 'application/json', 44 | payload 45 | )::http_request 46 | ); 47 | RETURN NULL; 48 | END; 49 | $$ LANGUAGE plpgsql; 50 | 51 | CREATE TRIGGER notify_articles 52 | AFTER INSERT OR UPDATE OR DELETE ON "Article" 53 | FOR EACH ROW EXECUTE PROCEDURE notify_of_changes(); 54 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/20211004205041_remove_triggers/migration.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS notify_articles ON "Article"; 2 | DROP FUNCTION IF EXISTS notify_of_changes; 3 | DROP TABLE IF EXISTS "Secret"; 4 | -------------------------------------------------------------------------------- /api-helpers/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /api-helpers/prisma/prisma-helper.ts: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom'; 2 | 3 | import { logger } from '../logger'; 4 | 5 | import type { PrismaError } from './prisma-errors'; 6 | 7 | export function isPrismaError(err: any): err is PrismaError { 8 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument -- it's right 9 | return Boolean(err?.code) && /^P\d{4}$/.test(err.code); 10 | } 11 | 12 | export function handlePrismaError(err: PrismaError) { 13 | switch (err.code) { 14 | case 'P2001': 15 | throw Boom.notFound(); 16 | case 'P2002': 17 | throw Boom.conflict(); 18 | default: 19 | logger.error(`Unhandled Prisma error: ${err.code}`); 20 | throw err; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api-helpers/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | // previewFeatures = [] 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("DATABASE_URL") 9 | } 10 | 11 | model Article { 12 | id String @default(cuid()) @id 13 | title String 14 | href String 15 | description String? 16 | publishedAt DateTime 17 | slug String 18 | 19 | blogId String 20 | blog Blog @relation(fields: [blogId], references: [id]) 21 | 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @default(now()) @updatedAt 24 | 25 | @@unique([slug, blogId]) 26 | } 27 | 28 | model Blog { 29 | id String @default(cuid()) @id 30 | name String 31 | href String @unique 32 | rss String @unique 33 | slug String? 34 | lastUpdateDate DateTime 35 | favicon String? 36 | creatorEmail String? 37 | isPublic Boolean @default(false) 38 | 39 | lastArticlePublishedAt DateTime? 40 | 41 | articles Article[] 42 | 43 | createdAt DateTime @default(now()) 44 | updatedAt DateTime @default(now()) @updatedAt 45 | } 46 | 47 | enum UserRole { 48 | USER 49 | ADMIN 50 | } 51 | 52 | model Member { 53 | id String @id 54 | role UserRole @default(USER) 55 | email String 56 | } 57 | -------------------------------------------------------------------------------- /api-helpers/rxjs-utils.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/Jason3S/rx-stream/blob/4c715e38924a44e65ed8c5aed883c2ca61dca1b1/src/streamToRx.ts 2 | 3 | import { Observable } from 'rxjs'; 4 | import { distinctUntilChanged } from 'rxjs/operators'; 5 | 6 | import { logger } from './logger'; 7 | 8 | import type { Subscription } from 'rxjs'; 9 | import type { Duplex } from 'stream'; 10 | 11 | export function streamToRx>( 12 | stream: S, 13 | pauser?: Observable, 14 | ): Observable { 15 | stream.pause(); 16 | 17 | return new Observable((subscriber) => { 18 | const endHandler = () => { 19 | logger.debug('END HANDLER'); 20 | return subscriber.complete(); 21 | }; 22 | const errorHandler = (e: Error) => subscriber.error(e); 23 | const dataHandler = (data: T) => subscriber.next(data); 24 | 25 | stream.addListener('end', endHandler); 26 | stream.addListener('close', endHandler); 27 | stream.addListener('error', errorHandler); 28 | stream.addListener('data', dataHandler); 29 | 30 | let pauseSubscription: Subscription | undefined; 31 | if (pauser) { 32 | pauseSubscription = pauser.pipe(distinctUntilChanged()).subscribe((b) => { 33 | if (b === false) { 34 | stream.resume(); 35 | } else if (b === true) { 36 | stream.pause(); 37 | } 38 | }); 39 | } 40 | 41 | stream.resume(); 42 | 43 | return () => { 44 | stream.removeListener('end', endHandler); 45 | stream.removeListener('close', endHandler); 46 | stream.removeListener('error', errorHandler); 47 | stream.removeListener('data', dataHandler); 48 | 49 | if (pauseSubscription) { 50 | pauseSubscription.unsubscribe(); 51 | } 52 | }; 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /api-helpers/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { getPagesArray } from '../utils/array-utils'; 2 | 3 | import { getLastArticlePage, getLastBlogPage, getArticlesSlugs } from './articles'; 4 | 5 | import type { PrismaClient } from '@prisma/client'; 6 | 7 | type Item = { 8 | readonly path: string; 9 | readonly changefreq: 'daily' | 'monthly' | 'always' | 'hourly' | 'weekly' | 'yearly' | 'never'; 10 | readonly priority: number; 11 | }; 12 | 13 | const staticItems: readonly Item[] = [ 14 | { path: '/', changefreq: 'hourly', priority: 1 }, 15 | { path: '/o-serwisie', changefreq: 'monthly', priority: 0.5 }, 16 | { path: '/zglos-serwis', changefreq: 'monthly', priority: 0.5 }, 17 | 18 | { path: '/list', changefreq: 'hourly', priority: 0.9 }, 19 | { path: '/grid', changefreq: 'hourly', priority: 0.9 }, 20 | ]; 21 | 22 | export async function getSitemap(prisma: PrismaClient) { 23 | const [gridLastPage, listLastPage, articleSlugs] = await Promise.all([ 24 | getLastBlogPage(prisma), 25 | getLastArticlePage(prisma), 26 | getArticlesSlugs(prisma), 27 | ]); 28 | const gridPages = getPagesArray(gridLastPage); 29 | const listPages = getPagesArray(listLastPage); 30 | 31 | const dynamicItems: readonly Item[] = [ 32 | ...articleSlugs.map(({ slug }) => ({ 33 | path: `/artykuly/${slug}`, 34 | changefreq: 'monthly' as const, 35 | priority: 0.5, 36 | })), 37 | ...gridPages.map((page) => ({ 38 | path: `/grid/${page}`, 39 | changefreq: 'hourly' as const, 40 | priority: 0.4, 41 | })), 42 | ...listPages.map((page) => ({ 43 | path: `/list/${page}`, 44 | changefreq: 'hourly' as const, 45 | priority: 0.4, 46 | })), 47 | ]; 48 | 49 | return sitemapXml([...staticItems, ...dynamicItems]); 50 | } 51 | 52 | function itemsToXml(items: ReadonlyArray) { 53 | return items 54 | .map((item) => 55 | ` 56 | 57 | https://${process.env.NEXT_PUBLIC_URL}${item.path} 58 | ${item.changefreq} 59 | ${item.priority} 60 | 61 | `.trim(), 62 | ) 63 | .join('\n'); 64 | } 65 | 66 | function sitemapXml(items: ReadonlyArray) { 67 | const xml = itemsToXml(items); 68 | return ` 69 | 70 | 71 | ${xml} 72 | 73 | `.trim(); 74 | } 75 | -------------------------------------------------------------------------------- /api-helpers/types.ts: -------------------------------------------------------------------------------- 1 | import type { Models } from './models'; 2 | 3 | export type Model> = T & { 4 | readonly createdAt: Date; 5 | readonly updatedAt: Date; 6 | }; 7 | 8 | export type DeepPartial = { 9 | readonly [P in keyof T]?: T[P] extends ReadonlyArray 10 | ? ReadonlyArray> 11 | : DeepPartial; 12 | }; 13 | 14 | export type Awaited = T extends Promise ? Awaited : T; 15 | 16 | export type Nil = T | undefined | null; 17 | -------------------------------------------------------------------------------- /app/(articles)/[displayStyle]/[page]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getLastArticlePage, getLastBlogPage } from '../../../../api-helpers/articles'; 2 | import { openConnection } from '../../../../api-helpers/prisma/db'; 3 | import { BlogsGrid } from '../../../../components/BlogsGrid/BlogsGrid'; 4 | import { BlogsList } from '../../../../components/BlogsList/BlogsList'; 5 | import { getPagesArray } from '../../../../utils/array-utils'; 6 | 7 | import type { DisplayStyle } from '../../../../types'; 8 | 9 | const MAX_PAGES = 5; 10 | 11 | export type HomePageProps = { 12 | readonly params: { 13 | readonly displayStyle: DisplayStyle; 14 | readonly page: string; 15 | }; 16 | }; 17 | 18 | export const revalidate = 900; // 15 minutes 19 | 20 | export default function HomePage({ params }: HomePageProps) { 21 | const { displayStyle, page } = params; 22 | 23 | if (displayStyle !== 'list') { 24 | // @ts-expect-error Server Component 25 | return ; 26 | } 27 | 28 | // @ts-expect-error Server Component 29 | return ; 30 | } 31 | 32 | export const generateStaticParams = async () => { 33 | const prisma = openConnection(); 34 | 35 | const [gridLastPage, listLastPage] = await Promise.all([ 36 | await getLastBlogPage(prisma), 37 | await getLastArticlePage(prisma), 38 | ]); 39 | 40 | const gridPages = getPagesArray(gridLastPage, MAX_PAGES); 41 | const listPages = getPagesArray(listLastPage, MAX_PAGES); 42 | 43 | const paths = [ 44 | ...gridPages.map((page) => ({ displayStyle: 'grid' as const, page })), 45 | ...listPages.map((page) => ({ displayStyle: 'list' as const, page })), 46 | ] satisfies readonly HomePageProps['params'][]; 47 | 48 | return paths; 49 | }; 50 | -------------------------------------------------------------------------------- /app/(articles)/[displayStyle]/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../../../components/HeadTags'; 2 | 3 | import type { DisplayStyle } from '../../../types'; 4 | 5 | type HeadProps = { 6 | readonly params: { 7 | readonly displayStyle?: DisplayStyle; 8 | readonly page: string; 9 | }; 10 | }; 11 | 12 | export const displayStyleTitle: Record = { 13 | grid: 'siatka', 14 | list: 'lista', 15 | } as const; 16 | 17 | export default function Head({ params }: HeadProps) { 18 | return ( 19 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/(articles)/[displayStyle]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AlgoliaSearch } from '../../../components/AlgoliaSearch/AlgoliaSearch'; 2 | import { SwitchDisplayStyle } from '../../../components/SwitchDisplayStyle/SwitchDisplayStyle'; 3 | 4 | import type { DisplayStyle } from '../../../types'; 5 | import type { ReactNode } from 'react'; 6 | 7 | type ArticlesLayoutProps = { 8 | readonly children: ReactNode; 9 | readonly params: { 10 | readonly displayStyle: DisplayStyle; 11 | readonly page: String; 12 | }; 13 | }; 14 | 15 | export default function ArticlesLayout({ children, params }: ArticlesLayoutProps) { 16 | return ( 17 |
18 | 19 | 20 | {children} 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/(articles)/[displayStyle]/page.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from './[page]/page'; 2 | 3 | export const revalidate = 900; // 15 minutes 4 | 5 | export default HomePage; 6 | 7 | export const generateStaticParams = () => { 8 | return [{ displayStyle: 'list' }, { displayStyle: 'grid' }]; 9 | }; 10 | -------------------------------------------------------------------------------- /app/(content)/admin/[blogsType]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonAsLink } from '../../../../components/ButtonAsLink/ButtonAsLink'; 2 | import { ChangeBlogsList } from '../../../../components/ChangeBlogsList/ChangeBlogsList'; 3 | import { Content } from '../../../../components/Content/Content'; 4 | import { ContentNavigation } from '../../../../components/Content/ContentNavigation'; 5 | import { ContentTitle } from '../../../../components/Content/ContentTitle'; 6 | import { LogoutButton } from '../../../../components/LogoutButton/LogoutButton'; 7 | import { Table } from '../../../../components/Table/Table'; 8 | import { fetchAdminBlogsList } from '../../../../utils/fetchAdminBlogsList'; 9 | 10 | import type { BlogsType } from '../../../../types'; 11 | 12 | type AdminPageProps = { 13 | readonly params: { 14 | readonly blogsType: BlogsType; 15 | }; 16 | }; 17 | 18 | export const revalidate = 0; 19 | 20 | export default async function AdminPage({ params }: AdminPageProps) { 21 | const blogs = await fetchAdminBlogsList(params.blogsType); 22 | 23 | return ( 24 | <> 25 | Admin Panel - Blogi 26 | 27 | 28 | 29 | Strona Główna 30 | 31 | 32 | Wyloguj 33 | 34 | 35 | 36 | 37 | {blogs.length > 0 && } 38 | 39 | 40 | ); 41 | } 42 | 43 | export const generateStaticParams = () => { 44 | return [ 45 | { blogsType: 'public' }, 46 | { blogsType: 'nonpublic' }, 47 | { blogsType: 'all' }, 48 | ] satisfies readonly { readonly blogsType: BlogsType }[]; 49 | }; 50 | -------------------------------------------------------------------------------- /app/(content)/admin/blogs/[blogId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { openConnection } from '../../../../../api-helpers/prisma/db'; 2 | import { ButtonAsLink } from '../../../../../components/ButtonAsLink/ButtonAsLink'; 3 | import { Content } from '../../../../../components/Content/Content'; 4 | import { ContentNavigation } from '../../../../../components/Content/ContentNavigation'; 5 | import { ContentTitle } from '../../../../../components/Content/ContentTitle'; 6 | import { LogoutButton } from '../../../../../components/LogoutButton/LogoutButton'; 7 | import { DangerZone } from '../../../../../components/UpdateBlogSection/DangerZone'; 8 | import { UpdateBlogForm } from '../../../../../components/UpdateBlogSection/UpdateBlogForm'; 9 | 10 | type AdminBlogPageProps = { 11 | readonly params: { 12 | readonly blogId: string; 13 | }; 14 | }; 15 | 16 | export const revalidate = 0; 17 | 18 | export default function AdminBlogPage({ params }: AdminBlogPageProps) { 19 | return ( 20 | <> 21 | Aktualizacja danych 22 | 23 | 24 | 25 | Lista Blogów 26 | 27 | 28 | Wyloguj 29 | 30 | 31 | 32 | 33 | 34 | 35 | Danger zone 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export const generateStaticParams = async () => { 45 | const prisma = openConnection(); 46 | 47 | const blogs = await prisma.blog.findMany(); 48 | const paths = blogs.map(({ id }) => ({ blogId: id })); 49 | 50 | return paths; 51 | }; 52 | -------------------------------------------------------------------------------- /app/(content)/admin/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../../../components/HeadTags'; 2 | 3 | export default function Head() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(content)/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '../../../components/AuthGuard/AuthGuard'; 2 | 3 | import type { ReactNode } from 'react'; 4 | 5 | type AdminLayoutProps = { 6 | readonly children: ReactNode; 7 | }; 8 | 9 | export default function AdminLayout({ children }: AdminLayoutProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /app/(content)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import AdminPage from './[blogsType]/page'; 2 | export const revalidate = 900; // 15 minutes 3 | export default AdminPage; 4 | -------------------------------------------------------------------------------- /app/(content)/artykuly/[slug]/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../../../../components/HeadTags'; 2 | import { fetchArticleBySlug } from '../../../../utils/fetchArticleBySlug'; 3 | 4 | type HeadProps = { 5 | readonly params: { 6 | readonly slug: string; 7 | }; 8 | }; 9 | 10 | export default async function Head({ params }: HeadProps) { 11 | const article = await fetchArticleBySlug(params.slug); 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /app/(content)/artykuly/[slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { ButtonAsLink } from '../../../../components/ButtonAsLink/ButtonAsLink'; 4 | import { Content } from '../../../../components/Content/Content'; 5 | import { ContentNavigation } from '../../../../components/Content/ContentNavigation'; 6 | import { ContentTitle } from '../../../../components/Content/ContentTitle'; 7 | import { fetchArticleBySlug } from '../../../../utils/fetchArticleBySlug'; 8 | import { addTrackingToLink } from '../../../../utils/link-utils'; 9 | 10 | import type { ReactNode } from 'react'; 11 | 12 | type ArticleLayoutProps = { 13 | readonly children: ReactNode; 14 | readonly params: { 15 | readonly slug: string; 16 | }; 17 | }; 18 | 19 | export default async function ArticleLayout({ children, params }: ArticleLayoutProps) { 20 | const article = await fetchArticleBySlug(params.slug); 21 | 22 | return ( 23 | <> 24 | 25 | 30 | {article.blog.name} 31 | 32 | 33 | 34 | 35 | 36 | Strona Główna 37 | 38 | 39 | 40 | {children} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/(content)/artykuly/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getArticlesSlugs } from '../../../../api-helpers/articles'; 2 | import { DEFAULT_ARTICLES } from '../../../../api-helpers/general-feed'; 3 | import { openConnection } from '../../../../api-helpers/prisma/db'; 4 | import { ArticleDate } from '../../../../components/ArticleDate/ArticleDate'; 5 | import { ButtonAsLink } from '../../../../components/ButtonAsLink/ButtonAsLink'; 6 | import { detectContentGenre } from '../../../../utils/creator-utils'; 7 | import { fetchArticleBySlug } from '../../../../utils/fetchArticleBySlug'; 8 | import { addTrackingToLink } from '../../../../utils/link-utils'; 9 | 10 | const linkLabels: Record, string> = { 11 | blog: 'Przejdź do artykułu', 12 | podcast: 'Przejdź do podcastu', 13 | youtube: 'Przejdź do filmu', 14 | }; 15 | 16 | type ArticlePageProps = { 17 | readonly params: { 18 | readonly slug: string; 19 | }; 20 | }; 21 | 22 | export default async function ArticlePage({ params }: ArticlePageProps) { 23 | const article = await fetchArticleBySlug(params.slug); 24 | const articleLinkLabel = linkLabels[detectContentGenre(article, article.blog)]; 25 | 26 | return ( 27 |
28 |
29 |

{article.title}

30 | 31 |
32 |
36 |
37 |

Chcesz więcej? Sprawdź w oryginale!

38 | 43 | {articleLinkLabel} 44 | 45 |
46 |
47 | ); 48 | } 49 | 50 | export const generateStaticParams = async () => { 51 | const prisma = openConnection(); 52 | 53 | const articleSlugs = await getArticlesSlugs(prisma, DEFAULT_ARTICLES); 54 | 55 | return articleSlugs; 56 | }; 57 | -------------------------------------------------------------------------------- /app/(content)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | type ContentLayoutProps = { 4 | readonly children: ReactNode; 5 | }; 6 | 7 | export default function ContentLayout({ children }: ContentLayoutProps) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /app/(content)/login/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../../../components/HeadTags'; 2 | 3 | export default function Head() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(content)/login/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonAsLink } from '../../../components/ButtonAsLink/ButtonAsLink'; 2 | import { Content } from '../../../components/Content/Content'; 3 | import { ContentNavigation } from '../../../components/Content/ContentNavigation'; 4 | import { ContentTitle } from '../../../components/Content/ContentTitle'; 5 | 6 | import type { ReactNode } from 'react'; 7 | 8 | type LoginLayoutProps = { 9 | readonly children: ReactNode; 10 | }; 11 | 12 | export default function LoginLayout({ children }: LoginLayoutProps) { 13 | return ( 14 | <> 15 | LOGOWANIE 16 | 17 | 18 | 19 | Strona Główna 20 | 21 | 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/(content)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { AuthPage } from '../../../components/AuthPage/AuthPage'; 2 | 3 | export default function LoginPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(content)/o-serwisie/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../../../components/HeadTags'; 2 | 3 | export default function Head() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(content)/o-serwisie/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonAsLink } from '../../../components/ButtonAsLink/ButtonAsLink'; 2 | import { Content } from '../../../components/Content/Content'; 3 | import { ContentNavigation } from '../../../components/Content/ContentNavigation'; 4 | import { ContentTitle } from '../../../components/Content/ContentTitle'; 5 | 6 | import type { ReactNode } from 'react'; 7 | 8 | type AboutServiceLayoutProps = { 9 | readonly children: ReactNode; 10 | }; 11 | 12 | export default function AboutServiceLayout({ children }: AboutServiceLayoutProps) { 13 | return ( 14 | <> 15 | O serwisie 16 | 17 | 18 | 19 | Strona Główna 20 | 21 | 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/(content)/o-serwisie/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export default function AboutPage() { 4 | return ( 5 |
6 |

Po co jest ten serwis?

7 |

8 | Serwis Polski Frontend powstał w celu zebrania w jednym miejscu jak 9 | największej liczby stron, serwisów oraz blogów na temat szeroko rozumianego frontend 10 | developmentu. Co ważne, wszystkie zgromadzone tutaj serwisy tworzone są{' '} 11 | w języku polskim! 12 |

13 |

14 | Podstawowym założeniem „Polskiego Frontendu” było zebranie tutaj{' '} 15 | całych blogów, a nie pojedynczych artykułów. Z tego powodu selekcji treści 16 | mogę dokonywać jedynie na etapie dodawania danej strony do serwisu. Jako, że nie każdy pisze 17 | tylko i wyłącznie o frontendzie, to czasem może się tutaj pojawić wpis na temat, na 18 | przykład, PHP… Wydaje mi się jednak, że każdy blog czy strona, która pisze{' '} 19 | dużo o frontendzie, czy ogólniej o web developmencie, zasługuje by się 20 | tutaj znaleźć… Nawet jeśli frontend to nie jedyny poruszany tam temat. 21 |

22 |

23 | Jeżeli więc znasz (lub sam prowadzisz) jakiś blog, serwis lub stronę{' '} 24 | w języku polskim, której tutaj nie ma, a uważasz, że powinna się tu znaleźć 25 | - zapraszam do zgłoszenia go przez specjalnie do tego celu{' '} 26 | 27 | przygotowany formularz 28 | 29 | . Obiecuję, że przejrzę każde zgłoszenie i jeśli uznam, że dana strona się nadaje - dodam ją 30 | do serwisu! 31 |

32 |

33 | A jeśli podoba Ci się moja idea i sam prowadzisz bloga o frontendzie, to oprócz zgłoszenia 34 | zachęcam również, do polecania tej strony swoim czytelnikom! Im większy 35 | ruch tutaj tym większy ruch do Twojego bloga… 36 |

37 |

38 | Od 2020 portal jest własnością{' '} 39 | 43 | Type of Web 44 | {' '} 45 | i jest rozwijany jako{' '} 46 | 50 | Open Source na GitHubie 51 | 52 | ! 53 |

54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/(content)/regulamin/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../../../components/HeadTags'; 2 | 3 | export default function Head() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(content)/regulamin/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonAsLink } from '../../../components/ButtonAsLink/ButtonAsLink'; 2 | import { Content } from '../../../components/Content/Content'; 3 | import { ContentNavigation } from '../../../components/Content/ContentNavigation'; 4 | import { ContentTitle } from '../../../components/Content/ContentTitle'; 5 | 6 | import type { ReactNode } from 'react'; 7 | 8 | type TermsOfUseLayoutProps = { 9 | readonly children: ReactNode; 10 | }; 11 | 12 | export default function TermsOfUseLayout({ children }: TermsOfUseLayoutProps) { 13 | return ( 14 | <> 15 | 16 | Regulamin polskifrontend.pl 17 |

18 | Wersja z dnia 27. stycznia 2021r. 19 |

20 |
21 | 22 | 23 | 24 | Strona Główna 25 | 26 | 27 | 28 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(content)/regulamin/page.module.scss: -------------------------------------------------------------------------------- 1 | .ol { 2 | padding-inline-start: 0.6rem; 3 | 4 | @media screen and (min-width: 45em) { 5 | padding-inline-start: 0.8rem; 6 | } 7 | 8 | @media screen and (min-width: 60em) { 9 | padding-inline-start: 1rem; 10 | } 11 | } 12 | 13 | .li { 14 | margin-bottom: 1rem; 15 | 16 | li { 17 | list-style-type: upper-roman; 18 | 19 | li { 20 | list-style-type: lower-alpha; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/(content)/zglos-serwis/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../../../components/HeadTags'; 2 | 3 | export default function Head() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(content)/zglos-serwis/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonAsLink } from '../../../components/ButtonAsLink/ButtonAsLink'; 2 | import { Content } from '../../../components/Content/Content'; 3 | import { ContentNavigation } from '../../../components/Content/ContentNavigation'; 4 | import { ContentTitle } from '../../../components/Content/ContentTitle'; 5 | 6 | import type { ReactNode } from 'react'; 7 | 8 | type AddContentCreatorLayoutProps = { 9 | readonly children: ReactNode; 10 | }; 11 | 12 | export default function AddContentCreatorLayout({ children }: AddContentCreatorLayoutProps) { 13 | return ( 14 | <> 15 | Zgłoś Serwis 16 | 17 | 18 | 19 | Strona Główna 20 | 21 | 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/(content)/zglos-serwis/page.tsx: -------------------------------------------------------------------------------- 1 | import { AddContentCreatorForm } from '../../../components/AddContentCreatorForm/AddContentCreatorForm'; 2 | 3 | export default function AddContentCreatorPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/head.tsx: -------------------------------------------------------------------------------- 1 | import { HeadTags } from '../components/HeadTags'; 2 | 3 | export default function Head() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Roboto } from '@next/font/google'; 2 | import Image from 'next/image'; 3 | 4 | import { Analytics } from '../components/Analytics'; 5 | import { CookiesPopup } from '../components/CookiesPopup/CookiesPopup'; 6 | import { Footer } from '../components/Footer/Footer'; 7 | import { Header } from '../components/Header/Header'; 8 | import { Navigation } from '../components/Navigation/Navigation'; 9 | import { Providers } from '../components/Providers/Providers'; 10 | import Background from '../public/background.svg'; 11 | import BgTiles from '../public/bg-tiles.svg'; 12 | 13 | import type { ReactNode } from 'react'; 14 | 15 | import '../styles/global.scss'; 16 | import '../styles/tailwind.css'; 17 | 18 | type RootLayoutProps = { 19 | readonly children: ReactNode; 20 | }; 21 | 22 | const roboto = Roboto({ 23 | weight: ['300', '400', '500', '700', '900'], 24 | variable: '--font-roboto', 25 | }); 26 | 27 | export default function RootLayout({ children }: RootLayoutProps) { 28 | return ( 29 | 30 | 31 | 37 | 43 | 44 | 45 | 46 |
47 | 48 |
49 |
50 | 51 |
52 |
53 | 58 | 63 | 68 | 69 | 74 | 79 |
80 | 81 | 82 |
83 |
{children}
84 |
85 | 86 |
87 |
88 | 89 |
90 |
91 |
92 | 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /components/AddContentCreatorForm/AddContentCreatorForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import HCaptcha from '@hcaptcha/react-hcaptcha'; 4 | import Clsx from 'clsx'; 5 | import { useEffect, useRef, useCallback, useState } from 'react'; 6 | 7 | import { getConfig } from '../../api-helpers/config'; 8 | import { useMutation } from '../../hooks/useMutation'; 9 | import { addContentCreator } from '../../utils/api/addContentCreator'; 10 | import { Button } from '../Button/Button'; 11 | 12 | import { FormStatus } from './FormStatus'; 13 | 14 | import type { ChangeEventHandler, FormEventHandler } from 'react'; 15 | 16 | export const AddContentCreatorForm = () => { 17 | const [fields, setFields] = useState({ contentURL: '', email: '' }); 18 | const [token, setToken] = useState(null); 19 | const [touched, setTouched] = useState>({}); 20 | const captchaRef = useRef(null); 21 | const formRef = useRef(null); 22 | const [isFormValid, setIsFormValid] = useState(false); 23 | const { mutate, status, errorCode } = useMutation(addContentCreator); 24 | 25 | useEffect(() => { 26 | if (status === 'success') { 27 | setFields({ contentURL: '', email: '' }); 28 | setTouched({}); 29 | } 30 | }, [status]); 31 | 32 | const handleChange: ChangeEventHandler = useCallback(({ currentTarget }) => { 33 | setTouched((touched) => ({ ...touched, [currentTarget.name]: true })); 34 | setFields((fields) => ({ ...fields, [currentTarget.name]: currentTarget.value })); 35 | setIsFormValid(formRef.current?.checkValidity() || false); 36 | }, []); 37 | 38 | const handleSubmit: FormEventHandler = (e) => { 39 | e.preventDefault(); 40 | if (token) { 41 | setToken(null); 42 | captchaRef.current?.resetCaptcha(); 43 | void mutate({ ...fields, captchaToken: token }); 44 | } 45 | }; 46 | 47 | const handleCaptchaExpire = useCallback(() => { 48 | setToken(null); 49 | }, []); 50 | const handleCaptchaError = useCallback(() => { 51 | setToken(null); 52 | }, []); 53 | 54 | if (!getConfig('NEXT_PUBLIC_CAPTCHA_SITE_KEY')) { 55 | return ( 56 | 57 | Wystąpił błąd po stronie serwera, jeśli ten problem będzie się utrzymywał, proszę skontaktuj 58 | się z administratorem serwisu. 59 | 60 | ); 61 | } 62 | 63 | const isLoading = status === 'loading'; 64 | const isButtonDisabled = isLoading || !isFormValid || !token; 65 | 66 | return ( 67 |
68 | 85 | 102 |
103 |
104 | 113 |
114 | 115 |
116 | 119 |
120 |
121 | 122 | 123 | 124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /components/AddContentCreatorForm/FormStatus.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import { memo } from 'react'; 5 | 6 | import type { Status } from '../../hooks/useMutation'; 7 | 8 | type FormStatusProps = { 9 | readonly status: Status; 10 | readonly errorCode?: number; 11 | }; 12 | 13 | const errorToMessage: Record = { 14 | 409: 'Blog o podanym adresie został już dodany do naszej bazy danych. Jeżeli nie pojawiają się najnowsze wpisy lub masz inne zastrzeżenia – proszę skontaktuj się z administratorem.', 15 | 400: `Nie udało się wyciągnąć ID kanału z podanego adresu YouTube. Postaraj się znaleźć adres w postaci https://youtube.com/channel/{ID_KANAŁU} lub https://youtube.com/user/{ID_UŻYTKOWNIKA}`, 16 | 422: 'Nie udało się odnaleźć pliku RSS na twojej stronie. Upewnij się, że strona posiada RSS/ATOM i link do niego jest poprawnie dodany w sekcji na stronie.', 17 | }; 18 | 19 | const defaultErrorMessage = 20 | 'Wystąpił błąd podczas dodawania nowego serwisu. Sprawdź poprawność danych i spróbuj ponownie.'; 21 | 22 | function getStatusMessage({ status, errorCode }: FormStatusProps) { 23 | switch (status) { 24 | case 'loading': 25 | return 'Oczekiwanie...'; 26 | case 'success': 27 | return 'Dziękujemy za zgłoszenie, dodany serwis pojawi się na stronie po zaakceptowaniu przez administrację'; 28 | case 'error': 29 | return errorCode ? errorToMessage[errorCode] ?? defaultErrorMessage : defaultErrorMessage; 30 | default: 31 | return null; 32 | } 33 | } 34 | 35 | function getStatusIcon(status: Status) { 36 | switch (status) { 37 | case 'loading': 38 | return 'spinner3'; 39 | case 'success': 40 | return 'checkmark'; 41 | case 'error': 42 | return 'cross'; 43 | default: 44 | return 'question'; 45 | } 46 | } 47 | 48 | export const FormStatus = memo(({ status, errorCode }) => { 49 | if (status === 'idle') { 50 | return null; 51 | } 52 | 53 | return ( 54 |
55 | 56 | {getStatusMessage({ status, errorCode })} 57 |
58 | ); 59 | }); 60 | FormStatus.displayName = 'FormStatus'; 61 | -------------------------------------------------------------------------------- /components/AlgoliaSearch/AlgoliaHit.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import { createExcerpt } from '../../utils/excerpt-utils'; 4 | import { ArticleTile } from '../ArticleTile/ArticleTile'; 5 | 6 | import type { Article, BlogFromArticle } from '../../types'; 7 | 8 | export type Hit = { 9 | readonly objectID: string; 10 | readonly href: string; 11 | readonly publishedAt: string; 12 | readonly slug: string; 13 | readonly description: string | null; 14 | readonly title: string; 15 | readonly blog: { 16 | readonly name: string; 17 | readonly href: string; 18 | readonly favicon?: string; 19 | }; 20 | }; 21 | 22 | type HitProps = { 23 | readonly hit: Hit; 24 | }; 25 | 26 | const hitToArticle = (hit: Hit) => { 27 | return { 28 | id: hit.objectID, 29 | href: hit.href, 30 | slug: hit.slug, 31 | title: hit.title, 32 | description: hit.description, 33 | publishedAt: new Date(hit.publishedAt), 34 | excerpt: createExcerpt(hit.description || ''), 35 | blog: { 36 | ...hit.blog, 37 | favicon: hit.blog.favicon ?? null, 38 | }, 39 | } satisfies Article; 40 | }; 41 | const hitToBlog = (hit: Hit) => { 42 | return { 43 | name: hit.blog.name, 44 | href: hit.blog.href, 45 | favicon: hit.blog.favicon ?? null, 46 | } satisfies BlogFromArticle; 47 | }; 48 | 49 | export const AlgoliaHit = memo( 50 | ({ hit }: HitProps) => ( 51 | 52 | ), 53 | ({ hit: prev }, { hit: next }) => prev.objectID === next.objectID, 54 | ); 55 | AlgoliaHit.displayName = 'AlgoliaHit'; 56 | -------------------------------------------------------------------------------- /components/AlgoliaSearch/AlgoliaHits.tsx: -------------------------------------------------------------------------------- 1 | import { connectHits } from 'react-instantsearch-dom'; 2 | 3 | import { AlgoliaHit } from './AlgoliaHit'; 4 | 5 | import type { Hit } from './AlgoliaHit'; 6 | 7 | type HitsProps = { 8 | readonly hits: readonly Hit[]; 9 | }; 10 | 11 | const Hits = ({ hits }: HitsProps) => ( 12 |
    13 | {hits.map((hit) => ( 14 |
  1. 15 | 16 |
  2. 17 | ))} 18 |
19 | ); 20 | 21 | export const AlgoliaHits = connectHits(Hits); 22 | -------------------------------------------------------------------------------- /components/AlgoliaSearch/AlgoliaSearch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { memo } from 'react'; 4 | import { InstantSearch } from 'react-instantsearch-dom'; 5 | 6 | import { getConfig } from '../../api-helpers/config'; 7 | 8 | import { AlgoliaHits } from './AlgoliaHits'; 9 | import { SearchBox } from './SearchBox'; 10 | import { useAlgolia } from './useAlgolia'; 11 | 12 | import type { ReactNode } from 'react'; 13 | 14 | export type AlgoliaSearchProps = { 15 | readonly children: ReactNode; 16 | }; 17 | 18 | export const AlgoliaSearch = memo(({ children }) => { 19 | const { searchClient, searchState, onSearchStateChange, changeSearchState, clearSearchState } = 20 | useAlgolia(); 21 | 22 | return ( 23 | 29 | 34 | {searchState.query?.length > 0 ? : <>{children}} 35 | 36 | ); 37 | }); 38 | 39 | AlgoliaSearch.displayName = 'AlgoliaSearch'; 40 | -------------------------------------------------------------------------------- /components/AlgoliaSearch/SearchBox.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import { connectSearchBox, PoweredBy } from 'react-instantsearch-dom'; 5 | 6 | import type { ChangeEvent } from 'react'; 7 | 8 | type CustomSearchBoxProps = { 9 | readonly changeSearchState: (query: string) => void; 10 | readonly clearSearchState: () => void; 11 | readonly currentRefinement: string; 12 | readonly query: string; 13 | readonly isSearchStalled: boolean; 14 | readonly refine: (query: string) => void; 15 | }; 16 | 17 | const CustomSearchBox = ({ 18 | query, 19 | refine, 20 | changeSearchState, 21 | clearSearchState, 22 | }: CustomSearchBoxProps) => { 23 | const handleSearch = () => { 24 | if (query?.length <= 0) return; 25 | 26 | refine(query); 27 | }; 28 | 29 | const handleChangeSearchState = (event: ChangeEvent) => { 30 | changeSearchState(event.currentTarget.value); 31 | }; 32 | 33 | return ( 34 |
35 |
36 | 58 | 59 | 66 | 67 | 68 | 69 | {query?.length > 0 && ( 70 | 78 | )} 79 |
80 | 81 |
82 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | export const SearchBox = connectSearchBox(CustomSearchBox); 89 | -------------------------------------------------------------------------------- /components/AlgoliaSearch/useAlgolia.ts: -------------------------------------------------------------------------------- 1 | import Algoliasearch from 'algoliasearch/lite'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | 4 | import { getConfig } from '../../api-helpers/config'; 5 | 6 | export const useAlgolia = () => { 7 | const [searchState, setSearchState] = useState({ query: '' }); 8 | 9 | const searchClient = Algoliasearch( 10 | getConfig('NEXT_PUBLIC_ALGOLIA_APP_ID'), 11 | getConfig('NEXT_PUBLIC_ALGOLIA_API_KEY'), 12 | ); 13 | 14 | const changeSearchState = (query: string) => { 15 | setSearchState(() => ({ 16 | ...searchState, 17 | query, 18 | })); 19 | }; 20 | 21 | const clearSearchState = useCallback(() => { 22 | setSearchState(() => ({ 23 | ...searchState, 24 | query: '', 25 | })); 26 | }, [searchState]); 27 | 28 | useEffect(() => { 29 | const handleKeyDown = (event: KeyboardEvent) => { 30 | return event.key === 'Escape' && clearSearchState(); 31 | }; 32 | 33 | window.addEventListener('keydown', handleKeyDown); 34 | 35 | return () => { 36 | return window.removeEventListener('keydown', handleKeyDown); 37 | }; 38 | }, [clearSearchState]); 39 | 40 | return { 41 | searchClient, 42 | searchState, 43 | onSearchStateChange: setSearchState, 44 | changeSearchState, 45 | clearSearchState, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /components/Analytics.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Analytics as VercelAnalytics } from '@vercel/analytics/react'; 4 | 5 | export const Analytics = () => { 6 | return ; 7 | }; 8 | -------------------------------------------------------------------------------- /components/ArticleDate/ArticleDate.tsx: -------------------------------------------------------------------------------- 1 | import { formatDate } from '../../utils/date-utils'; 2 | 3 | type ArticleDateProps = { 4 | readonly publishedAt: Date; 5 | }; 6 | 7 | export const ArticleDate = ({ publishedAt }: ArticleDateProps) => { 8 | const dateTime = publishedAt.toISOString(); 9 | const readableDate = formatDate(publishedAt); 10 | 11 | return ( 12 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /components/ArticleTile/ArticleTile.tsx: -------------------------------------------------------------------------------- 1 | import Clsx from 'clsx'; 2 | import Image from 'next/image'; 3 | import Link from 'next/link'; 4 | 5 | import { detectContentGenre } from '../../utils/creator-utils'; 6 | import { addTrackingToLink } from '../../utils/link-utils'; 7 | import { ArticleDate } from '../ArticleDate/ArticleDate'; 8 | 9 | import Styles from './articleTile.module.scss'; 10 | 11 | import type { ArticleFromBlog, BlogFromArticle } from '../../types'; 12 | 13 | type ArticleTileProps = { 14 | readonly article: ArticleFromBlog; 15 | readonly blog: BlogFromArticle; 16 | readonly truncate?: boolean; 17 | readonly isInGrid: boolean; 18 | }; 19 | 20 | export const ArticleTile = ({ article, blog, truncate, isInGrid }: ArticleTileProps) => { 21 | const contentGenre = detectContentGenre(article, blog); 22 | 23 | const { title, publishedAt, excerpt, href, slug } = article; 24 | const { name: blogName, favicon } = blog; 25 | 26 | const TitleTag = isInGrid ? 'h4' : 'h3'; 27 | 28 | return ( 29 |
30 |
31 |
32 |
33 | {favicon && ( 34 | 41 | )} 42 |

43 | {blogName}  44 | {contentGenre} 45 |

46 |
47 | 48 | 49 | 55 | {title} 56 | 57 | 58 |
59 |
60 | 61 |

{excerpt}

62 | 63 |
64 | 70 | 71 | URL 72 | 73 | 74 |
75 | 76 |
77 |
78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /components/ArticleTile/articleTile.module.scss: -------------------------------------------------------------------------------- 1 | .excerpt { 2 | display: -webkit-box; 3 | -webkit-line-clamp: 3; 4 | -webkit-box-orient: vertical; 5 | overflow: hidden; 6 | } 7 | -------------------------------------------------------------------------------- /components/AuthGuard/AuthGuard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | import React, { useEffect } from 'react'; 5 | 6 | import { ContentTitle } from '../Content/ContentTitle'; 7 | import { LoadingScreen } from '../LoadingScreen/LoadingScreen'; 8 | 9 | import { useAuth } from './useAuth'; 10 | 11 | import type { ReactNode } from 'react'; 12 | 13 | type Props = { 14 | readonly userRole?: 'ADMIN'; 15 | readonly children?: ReactNode; 16 | }; 17 | 18 | export const AuthGuard: React.FC = ({ children, userRole: role }) => { 19 | const { isLoading, isLoggedIn, user } = useAuth(); 20 | const router = useRouter(); 21 | 22 | useEffect(() => { 23 | if (!isLoading && !isLoggedIn) { 24 | router.push('/login'); 25 | } 26 | }, [isLoggedIn, isLoading, router]); 27 | 28 | if (!isLoading && !isLoggedIn) { 29 | return null; 30 | } 31 | 32 | if (isLoading) { 33 | return ; 34 | } 35 | 36 | // Without role allow all authorized users 37 | if (!role && user) { 38 | return <>{children}; 39 | } 40 | 41 | if (role === 'ADMIN' && user?.member.role === 'ADMIN') { 42 | return <>{children}; 43 | } 44 | 45 | return ( 46 |
47 | Brak Uprawnień 48 |

49 | Nie masz odpowiednich uprawnień, żeby korzystać z tej podstrony. W celu weryfikacji 50 | skontaktuj się z administracją serwisu. 51 |

52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /components/AuthGuard/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '../../hooks/useQuery'; 2 | import { getMe } from '../../utils/api/getMe'; 3 | 4 | export type AuthHookRet = 5 | | { readonly isLoading: true; readonly isLoggedIn?: undefined; readonly user?: undefined } 6 | | { 7 | readonly isLoggedIn: false; 8 | readonly isLoading: false; 9 | readonly user?: undefined; 10 | } 11 | | { 12 | readonly isLoggedIn: true; 13 | readonly isLoading: false; 14 | readonly user: { 15 | readonly user: { 16 | readonly id: string; 17 | }; 18 | readonly member: { 19 | readonly id: string; 20 | readonly email: string; 21 | readonly role: string; 22 | }; 23 | }; 24 | }; 25 | 26 | export const useAuth = (): AuthHookRet => { 27 | const { value: me, status } = useQuery(getMe); 28 | 29 | if (typeof window === 'undefined') { 30 | return { isLoading: true }; 31 | } 32 | 33 | switch (status) { 34 | case 'loading': 35 | case 'idle': 36 | return { isLoading: true }; 37 | case 'error': 38 | return { isLoggedIn: false, isLoading: false }; 39 | case 'success': { 40 | if (!me?.user || !me.member) { 41 | return { isLoggedIn: false, isLoading: false }; 42 | } 43 | return { isLoggedIn: true, user: me, isLoading: false }; 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /components/AuthPage/AuthPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { Auth, ThemeSupa } from '@supabase/auth-ui-react'; 5 | 6 | import { useAuthGuard } from './useAuthGuard'; 7 | import { useAuthListener } from './useAuthListener'; 8 | 9 | export const AuthPage = () => { 10 | const supabase = createBrowserSupabaseClient(); 11 | const { authView } = useAuthListener(supabase); 12 | useAuthGuard(); 13 | 14 | return ( 15 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /components/AuthPage/useAuthGuard.ts: -------------------------------------------------------------------------------- 1 | import { useSession } from '@supabase/auth-helpers-react'; 2 | import { useRouter } from 'next/navigation'; 3 | import { useEffect } from 'react'; 4 | 5 | export const useAuthGuard = () => { 6 | const session = useSession(); 7 | const router = useRouter(); 8 | 9 | useEffect(() => { 10 | if (session) { 11 | void router.replace('/admin'); 12 | } 13 | }, [session, router]); 14 | }; 15 | -------------------------------------------------------------------------------- /components/AuthPage/useAuthListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import type { SupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import type { Auth } from '@supabase/auth-ui-react'; 5 | 6 | type ViewType = Parameters[0]['view']; 7 | 8 | export const useAuthListener = (supabase: SupabaseClient) => { 9 | const [authView, setAuthView] = useState('sign_in'); 10 | 11 | useEffect(() => { 12 | const { data: authListener } = supabase.auth.onAuthStateChange((event) => { 13 | if (event === 'PASSWORD_RECOVERY') { 14 | setAuthView('update_password'); 15 | } 16 | if (event === 'USER_UPDATED') { 17 | setTimeout(() => setAuthView('sign_in'), 1000); 18 | } 19 | }); 20 | 21 | return () => { 22 | authListener.subscription.unsubscribe(); 23 | }; 24 | }, [supabase.auth]); 25 | 26 | return { 27 | authView, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /components/BlogsGrid/BlogsGrid.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { fetchBlogsForGrid } from '../../utils/fetchBlogsForGrid'; 4 | import { ArticleTile } from '../ArticleTile/ArticleTile'; 5 | import { Pagination } from '../Pagination/Pagination'; 6 | 7 | type BlogsGridProps = { 8 | readonly page: string; 9 | }; 10 | 11 | export const BlogsGrid = async ({ page }: BlogsGridProps) => { 12 | const { blogs, ...rest } = await fetchBlogsForGrid(page); 13 | 14 | return ( 15 | <> 16 |
    20 | {blogs.map((blog) => ( 21 |
  • 22 |

    23 | 24 | {blog.name} 25 | 26 |

    27 | 28 |
      29 | {blog.articles.map((article) => ( 30 |
    • 31 | 32 |
    • 33 | ))} 34 |
    35 |
  • 36 | ))} 37 |
38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /components/BlogsList/BlogsList.tsx: -------------------------------------------------------------------------------- 1 | import { fetchArticlesForList } from '../../utils/fetchArticlesForList'; 2 | import { ArticleTile } from '../ArticleTile/ArticleTile'; 3 | import { Pagination } from '../Pagination/Pagination'; 4 | 5 | type BlogsListProps = { 6 | readonly page: string; 7 | }; 8 | 9 | export const BlogsList = async ({ page }: BlogsListProps) => { 10 | const { articles, ...rest } = await fetchArticlesForList(page); 11 | 12 | return ( 13 | <> 14 |
    15 | {articles.map((article) => ( 16 |
  • 17 | 18 |
  • 19 | ))} 20 |
21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import Clsx from 'clsx'; 2 | import Image from 'next/image'; 3 | import { memo, forwardRef } from 'react'; 4 | 5 | import type { ButtonHTMLAttributes } from 'react'; 6 | 7 | type ButtonProps = { 8 | readonly icon?: string; 9 | } & ButtonHTMLAttributes; 10 | 11 | export const Button = memo( 12 | forwardRef( 13 | ({ icon, children, disabled, className = '', ...props }, ref) => { 14 | return ( 15 | 32 | ); 33 | }, 34 | ), 35 | ); 36 | 37 | Button.displayName = 'Button'; 38 | -------------------------------------------------------------------------------- /components/ButtonAsLink/ButtonAsLink.tsx: -------------------------------------------------------------------------------- 1 | import Clsx from 'clsx'; 2 | import Image from 'next/image'; 3 | import Link from 'next/link'; 4 | import { memo, forwardRef } from 'react'; 5 | 6 | import type { AnchorHTMLAttributes } from 'react'; 7 | 8 | type ButtonAsLinkProps = { 9 | readonly icon?: string; 10 | readonly href: string; 11 | } & AnchorHTMLAttributes; 12 | 13 | export const ButtonAsLink = memo( 14 | forwardRef( 15 | ({ icon, children, className = '', ...props }, ref) => { 16 | return ( 17 | 25 | {icon && ( 26 |
27 | 28 |
29 | )} 30 | {children} 31 | 32 | ); 33 | }, 34 | ), 35 | ); 36 | 37 | ButtonAsLink.displayName = 'ButtonAsLink'; 38 | -------------------------------------------------------------------------------- /components/ChangeBlogsList/ChangeBlogsList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | 5 | import type { BlogsType } from '../../types'; 6 | import type { ChangeEvent } from 'react'; 7 | 8 | type ChangeBlogsListProps = { 9 | readonly type: BlogsType; 10 | }; 11 | 12 | export const ChangeBlogsList = ({ type }: ChangeBlogsListProps) => { 13 | const changeType = useChangeBlogsList(); 14 | 15 | return ( 16 | 26 | ); 27 | }; 28 | 29 | const useChangeBlogsList = () => { 30 | const router = useRouter(); 31 | 32 | const changeType = (event: ChangeEvent) => { 33 | const value = event.target.value; 34 | 35 | if (!checkValueHasCorrectType(value)) { 36 | return; 37 | } 38 | 39 | if (value === 'all') { 40 | return router.push('/admin'); 41 | } 42 | 43 | return router.push(`/admin/${value}`); 44 | }; 45 | 46 | return changeType; 47 | }; 48 | 49 | const checkValueHasCorrectType = (value: string): value is BlogsType => { 50 | return ['public', 'nonpublic', 'all'].includes(value); 51 | }; 52 | -------------------------------------------------------------------------------- /components/Content/Content.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | type ContentProps = { 4 | readonly children: ReactNode; 5 | }; 6 | 7 | export const Content = ({ children }: ContentProps) => { 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /components/Content/ContentNavigation.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | type ContentNavigationProps = { 4 | readonly children: ReactNode; 5 | }; 6 | 7 | export const ContentNavigation = ({ children }: ContentNavigationProps) => { 8 | return ( 9 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /components/Content/ContentTitle.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | type ContentTitleProps = { 4 | readonly children: ReactNode; 5 | }; 6 | 7 | export const ContentTitle = ({ children }: ContentTitleProps) => { 8 | return ( 9 |

10 | {children} 11 |

12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /components/CookiesPopup/CookiesPopup.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '../Button/Button'; 4 | 5 | import { useCookies } from './useCookies'; 6 | 7 | export const CookiesPopup = () => { 8 | const { accepted, accept } = useCookies(); 9 | 10 | if (accepted !== 'not-accepted') { 11 | return null; 12 | } 13 | 14 | return ( 15 |
16 |

17 | Ta strona, tak jak praktycznie każda w internecie, wykorzystuje ciasteczka. 18 |

19 | 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /components/CookiesPopup/useCookies.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '../../hooks/useLocalStorage'; 2 | 3 | type CookiesPreferences = 'not-accepted' | 'accepted'; 4 | 5 | export const useCookies = () => { 6 | const [accepted, setAccepted] = useLocalStorage( 7 | 'cookies-accepted', 8 | 'not-accepted', 9 | ); 10 | 11 | const accept = () => { 12 | setAccepted('accepted'); 13 | }; 14 | 15 | return { 16 | accepted, 17 | accept, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /components/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Link from 'next/link'; 3 | import { memo } from 'react'; 4 | 5 | const date = new Date(); 6 | const copyrightYear = date.getFullYear(); 7 | 8 | export const Footer = memo(() => { 9 | return ( 10 |
11 |
12 |
13 |

14 | Copyright@{copyrightYear} –{' '} 15 | 16 | Type of Web 17 | 18 |

19 | 20 | 21 | REGULAMIN 22 | 23 |
24 | 25 | 30 | 31 | 32 |
33 |
34 | ); 35 | }); 36 | 37 | Footer.displayName = 'Footer'; 38 | -------------------------------------------------------------------------------- /components/HeadTags.tsx: -------------------------------------------------------------------------------- 1 | import { getConfig } from '../api-helpers/config'; 2 | 3 | type HeadTagsProps = { 4 | readonly title?: string; 5 | readonly description?: string; 6 | readonly robots?: boolean; 7 | }; 8 | 9 | export const HeadTags = ({ 10 | title = 'Polski Frontend', 11 | description = 'Serwis Polski Frontend powstał w celu zebrania w jednym miejscu jak największej liczby stron, serwisów oraz blogów na temat szeroko rozumianego frontend developmentu. Co ważne, wszystkie zgromadzone tutaj serwisy tworzone są w języku polskim!', 12 | robots = true, 13 | }: HeadTagsProps) => { 14 | const parsedTitle = title.trim() ? `${title} • Polski Frontend` : `Polski Frontend`; 15 | 16 | return ( 17 | <> 18 | 19 | {parsedTitle} 20 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | export const Header = () => { 2 | return ( 3 |
4 |

5 | Wszystkie źródła informacji w jednym miejscu, po 6 | polsku! 7 |

8 | 9 | {/* Divider */} 10 |
11 | 12 |

13 | Szukaj interesujących Cię tematów wśród setek artykułów, wpisów i filmów dostępnych na 14 | polskich blogach i kanałach o programowaniu. 15 |

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /components/LoadingScreen/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | export const LoadingScreen = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /components/LogoutButton/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '../Button/Button'; 4 | 5 | import { useLogout } from './useLogout'; 6 | 7 | import type { ReactNode } from 'react'; 8 | 9 | type LogoutButtonProps = { 10 | readonly children: ReactNode; 11 | }; 12 | 13 | export const LogoutButton = ({ children }: LogoutButtonProps) => { 14 | const { logout } = useLogout(); 15 | 16 | return ( 17 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /components/LogoutButton/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient } from '@supabase/auth-helpers-react'; 2 | import { useRouter } from 'next/navigation'; 3 | 4 | export const useLogout = () => { 5 | const supabase = useSupabaseClient(); 6 | const router = useRouter(); 7 | 8 | const logout = async () => { 9 | const user = await supabase.auth.getUser(); 10 | 11 | if (!user.data.user) { 12 | return; 13 | } 14 | 15 | await supabase.auth.signOut(); 16 | 17 | router.push('/'); 18 | }; 19 | 20 | return { 21 | logout, 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /components/Navigation/MobileNavbar/MobileNavbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Clsx from 'clsx'; 4 | import Image from 'next/image'; 5 | 6 | import { ButtonAsLink } from '../../ButtonAsLink/ButtonAsLink'; 7 | import { NavItem } from '../NavItem'; 8 | import { links } from '../links'; 9 | 10 | import { useMobileNavbar } from './useMobileNavbar'; 11 | 12 | export const MobileNavbar = () => { 13 | const { isActive, close, open } = useMobileNavbar(); 14 | 15 | return ( 16 | <> 17 |
    24 | {links.map(({ label, href, openInNewTab, icon }) => ( 25 |
  • 26 | 35 | {label} 36 | 37 |
  • 38 | ))} 39 | 40 |
  • 41 | 42 | DODAJ SERWIS 43 | 44 |
  • 45 |
46 | 47 | 54 | 55 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /components/Navigation/MobileNavbar/useMobileNavbar.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export const useMobileNavbar = () => { 4 | const [isActive, setIsActive] = useState(false); 5 | 6 | const open = () => { 7 | setIsActive(true); 8 | }; 9 | 10 | const close = () => { 11 | setIsActive(false); 12 | }; 13 | 14 | return { 15 | isActive, 16 | open, 17 | close, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /components/Navigation/NavItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { clsx } from 'clsx'; 4 | import Image from 'next/image'; 5 | import Link from 'next/link'; 6 | import { usePathname } from 'next/navigation'; 7 | 8 | import type { AnchorHTMLAttributes, ReactNode } from 'react'; 9 | import type { UrlObject } from 'url'; 10 | 11 | type NavItemProps = { 12 | readonly href: string | UrlObject; 13 | readonly icon: string; 14 | readonly children: ReactNode; 15 | } & AnchorHTMLAttributes; 16 | 17 | export const NavItem = ({ href, icon, children, className, ...rest }: NavItemProps) => { 18 | const pathname = usePathname(); 19 | const isActive = detectIsActive(href, pathname); 20 | 21 | return ( 22 | 29 |
30 | 37 | {children} 38 |
39 | 45 | 46 | ); 47 | }; 48 | 49 | const detectIsActive = (href: string, pathname: string | null) => { 50 | if (href === '/') { 51 | return pathname?.includes('/list') || pathname?.includes('/grid') || href === pathname; 52 | } 53 | 54 | return href === pathname; 55 | }; 56 | -------------------------------------------------------------------------------- /components/Navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Link from 'next/link'; 3 | 4 | import { ButtonAsLink } from '../ButtonAsLink/ButtonAsLink'; 5 | 6 | import { MobileNavbar } from './MobileNavbar/MobileNavbar'; 7 | import { NavItem } from './NavItem'; 8 | import { links } from './links'; 9 | 10 | export const Navigation = () => ( 11 | 51 | ); 52 | -------------------------------------------------------------------------------- /components/Navigation/links.ts: -------------------------------------------------------------------------------- 1 | export const links = [ 2 | { 3 | href: '/', 4 | icon: 'home', 5 | label: 'HOME', 6 | openInNewTab: false, 7 | }, 8 | { 9 | href: '/o-serwisie', 10 | icon: 'question', 11 | label: 'O SERWISIE', 12 | openInNewTab: false, 13 | }, 14 | { 15 | href: 'https://facebook.com/polskifrontend', 16 | icon: 'facebook2', 17 | label: 'FACEBOOK', 18 | openInNewTab: true, 19 | }, 20 | { 21 | href: 'https://polskifrontend.pl/feed', 22 | icon: 'rss', 23 | label: 'RSS', 24 | openInNewTab: true, 25 | }, 26 | { 27 | href: 'https://discord.typeofweb.com', 28 | icon: 'discord', 29 | label: 'DISCORD', 30 | openInNewTab: true, 31 | }, 32 | { 33 | href: 'https://github.com/typeofweb/polskifrontend', 34 | icon: 'github', 35 | label: 'GITHUB', 36 | openInNewTab: true, 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /components/Pagination/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonAsLink } from '../ButtonAsLink/ButtonAsLink'; 2 | 3 | import type { DisplayStyle } from '../../types'; 4 | 5 | type PaginationProps = { 6 | readonly isFirstPage: boolean; 7 | readonly isLastPage: boolean; 8 | readonly displayStyle: DisplayStyle; 9 | readonly previousPage: number; 10 | readonly nextPage: number; 11 | }; 12 | 13 | export const Pagination = ({ 14 | isFirstPage, 15 | isLastPage, 16 | displayStyle, 17 | previousPage, 18 | nextPage, 19 | }: PaginationProps) => { 20 | return ( 21 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /components/Providers/AuthRedirectProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/navigation'; 2 | import { useEffect } from 'react'; 3 | 4 | import type { ReactNode } from 'react'; 5 | 6 | type AuthRedirectProviderProps = { 7 | readonly children: ReactNode; 8 | }; 9 | 10 | export const AuthRedirectProvider = ({ children }: AuthRedirectProviderProps) => { 11 | const router = useRouter(); 12 | 13 | useEffect(() => { 14 | // redirect for auth 15 | if (typeof window === 'undefined') { 16 | return; 17 | } 18 | 19 | // it might have 5 properties: access_token, expires_in, provider_token, refresh_token, token_type 20 | // we only care about 3 21 | const searchParams = new URLSearchParams(location.hash.slice(1)); 22 | const properties = ['access_token', 'provider_token', 'refresh_token']; 23 | const shouldRedirect = properties.some((p) => searchParams.has(p)); 24 | 25 | if (shouldRedirect) { 26 | router.push('/login'); 27 | } 28 | }, [router]); 29 | 30 | return <>{children}; 31 | }; 32 | -------------------------------------------------------------------------------- /components/Providers/Providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs'; 4 | import { SessionContextProvider } from '@supabase/auth-helpers-react'; 5 | import { useState } from 'react'; 6 | 7 | import { AuthRedirectProvider } from './AuthRedirectProvider'; 8 | 9 | import type { ReactNode } from 'react'; 10 | 11 | type ProvidersProps = { 12 | readonly children: ReactNode; 13 | }; 14 | 15 | export const Providers = ({ children }: ProvidersProps) => { 16 | const [supabaseClient] = useState(() => createBrowserSupabaseClient()); 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /components/SwitchDisplayStyle/SwitchDisplayStyle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { memo } from 'react'; 4 | 5 | import { useChangeDisplayStyle } from './useChangeDisplayStyle'; 6 | 7 | import type { DisplayPreferences } from '../../hooks/useDisplayPreferences'; 8 | 9 | type SwitchDisplayStyleProps = { 10 | readonly value: DisplayPreferences; 11 | }; 12 | 13 | export const SwitchDisplayStyle = memo(({ value }) => { 14 | const { changeDisplayToGrid, changeDisplayToList } = useChangeDisplayStyle(); 15 | 16 | return ( 17 |
18 | 30 | 31 | 41 |
42 | ); 43 | }); 44 | 45 | SwitchDisplayStyle.displayName = 'SwitchDisplayStyle'; 46 | -------------------------------------------------------------------------------- /components/SwitchDisplayStyle/useChangeDisplayStyle.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/navigation'; 2 | 3 | export const useChangeDisplayStyle = () => { 4 | const router = useRouter(); 5 | 6 | const changeDisplayToGrid = () => { 7 | router.push('/grid'); 8 | }; 9 | 10 | const changeDisplayToList = () => { 11 | router.push('/list'); 12 | }; 13 | 14 | return { 15 | changeDisplayToGrid, 16 | changeDisplayToList, 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /components/Table/Table.tsx: -------------------------------------------------------------------------------- 1 | import Styles from './table.module.scss'; 2 | 3 | import type { AdminTableBlogsRow } from '../../types'; 4 | 5 | const columns = [ 6 | ['link', 'Link do bloga'], 7 | ['name', 'Nazwa bloga'], 8 | ['isPublic', 'Widoczny'], 9 | ['creatorEmail', 'E-mail autora'], 10 | ['lastArticlePublishedAt', 'Ostatnia publikacja'], 11 | ['createdAt', 'Data zgłoszenia'], 12 | ['edit', 'Edytuj dane bloga'], 13 | ] as const; 14 | 15 | type TableProps = { 16 | readonly data: readonly AdminTableBlogsRow[]; 17 | }; 18 | 19 | const isDate = (v: unknown): v is Date => Object.prototype.toString.call(v) === '[object Date]'; 20 | 21 | export const Table = ({ data }: TableProps) => ( 22 |
23 |
24 | 25 | 26 | {columns.map(([key, label]) => ( 27 | 28 | ))} 29 | 30 | 31 | 32 | {data.map((row) => ( 33 | 34 | {columns.map(([key]) => { 35 | const v = row[key]; 36 | return ; 37 | })} 38 | 39 | ))} 40 | 41 |
{label}
{isDate(v) ? v.toISOString() : v}
42 | 43 | ); 44 | -------------------------------------------------------------------------------- /components/Table/table.module.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | text-align: center; 3 | position: relative; 4 | border-spacing: 0; 5 | width: 100%; 6 | 7 | thead > tr > th { 8 | background: var(--theme-primary); 9 | } 10 | 11 | th, 12 | td { 13 | padding: 0.5rem 1rem; 14 | border: 1px solid var(--gray-text-secondary); 15 | 16 | & + th, 17 | & + td { 18 | border-left: none; 19 | } 20 | } 21 | 22 | tr + tr { 23 | td, 24 | th { 25 | border-top: none; 26 | } 27 | } 28 | 29 | th { 30 | top: 0; 31 | position: sticky; 32 | background-color: var(--gray-border); 33 | } 34 | 35 | tbody > tr:hover { 36 | background-color: var(--gray-border); 37 | } 38 | } 39 | 40 | .tableWrapper { 41 | width: 100%; 42 | overflow: auto; 43 | /* 44 | In order to be able to make elements inside "sticky", the container has to have a fixed height 45 | https://uxdesign.cc/position-stuck-96c9f55d9526 46 | */ 47 | height: 80vh; 48 | -webkit-overflow-scrolling: touch; 49 | } 50 | -------------------------------------------------------------------------------- /components/UpdateBlogSection/DangerZone.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | 5 | import { useMutation } from '../../hooks/useMutation'; 6 | import { deleteBlog } from '../../utils/api/deleteBlog'; 7 | import { resetBlog } from '../../utils/api/resetBlog'; 8 | import { Button } from '../Button/Button'; 9 | 10 | type DangerZoneProps = { 11 | readonly blogId: string; 12 | }; 13 | 14 | export const DangerZone = ({ blogId }: DangerZoneProps) => { 15 | const deleteMutation = useMutation(deleteBlog); 16 | const resetMutation = useMutation(resetBlog); 17 | const router = useRouter(); 18 | 19 | async function handleDelete() { 20 | if ( 21 | window.confirm( 22 | 'Czy na pewno chcesz usunąć bloga? Wszystkie jego dane oraz wpisy zostaną nieodwracalnie usunięte.', 23 | ) 24 | ) { 25 | try { 26 | await deleteMutation.mutate(blogId); 27 | router.push('/admin'); 28 | } catch (err) { 29 | console.error(err); 30 | window.alert('Nie udało się usunąć bloga.'); 31 | } 32 | } 33 | } 34 | 35 | async function handleReset() { 36 | if ( 37 | window.confirm( 38 | 'Czy na pewno chcesz zresetować tego bloga? Wszystkie jego dane oraz wpisy zostaną usunięte i pobrane na nowo.', 39 | ) 40 | ) { 41 | try { 42 | await resetMutation.mutate(blogId); 43 | window.alert('Udało się!'); 44 | } catch (err) { 45 | console.error(err); 46 | window.alert('Nie udało się usunąć bloga.'); 47 | } 48 | } 49 | } 50 | 51 | return ( 52 |
53 |

Usuń bloga

54 |

55 | Blog oraz wszystkie jego artykuły zostaną nieodwracalnie usunięte z serwisu 56 | polskifrontend.pl 57 |

58 |
59 | 66 | 73 |
74 | {/** 75 | * @TODO Change RSS URL and remove all articles functionality 76 | */} 77 |
78 | ); 79 | }; 80 | DangerZone.displayName = 'DangerZone'; 81 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | //api-helpers / articles 2 | export const TILES_BLOGS_PER_PAGE = 4; 3 | export const TILES_ARTICLES_PER_BLOG = 5; 4 | export const LIST_ARTICLES_PER_PAGE = 20; 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | typeofweb_polskifrontend: 5 | image: postgres:11.5-alpine 6 | ports: 7 | - '5432:5432' 8 | environment: 9 | POSTGRES_USER: 'postgres' 10 | POSTGRES_DBNAME: 'polskifrontend' 11 | POSTGRES_PASSWORD: 'polskifrontend' 12 | volumes: 13 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 14 | -------------------------------------------------------------------------------- /hooks/useDidMount.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import type { EffectCallback } from 'react'; 4 | 5 | /* eslint-disable react-hooks/exhaustive-deps -- this is on purpose */ 6 | export const useDidMount = (cb: EffectCallback) => useEffect(cb, []); 7 | -------------------------------------------------------------------------------- /hooks/useDisplayPreferences.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | 4 | import { useLocalStorage } from './useLocalStorage'; 5 | 6 | export const displayPreferences = ['list', 'grid'] as const; 7 | export type DisplayPreferences = (typeof displayPreferences)[number]; 8 | 9 | export const useDisplayPreferences = () => { 10 | const router = useRouter(); 11 | const [display, setDisplay] = useLocalStorage('display-preferences', 'list'); 12 | 13 | useEffect(() => { 14 | if (display && router.asPath === '/') { 15 | void router.replace(`/${display}`); 16 | } 17 | }, [router, display]); 18 | 19 | const changeDisplayPreference = (displayPreference: DisplayPreferences) => { 20 | void router.replace(`/${displayPreference}`); 21 | setDisplay(displayPreference); 22 | }; 23 | 24 | return [changeDisplayPreference] as const; 25 | }; 26 | -------------------------------------------------------------------------------- /hooks/useLocalStorage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import { useDidMount } from './useDidMount'; 4 | 5 | export function useLocalStorage(key: string, defaultValue: T) { 6 | const [value, setValue] = useState(null); 7 | 8 | useDidMount(() => { 9 | try { 10 | const savedValue = localStorage.getItem(key); 11 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- @todo validate this 12 | setValue((savedValue as T) || defaultValue); 13 | } catch { 14 | setValue(defaultValue); 15 | } 16 | }); 17 | 18 | const setStoredValue = useCallback( 19 | (val: T) => { 20 | setValue(val); 21 | try { 22 | localStorage.setItem(key, val); 23 | } catch (err) { 24 | console.error(err); 25 | } 26 | }, 27 | [key], 28 | ); 29 | 30 | return [value, setStoredValue] as const; 31 | } 32 | -------------------------------------------------------------------------------- /hooks/useMutation.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import { ResponseError } from '../utils/fetcher'; 4 | 5 | export type Status = 'idle' | 'loading' | 'error' | 'success'; 6 | 7 | export function useMutation(mutation: (body: Body) => Promise) { 8 | const [status, setStatus] = useState<{ readonly status: Status; readonly errorCode?: number }>({ 9 | status: 'idle', 10 | }); 11 | 12 | const mutate = useCallback( 13 | async (body: Body) => { 14 | setStatus({ status: 'loading' }); 15 | try { 16 | await mutation(body); 17 | setStatus({ status: 'success' }); 18 | } catch (err) { 19 | if (err instanceof ResponseError) { 20 | setStatus({ status: 'error', errorCode: err.status }); 21 | } 22 | setStatus({ status: 'error' }); 23 | throw err; 24 | } 25 | }, 26 | [mutation], 27 | ); 28 | 29 | return { mutate, ...status } as const; 30 | } 31 | -------------------------------------------------------------------------------- /hooks/useQuery.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { ResponseError } from '../utils/fetcher'; 4 | 5 | type Status = 'idle' | 'loading' | 'success' | 'error'; 6 | 7 | type QueryData = { 8 | readonly value: T | null; 9 | readonly status: Status; 10 | readonly errorCode?: number; 11 | }; 12 | 13 | export function useQuery(queryFunc: () => Promise) { 14 | const [queryData, setQueryData] = useState>({ 15 | value: null, 16 | status: 'idle', 17 | }); 18 | 19 | useEffect(() => { 20 | setQueryData((queryData) => ({ ...queryData, status: 'loading' })); 21 | queryFunc() 22 | .then((data) => { 23 | setQueryData({ value: data, status: 'success' }); 24 | }) 25 | .catch((err) => { 26 | if (err instanceof ResponseError) { 27 | setQueryData({ value: null, status: 'error', errorCode: err.status }); 28 | } 29 | setQueryData({ value: null, status: 'error' }); 30 | }); 31 | }, [queryFunc]); 32 | 33 | return queryData; 34 | } 35 | -------------------------------------------------------------------------------- /init.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE polskifrontend; 2 | -------------------------------------------------------------------------------- /jest-setup-after.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | import { getPublicUrlFromRequest, mswMockServer } from './jest-utils'; 3 | 4 | beforeAll(() => { 5 | mswMockServer.listen({ 6 | onUnhandledRequest: (request) => { 7 | const publicUrl = getPublicUrlFromRequest(request); 8 | const message = `captured a ${ 9 | request.method 10 | } ${request.url.toString()} request without a corresponding request handler. 11 | If you wish to intercept this request, consider creating a request handler for it: 12 | rest.${request.method.toLowerCase()}('${publicUrl}', (req, res, ctx) => { 13 | return res(ctx.text('body')) 14 | })`; 15 | fail(message); 16 | }, 17 | }); 18 | }); 19 | 20 | afterEach(() => { 21 | mswMockServer.resetHandlers(); 22 | }); 23 | 24 | afterAll(() => { 25 | mswMockServer.close(); 26 | }); 27 | -------------------------------------------------------------------------------- /jest-utils.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | 3 | import type { MockedRequest } from 'msw'; 4 | 5 | export const getPublicUrlFromRequest = (request: MockedRequest) => { 6 | const { protocol, host, pathname, origin } = request.url; 7 | return request.referrer.startsWith(origin) ? pathname : `${protocol}://${host}${pathname}`; 8 | }; 9 | 10 | export const mswMockServer = setupServer(); 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest'); 2 | 3 | const createJestConfig = nextJest({ 4 | dir: './', 5 | }); 6 | 7 | // Add any custom config to be passed to Jest 8 | const customJestConfig = { 9 | moduleDirectories: ['node_modules', '/'], 10 | testEnvironment: 'jest-environment-jsdom', 11 | testPathIgnorePatterns: ['[/\\\\](node_modules|.next)[/\\\\]', '/cypress/'], 12 | moduleNameMapper: { 13 | '\\.(css|less|sass|scss)$': 'identity-obj-proxy', 14 | '\\.(gif|ttf|eot|svg|png)$': '/test/__mocks__/fileMock.js', 15 | }, 16 | setupFilesAfterEnv: ['next', './jest-setup-after.ts'], 17 | testTimeout: 10000, 18 | }; 19 | 20 | module.exports = createJestConfig(customJestConfig); 21 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | import type { NextRequest } from 'next/server'; 5 | 6 | export async function middleware(req: NextRequest) { 7 | const res = NextResponse.next(); 8 | 9 | const supabase = createMiddlewareSupabaseClient({ req, res }); 10 | 11 | // this will refresh user's session 12 | await supabase.auth.getSession(); 13 | 14 | return res; 15 | } 16 | 17 | export const config = { 18 | matcher: ['/admin', '/admin/:path*'], 19 | }; 20 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // const { VERCEL_GITHUB_COMMIT_SHA, VERCEL_GITLAB_COMMIT_SHA, VERCEL_BITBUCKET_COMMIT_SHA, ANALYZE } = 4 | // process.env; 5 | 6 | // const COMMIT_SHA = 7 | // VERCEL_GITHUB_COMMIT_SHA || VERCEL_GITLAB_COMMIT_SHA || VERCEL_BITBUCKET_COMMIT_SHA; 8 | 9 | const config = { 10 | reactStrictMode: true, 11 | poweredByHeader: false, 12 | async rewrites() { 13 | return [ 14 | { 15 | source: '/', 16 | destination: '/grid/', 17 | }, 18 | { 19 | source: '/feed', 20 | destination: '/api/feed', 21 | }, 22 | { 23 | source: '/sitemap.xml', 24 | destination: '/api/sitemap', 25 | }, 26 | ]; 27 | }, 28 | experimental: { 29 | appDir: true, 30 | }, 31 | productionBrowserSourceMaps: true, 32 | images: { 33 | domains: ['res.cloudinary.com'], 34 | }, 35 | eslint: { 36 | dirs: ['.'], 37 | }, 38 | }; 39 | 40 | module.exports = config; 41 | -------------------------------------------------------------------------------- /pages/api/auth/me.ts: -------------------------------------------------------------------------------- 1 | import { withAsync, withAuth, withMethods } from '../../../api-helpers/api-hofs'; 2 | 3 | export default withAsync( 4 | withAuth()( 5 | withMethods({ 6 | async GET(req) { 7 | const user = req.session.user; 8 | 9 | const member = await req.db.member.findUnique({ 10 | where: { 11 | id: user.id, 12 | }, 13 | }); 14 | 15 | return { 16 | data: { 17 | user, 18 | member, 19 | }, 20 | }; 21 | }, 22 | }), 23 | ), 24 | ); 25 | -------------------------------------------------------------------------------- /pages/api/blogs/[blogId].ts: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom'; 2 | import { boolean, object, string } from 'yup'; 3 | 4 | import { withAsync, withValidation, withAuth, withMethods } from '../../../api-helpers/api-hofs'; 5 | import { addContentCreator } from '../../../api-helpers/contentCreatorFunctions'; 6 | 7 | import type { InferType } from 'yup'; 8 | 9 | const cuidValidator = string() 10 | .matches(/^c[a-zA-Z0-9]{24}$/) 11 | .required(); 12 | 13 | const blogIdRequestBody = object({ 14 | href: string().url().required(), 15 | creatorEmail: string().nullable(), 16 | isPublic: boolean().required(), 17 | }).required(); 18 | export type BlogIdRequestBody = InferType; 19 | 20 | export default withAsync( 21 | withAuth('ADMIN')( 22 | withMethods({ 23 | GET: withValidation({ 24 | query: object({ 25 | blogId: cuidValidator, 26 | }), 27 | })(async (req) => { 28 | const blog = await req.db.blog.findUnique({ 29 | where: { 30 | id: req.query.blogId, 31 | }, 32 | }); 33 | 34 | if (blog) { 35 | return blog; 36 | } 37 | 38 | throw Boom.notFound(); 39 | }), 40 | 41 | PUT: withValidation({ 42 | query: object({ 43 | blogId: cuidValidator, 44 | }), 45 | body: blogIdRequestBody, 46 | })(async (req) => { 47 | const blog = await req.db.blog.update({ 48 | where: { 49 | id: req.query.blogId, 50 | }, 51 | data: req.body, 52 | }); 53 | 54 | return blog; 55 | }), 56 | 57 | PATCH: withValidation({ 58 | query: object({ 59 | blogId: cuidValidator, 60 | }), 61 | })(async (req) => { 62 | const existingBlog = await req.db.blog.findUnique({ where: { id: req.query.blogId } }); 63 | await req.db.article.deleteMany({ where: { blogId: req.query.blogId } }); 64 | await req.db.blog.delete({ where: { id: req.query.blogId } }); 65 | 66 | if (!existingBlog?.creatorEmail) { 67 | return null; 68 | } 69 | 70 | const blog = await addContentCreator(existingBlog.href, existingBlog.creatorEmail, req.db); 71 | 72 | const savedBlog = await req.db.blog.update({ 73 | data: { ...blog, isPublic: existingBlog.isPublic }, 74 | where: { id: blog.id }, 75 | }); 76 | 77 | return savedBlog; 78 | }), 79 | 80 | DELETE: withValidation({ 81 | query: object({ 82 | blogId: cuidValidator, 83 | }), 84 | })(async (req) => { 85 | await req.db.article.deleteMany({ where: { blogId: req.query.blogId } }); 86 | await req.db.blog.delete({ where: { id: req.query.blogId } }); 87 | 88 | return null; 89 | }), 90 | }), 91 | ), 92 | ); 93 | -------------------------------------------------------------------------------- /pages/api/blogs/index.ts: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom'; 2 | import { boolean, object } from 'yup'; 3 | 4 | import { withAsync, withValidation, withAuth } from '../../../api-helpers/api-hofs'; 5 | 6 | export default withAsync( 7 | withAuth('ADMIN')( 8 | withValidation({ 9 | query: object({ 10 | isPublic: boolean(), 11 | }).optional(), 12 | })(async (req) => { 13 | if (req.method !== 'GET') { 14 | throw Boom.notFound(); 15 | } 16 | 17 | const blogs = await req.db.blog.findMany({ 18 | where: { 19 | isPublic: req.query?.isPublic, 20 | }, 21 | orderBy: { 22 | id: 'desc', 23 | }, 24 | }); 25 | 26 | return { 27 | data: blogs, 28 | }; 29 | }), 30 | ), 31 | ); 32 | -------------------------------------------------------------------------------- /pages/api/content-creator.ts: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom'; 2 | import { verify } from 'hcaptcha'; 3 | import { string, object } from 'yup'; 4 | 5 | import { withAsync, withDb, withMethods, withValidation } from '../../api-helpers/api-hofs'; 6 | import { getConfig } from '../../api-helpers/config'; 7 | import { addContentCreator } from '../../api-helpers/contentCreatorFunctions'; 8 | import { sendNewCreatorNotification } from '../../api-helpers/external-services/mailgun'; 9 | 10 | export default withAsync( 11 | withMethods({ 12 | POST: withValidation({ 13 | body: object({ 14 | email: string().email().required(), 15 | contentURL: string().url().required(), 16 | captchaToken: string().required(), 17 | }).required(), 18 | })( 19 | withDb(async (req) => { 20 | const isTokenValid = await verify(getConfig('CAPTCHA_SECRET_KEY'), req.body.captchaToken); 21 | if (!isTokenValid) { 22 | throw Boom.unauthorized(); 23 | } 24 | 25 | // normalize url 26 | const url = new URL(req.body.contentURL).toString(); 27 | 28 | const contentCreator = await addContentCreator(url, req.body.email, req.db); 29 | await sendNewCreatorNotification(contentCreator, req.db).catch(() => {}); 30 | 31 | return null; 32 | }), 33 | ), 34 | }), 35 | ); 36 | -------------------------------------------------------------------------------- /pages/api/feed.ts: -------------------------------------------------------------------------------- 1 | import { withAsync, withDb, withMethods } from '../../api-helpers/api-hofs'; 2 | import { getGeneralFeed } from '../../api-helpers/general-feed'; 3 | 4 | export default withAsync( 5 | withMethods({ 6 | GET: withDb(async (req, res) => { 7 | const feed = await getGeneralFeed(req.db); 8 | 9 | res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8'); 10 | res.setHeader('Access-Control-Allow-Origin', '*'); 11 | // 900 equals revalidation time (15 minutes) 12 | res.setHeader('Cache-Control', `s-maxage=900, stale-while-revalidate`); 13 | 14 | res.send(feed); 15 | 16 | return null; 17 | }), 18 | }), 19 | ); 20 | -------------------------------------------------------------------------------- /pages/api/index.ts: -------------------------------------------------------------------------------- 1 | import { withAsync, withDb } from '../../api-helpers/api-hofs'; 2 | 3 | export default withAsync( 4 | withDb((req) => { 5 | return req.db.$queryRaw`SELECT 1 + 1;`; 6 | }), 7 | ); 8 | -------------------------------------------------------------------------------- /pages/api/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { withAsync, withDb, withMethods } from '../../api-helpers/api-hofs'; 2 | import { getSitemap } from '../../api-helpers/sitemap'; 3 | 4 | export default withAsync( 5 | withMethods({ 6 | GET: withDb(async (req, res) => { 7 | const sitemap = await getSitemap(req.db); 8 | 9 | res.setHeader('Content-Type', 'application/xml; charset=utf-8'); 10 | // 900 equals revalidation time (15 minutes) 11 | // revalidate a bit less frequently than usual 12 | res.setHeader('Cache-Control', `s-maxage=${900 * 4}, stale-while-revalidate`); 13 | 14 | res.send(sitemap); 15 | 16 | return null; 17 | }), 18 | }), 19 | ); 20 | -------------------------------------------------------------------------------- /pages/api/update-algolia.ts: -------------------------------------------------------------------------------- 1 | import Algoliasearch from 'algoliasearch'; 2 | import * as AllHtmlEntities from 'html-entities'; 3 | import Xss from 'xss'; 4 | import { object, string, mixed } from 'yup'; 5 | 6 | import { withAsync, withDb, withMethods, withValidation } from '../../api-helpers/api-hofs'; 7 | import { getConfig } from '../../api-helpers/config'; 8 | 9 | import type { Article } from '@prisma/client'; 10 | 11 | const client = Algoliasearch( 12 | getConfig('NEXT_PUBLIC_ALGOLIA_APP_ID'), 13 | getConfig('ALGOLIA_API_SECRET'), 14 | ); 15 | const algoliaIndex = client.initIndex(getConfig('NEXT_PUBLIC_ALGOLIA_INDEX_NAME')); 16 | 17 | type ToJson = T extends Date 18 | ? string 19 | : T extends object 20 | ? { 21 | readonly [K in keyof T]: ToJson; 22 | } 23 | : T; 24 | 25 | interface Hit { 26 | readonly objectID: string; 27 | readonly href: string; 28 | readonly publishedAt: string; 29 | readonly slug?: string | null; 30 | readonly description?: string | null; 31 | readonly title: string; 32 | readonly blog: { 33 | readonly name: string; 34 | readonly href: string; 35 | readonly favicon?: string | null; 36 | }; 37 | } 38 | 39 | type InsertPayload = { 40 | readonly type: 'INSERT'; 41 | readonly table: 'Article'; 42 | readonly schema: 'public'; 43 | readonly record: ToJson
; 44 | readonly old_record: null; 45 | }; 46 | 47 | type UpdatePayload = { 48 | readonly type: 'UPDATE'; 49 | readonly table: 'Article'; 50 | readonly schema: 'public'; 51 | readonly record: ToJson
; 52 | readonly old_record: ToJson
; 53 | }; 54 | 55 | type DeletePayload = { 56 | readonly type: 'DELETE'; 57 | readonly table: 'Article'; 58 | readonly schema: 'public'; 59 | readonly record: null; 60 | readonly old_record: ToJson
; 61 | }; 62 | 63 | type Payload = InsertPayload | UpdatePayload | DeletePayload; 64 | 65 | const commonSchema = object({ 66 | table: mixed<'Article'>().required().oneOf(['Article']), 67 | schema: string().required(), 68 | }).required(); 69 | 70 | const articleSchema = object({}); 71 | 72 | const insertPayloadSchema = commonSchema.shape({ 73 | type: mixed().required().oneOf(['INSERT']), 74 | record: articleSchema.required(), 75 | old_record: mixed<{}>().nullable(), 76 | }); 77 | const updatePayloadSchema = commonSchema.shape({ 78 | type: mixed().required().oneOf(['UPDATE']), 79 | record: articleSchema.required(), 80 | old_record: articleSchema.required(), 81 | }); 82 | const deletePayloadSchema = commonSchema.shape({ 83 | type: mixed().required().oneOf(['DELETE']), 84 | record: mixed<{}>().nullable(), 85 | old_record: articleSchema.required(), 86 | }); 87 | 88 | export default withAsync( 89 | withMethods({ 90 | POST: withValidation({ 91 | body: mixed() 92 | .required() 93 | .test('shape', 'value', (data) => { 94 | return ( 95 | insertPayloadSchema.isValidSync(data) || 96 | updatePayloadSchema.isValidSync(data) || 97 | deletePayloadSchema.isValidSync(data) 98 | ); 99 | }), 100 | })( 101 | withDb(async (req) => { 102 | if (req.body.type === 'DELETE') { 103 | return algoliaIndex.deleteObject(req.body.old_record.id); 104 | } else { 105 | const articleToSubmit = await req.db.article.findFirst({ 106 | where: { 107 | id: req.body.record.id, 108 | }, 109 | select: { 110 | id: true, 111 | title: true, 112 | href: true, 113 | description: true, 114 | publishedAt: true, 115 | slug: true, 116 | blog: { 117 | select: { 118 | name: true, 119 | href: true, 120 | favicon: true, 121 | }, 122 | }, 123 | }, 124 | }); 125 | 126 | if (!articleToSubmit) { 127 | return null; 128 | } 129 | 130 | const { id: objectID, description, title, publishedAt, ...rest } = articleToSubmit; 131 | 132 | const hit: Hit = { 133 | ...rest, 134 | objectID, 135 | publishedAt: String(publishedAt), 136 | description: AllHtmlEntities.decode( 137 | Xss(description ?? '', { stripIgnoreTag: true, whiteList: {} }), 138 | ), 139 | title: AllHtmlEntities.decode(Xss(title, { stripIgnoreTag: true, whiteList: {} })), 140 | }; 141 | 142 | return algoliaIndex.saveObject(hit, { autoGenerateObjectIDIfNotExist: false }); 143 | } 144 | }), 145 | ), 146 | }), 147 | ); 148 | -------------------------------------------------------------------------------- /pages/api/update-blogs.ts: -------------------------------------------------------------------------------- 1 | import Crypto from 'crypto'; 2 | 3 | import Boom from '@hapi/boom'; 4 | import { string, object } from 'yup'; 5 | 6 | import { withAsync, withDb, withMethods, withValidation } from '../../api-helpers/api-hofs'; 7 | import { getConfig } from '../../api-helpers/config'; 8 | import { updateBlogs } from '../../api-helpers/feedFunctions'; 9 | 10 | export default withAsync( 11 | withMethods({ 12 | PATCH: withValidation({ 13 | body: object({ secret: string().required() }).required(), 14 | })( 15 | withDb(async (req) => { 16 | try { 17 | const isSecretValid = Crypto.timingSafeEqual( 18 | Buffer.from(req.body.secret), 19 | Buffer.from(getConfig('FEED_UPDATE_SECRET')), 20 | ); 21 | 22 | if (!isSecretValid) { 23 | throw Boom.unauthorized(); 24 | } 25 | } catch (err) { 26 | throw Boom.isBoom(err) ? err : Boom.unauthorized(); 27 | } 28 | 29 | await updateBlogs(req.db); 30 | 31 | return null; 32 | }), 33 | ), 34 | }), 35 | ); 36 | -------------------------------------------------------------------------------- /pages/api/update-feed.ts: -------------------------------------------------------------------------------- 1 | import Crypto from 'crypto'; 2 | 3 | import Boom from '@hapi/boom'; 4 | import { string, object } from 'yup'; 5 | 6 | import { withAsync, withDb, withMethods, withValidation } from '../../api-helpers/api-hofs'; 7 | import { getConfig } from '../../api-helpers/config'; 8 | import { updateFeeds } from '../../api-helpers/feedFunctions'; 9 | 10 | export default withAsync( 11 | withMethods({ 12 | PATCH: withValidation({ 13 | body: object({ secret: string().required() }).required(), 14 | })( 15 | withDb(async (req) => { 16 | try { 17 | const isSecretValid = Crypto.timingSafeEqual( 18 | Buffer.from(req.body.secret), 19 | Buffer.from(getConfig('FEED_UPDATE_SECRET')), 20 | ); 21 | 22 | if (!isSecretValid) { 23 | throw Boom.unauthorized(); 24 | } 25 | } catch (err) { 26 | throw Boom.isBoom(err) ? err : Boom.unauthorized(); 27 | } 28 | 29 | await updateFeeds(req.db); 30 | return null; 31 | }), 32 | ), 33 | }), 34 | ); 35 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('prettier-plugin-tailwindcss')], 3 | tailwindConfig: './tailwind.config.js', 4 | }; 5 | -------------------------------------------------------------------------------- /public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/android-icon-144x144.png -------------------------------------------------------------------------------- /public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/android-icon-192x192.png -------------------------------------------------------------------------------- /public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/android-icon-36x36.png -------------------------------------------------------------------------------- /public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/android-icon-48x48.png -------------------------------------------------------------------------------- /public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/android-icon-72x72.png -------------------------------------------------------------------------------- /public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/android-icon-96x96.png -------------------------------------------------------------------------------- /public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/apple-icon.png -------------------------------------------------------------------------------- /public/background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/bg-tiles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/favicon.ico -------------------------------------------------------------------------------- /public/icons/arrow-left2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrow-left2 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/arrow-right2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrow-right2 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | checkmark 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | cross 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/discord.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | discord 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/exit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | exit 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/facebook2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | facebook2 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | github 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | home 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | link 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/icons/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | menu 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | plus 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | question 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/rss.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | rss 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | search 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/spinner3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | spinner3 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/logo.png -------------------------------------------------------------------------------- /public/logo_og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/logo_og.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Polski Frontend", 3 | "description": "Serwis Polski Frontend powstał w celu zebrania w jednym miejscu jak największej liczby stron, serwisów oraz blogów na temat szeroko rozumianego frontend developmentu. Co ważne, wszystkie zgromadzone tutaj serwisy tworzone są w języku polskim!", 4 | "background_color": "#ffffff", 5 | "theme_color": "#5ab783", 6 | "icons": [ 7 | { 8 | "src": "/android-icon-36x36.png", 9 | "sizes": "36x36", 10 | "type": "image/png", 11 | "density": "0.75" 12 | }, 13 | { 14 | "src": "/android-icon-48x48.png", 15 | "sizes": "48x48", 16 | "type": "image/png", 17 | "density": "1.0" 18 | }, 19 | { 20 | "src": "/android-icon-72x72.png", 21 | "sizes": "72x72", 22 | "type": "image/png", 23 | "density": "1.5" 24 | }, 25 | { 26 | "src": "/android-icon-96x96.png", 27 | "sizes": "96x96", 28 | "type": "image/png", 29 | "density": "2.0" 30 | }, 31 | { 32 | "src": "/android-icon-144x144.png", 33 | "sizes": "144x144", 34 | "type": "image/png", 35 | "density": "3.0" 36 | }, 37 | { 38 | "src": "/android-icon-192x192.png", 39 | "sizes": "192x192", 40 | "type": "image/png", 41 | "density": "4.0" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /admin 3 | 4 | Sitemap: https://polskifrontend.pl/sitemap.xml 5 | -------------------------------------------------------------------------------- /stats-config.js: -------------------------------------------------------------------------------- 1 | const clientGlobs = [ 2 | { 3 | name: 'Client Bundles (main, webpack, commons)', 4 | globs: ['.next/static/runtime/+(main|webpack)-*', '.next/static/chunks/!(polyfills*)'], 5 | }, 6 | { 7 | name: 'Legacy Client Bundles (polyfills)', 8 | globs: ['.next/static/chunks/+(polyfills)-*'], 9 | }, 10 | { 11 | name: 'Client Pages', 12 | globs: ['.next/static/*/pages/**/*'], 13 | }, 14 | { 15 | name: 'Client Build Manifests', 16 | globs: ['.next/static/*/_buildManifest*'], 17 | }, 18 | { 19 | name: 'Rendered Page Sizes', 20 | globs: ['fetched-pages/**/*.html'], 21 | }, 22 | ]; 23 | 24 | const renames = [ 25 | { 26 | srcGlob: '.next/static/*/pages', 27 | dest: '.next/static/BUILD_ID/pages', 28 | }, 29 | { 30 | srcGlob: '.next/static/runtime/main-*', 31 | dest: '.next/static/runtime/main-HASH.js', 32 | }, 33 | { 34 | srcGlob: '.next/static/runtime/webpack-*', 35 | dest: '.next/static/runtime/webpack-HASH.js', 36 | }, 37 | { 38 | srcGlob: '.next/static/runtime/polyfills-*', 39 | dest: '.next/static/runtime/polyfills-HASH.js', 40 | }, 41 | { 42 | srcGlob: '.next/static/chunks/commons*', 43 | dest: '.next/static/chunks/commons.HASH.js', 44 | }, 45 | { 46 | srcGlob: '.next/static/chunks/framework*', 47 | dest: '.next/static/chunks/framework.HASH.js', 48 | }, 49 | // misc 50 | { 51 | srcGlob: '.next/static/*/_buildManifest.js', 52 | dest: '.next/static/BUILD_ID/_buildManifest.js', 53 | }, 54 | ]; 55 | 56 | module.exports = { 57 | // the Heading to show at the top of stats comments 58 | commentHeading: 'Stats from current PR', 59 | commentReleaseHeading: 'Stats from current release', 60 | // the command to build the app (app source should be in `.stats-app`) 61 | appBuildCommand: 'cp .env-sample .env && NEXT_TELEMETRY_DISABLED=1 pnpm next build', 62 | appStartCommand: 'NEXT_TELEMETRY_DISABLED=1 pnpm next start --port $PORT', 63 | // the main branch to compare against (what PRs will be merging into) 64 | mainBranch: 'develop', 65 | // the main repository path (relative to https://github.com/) 66 | mainRepo: 'typeofweb/polskifrontend', 67 | // whether to attempt auto merging the main branch into PR before running stats 68 | autoMergeMain: false, 69 | // an array of configs for each run 70 | configs: [ 71 | { 72 | // first run's config 73 | // title of the run 74 | title: 'App', 75 | // whether to diff the outputted files (default: onOutputChange) 76 | diff: 'onOutputChange', 77 | // renames to apply to make file names deterministic 78 | renames, 79 | // an array of file groups to diff/track 80 | filesToTrack: clientGlobs, 81 | // an array of URLs to fetch while `appStartCommand` is running 82 | // will be output to fetched-pages/${pathname}.html 83 | pagesToFetch: [ 84 | 'http://localhost:$PORT/', 85 | 'http://localhost:$PORT/list', 86 | 'http://localhost:$PORT/zglos-serwis', 87 | ], 88 | }, 89 | ], 90 | }; 91 | -------------------------------------------------------------------------------- /styles/global.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-base: #ef516d; 3 | --primary-light: #{lighten(#ef516d, 20%)}; 4 | --primary-dark: #b5263f; 5 | --theme-primary: #ffffff; 6 | --theme-secondary: #f2f2f2; 7 | --gray-primary: #505050; 8 | --gray-secondary: #616161; 9 | --gray-light: #999999; 10 | --black-light: #{lighten(#000000, 20%)}; 11 | --error-color: #f16262; 12 | } 13 | 14 | body { 15 | padding-top: constant(safe-area-inset-top); 16 | padding-top: env(safe-area-inset-top); 17 | } 18 | 19 | main { 20 | padding: 0 constant(safe-area-inset-right) 0 constant(safe-area-inset-left); 21 | padding: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left); 22 | } 23 | 24 | a[href], 25 | area[href], 26 | input:not([disabled]), 27 | select:not([disabled]), 28 | textarea:not([disabled]), 29 | button:not([disabled]), 30 | iframe, 31 | [tabindex], 32 | [contentEditable='true'] { 33 | &:not([tabindex='-1']) { 34 | &:focus { 35 | outline: none; 36 | } 37 | &:focus-visible { 38 | outline: 2px dashed var(--primary-base); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .navigation-container, 7 | .header-container, 8 | .content-container-base, 9 | .content-container-lg, 10 | .footer-container { 11 | @apply mx-auto w-full px-3 md:px-5; 12 | } 13 | .navigation-container { 14 | @apply md:max-w-7xl; 15 | } 16 | 17 | .header-container { 18 | @apply md:max-w-5xl; 19 | } 20 | 21 | .content-container-base { 22 | @apply md:max-w-5xl; 23 | } 24 | 25 | .content-container-lg { 26 | @apply md:max-w-6xl; 27 | } 28 | 29 | .footer-container { 30 | @apply md:max-w-7xl; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the working 2 | # directory name when running `supabase init`. 3 | project_id = "polskifrontend" 4 | 5 | [api] 6 | # Port to use for the API URL. 7 | port = 54321 8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 9 | # endpoints. public and storage are always included. 10 | schemas = [] 11 | # Extra schemas to add to the search_path of every request. 12 | extra_search_path = ["extensions"] 13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 14 | # for accidental or malicious requests. 15 | max_rows = 1000 16 | 17 | [db] 18 | # Port to use for the local database URL. 19 | port = 54322 20 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 21 | # server_version;` on the remote database to check. 22 | major_version = 14 23 | 24 | [studio] 25 | # Port to use for Supabase Studio. 26 | port = 54323 27 | 28 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 29 | # are monitored, and you can view the emails that would have been sent from the web interface. 30 | [inbucket] 31 | # Port to use for the email testing server web interface. 32 | port = 54324 33 | smtp_port = 54325 34 | pop3_port = 54326 35 | 36 | [storage] 37 | # The maximum file size allowed (e.g. "5MB", "500KB"). 38 | file_size_limit = "50MiB" 39 | 40 | [auth] 41 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 42 | # in emails. 43 | site_url = "http://localhost:3000" 44 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 45 | additional_redirect_urls = ["https://localhost:3000"] 46 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one 47 | # week). 48 | jwt_expiry = 3600 49 | # Allow/disallow new user signups to your project. 50 | enable_signup = true 51 | 52 | [auth.email] 53 | # Allow/disallow new user signups via email to your project. 54 | enable_signup = true 55 | # If enabled, a user will be required to confirm any email change on both the old, and new email 56 | # addresses. If disabled, only the new email is required to confirm. 57 | double_confirm_changes = true 58 | # If enabled, users need to confirm their email address before signing in. 59 | enable_confirmations = false 60 | 61 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 62 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch`, `twitter`, `slack`, `spotify`. 63 | [auth.external.apple] 64 | enabled = false 65 | client_id = "" 66 | secret = "" 67 | # Overrides the default auth redirectUrl. 68 | redirect_uri = "" 69 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 70 | # or any other third-party OIDC providers. 71 | url = "" 72 | -------------------------------------------------------------------------------- /supabase/seed.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/polskifrontend/e226b6fac6d71fb507396b02b74a9a9ae5f00c9e/supabase/seed.sql -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 6 | theme: { 7 | extend: { 8 | colors: { 9 | primary: { 10 | base: 'var(--primary-base)', 11 | light: 'var(--primary-light)', 12 | dark: 'var(--primary-dark)', 13 | }, 14 | theme: { 15 | primary: 'var(--theme-primary)', 16 | secondary: 'var(--theme-secondary)', 17 | }, 18 | gray: { 19 | primary: 'var(--gray-primary)', 20 | secondary: 'var(--gray-secondary)', 21 | light: 'var(--gray-light)', 22 | }, 23 | 'black-light': 'var(--black-light)', 24 | 'error-color': '#f16262', 25 | }, 26 | fontFamily: { 27 | sans: ['var(--font-roboto)', ...defaultTheme.fontFamily.sans], 28 | }, 29 | }, 30 | }, 31 | plugins: [require('@tailwindcss/line-clamp'), require('@tailwindcss/typography')], 32 | }; 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "jsx": "preserve", 9 | "lib": ["dom", "dom.iterable", "esnext"], 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noEmit": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitReturns": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "es2019", 21 | "allowJs": true, 22 | "plugins": [ 23 | { 24 | "name": "typescript-plugin-css-modules" 25 | }, 26 | { 27 | "name": "next" 28 | } 29 | ], 30 | "incremental": true 31 | }, 32 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"], 33 | "exclude": ["node_modules", "cypress"] 34 | } 35 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import type { fetchAdminBlogsList } from './utils/fetchAdminBlogsList'; 2 | import type { fetchArticlesForList } from './utils/fetchArticlesForList'; 3 | import type { fetchBlogsForGrid } from './utils/fetchBlogsForGrid'; 4 | import type { addSanitizedDescriptionToArticle } from './utils/sanitize-utils'; 5 | 6 | export type SanitizedArticle = ReturnType; 7 | export type Article = Awaited>['articles'][number]; 8 | export type Blog = Awaited>['blogs'][number]; 9 | export type ArticleFromBlog = Blog['articles'][0]; 10 | export type BlogFromArticle = Article['blog']; 11 | export type AdminTableBlogsRow = Awaited>[number]; 12 | export type BlogsType = 'public' | 'nonpublic' | 'all'; 13 | 14 | export type DisplayStyle = 'list' | 'grid'; 15 | -------------------------------------------------------------------------------- /utils/api/addContentCreator.ts: -------------------------------------------------------------------------------- 1 | export type ContentCreatorReqBody = { 2 | readonly contentURL: string; 3 | readonly email?: string; 4 | readonly captchaToken: string; 5 | }; 6 | 7 | export const addContentCreator = (requestBody: ContentCreatorReqBody) => { 8 | return fetch(`/api/content-creator`, { 9 | method: 'POST', 10 | body: JSON.stringify(requestBody), 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /utils/api/deleteBlog.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from '../fetcher'; 2 | 3 | export const deleteBlog = (blogId: string) => { 4 | return fetcher(`/api/blogs/${blogId}`, { 5 | method: 'DELETE', 6 | schema: null, 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /utils/api/getBlog.ts: -------------------------------------------------------------------------------- 1 | import { blogSchema } from '../blog-schema-api'; 2 | import { fetcher } from '../fetcher'; 3 | 4 | export const getBlog = (blogId: string) => { 5 | return fetcher(`/api/blogs/${blogId}`, { method: 'GET', schema: blogSchema }); 6 | }; 7 | -------------------------------------------------------------------------------- /utils/api/getBlogs.ts: -------------------------------------------------------------------------------- 1 | import { object, array } from 'yup'; 2 | 3 | import { blogSchema } from '../blog-schema-api'; 4 | import { fetcher } from '../fetcher'; 5 | 6 | const getBlogsSchema = object({ 7 | data: array(blogSchema), 8 | }); 9 | 10 | export const getBlogs = async (isPublic: 'true' | 'false' | undefined | '') => { 11 | const queryString = isPublic ? new URLSearchParams({ isPublic }).toString() : ''; 12 | return ( 13 | await fetcher(`/api/blogs?${queryString}`, { 14 | schema: getBlogsSchema, 15 | method: 'GET', 16 | }) 17 | ).data; 18 | }; 19 | -------------------------------------------------------------------------------- /utils/api/getMe.ts: -------------------------------------------------------------------------------- 1 | import { string, object } from 'yup'; 2 | 3 | import { fetcher } from '../fetcher'; 4 | 5 | const getMeSchema = object({ 6 | data: object({ 7 | user: object({ 8 | id: string().required(), 9 | }) 10 | .noUnknown() 11 | .required(), 12 | member: object({ 13 | id: string().required(), 14 | email: string().required(), 15 | role: string().required(), 16 | }).required(), 17 | }).required(), 18 | }); 19 | 20 | export const getMe = async () => { 21 | return ( 22 | await fetcher('/api/auth/me', { 23 | schema: getMeSchema, 24 | method: 'GET', 25 | }) 26 | ).data; 27 | }; 28 | -------------------------------------------------------------------------------- /utils/api/resetBlog.ts: -------------------------------------------------------------------------------- 1 | import { fetcher } from '../fetcher'; 2 | 3 | export const resetBlog = (blogId: string) => { 4 | return fetcher(`/api/blogs/${blogId}`, { 5 | method: 'PATCH', 6 | schema: null, 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /utils/api/updateBlog.ts: -------------------------------------------------------------------------------- 1 | import { blogSchema } from '../blog-schema-api'; 2 | import { fetcher } from '../fetcher'; 3 | 4 | import type { BlogIdRequestBody } from '../../pages/api/blogs/[blogId]'; 5 | 6 | export const updateBlog = (blogId: string, body: BlogIdRequestBody) => { 7 | body.creatorEmail = body.creatorEmail || null; 8 | 9 | return fetcher(`/api/blogs/${blogId}`, { 10 | method: 'PUT', 11 | schema: blogSchema, 12 | body, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /utils/array-utils.ts: -------------------------------------------------------------------------------- 1 | export const getPagesArray = (lastPageNumber: number, length?: number): readonly string[] => { 2 | const fullArray = Array.from({ length: lastPageNumber }, (_val, index) => 3 | (index + 1).toString(), 4 | ).reverse(); 5 | if (length) { 6 | return fullArray.slice(0, length); 7 | } 8 | return fullArray; 9 | }; 10 | 11 | export const includes = (arr: readonly T[], item: unknown): item is T => 12 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/consistent-type-assertions -- this assertion is required but also completely typesafe 13 | arr.includes(item as any); 14 | -------------------------------------------------------------------------------- /utils/blog-schema-api.ts: -------------------------------------------------------------------------------- 1 | import { object, string, date, bool } from 'yup'; 2 | 3 | export const blogSchema = object({ 4 | id: string().required(), 5 | name: string().required(), 6 | href: string().required(), 7 | rss: string().required(), 8 | slug: string().nullable(), 9 | lastUpdateDate: date().required(), 10 | favicon: string().nullable(), 11 | creatorEmail: string().nullable(), 12 | isPublic: bool().required(), 13 | lastArticlePublishedAt: date().nullable(), 14 | createdAt: date().required(), 15 | updatedAt: date().required(), 16 | }); 17 | -------------------------------------------------------------------------------- /utils/creator-utils.ts: -------------------------------------------------------------------------------- 1 | import type { Article, Blog } from '../types'; 2 | 3 | // Detect YouTube videos by the article URL 4 | const ytRegex = /youtu(\.be|be\.com)\//; 5 | 6 | // Detect podcasts by the anchor.fm in article URL (most popular hosting) or podcast name (common words like talk or podcast) 7 | const podcastRegex = /(anchor\.fm\/|talk|podcast)/i; 8 | 9 | type ArticleToDetect = Pick; 10 | type BlogToDetect = Pick; 11 | 12 | export const detectContentGenre = (article: ArticleToDetect, blog: BlogToDetect) => { 13 | if (ytRegex.test(article.href)) { 14 | return 'youtube' as const; 15 | } else if (podcastRegex.test(article.href) || podcastRegex.test(blog.name)) { 16 | return 'podcast' as const; 17 | } else { 18 | return 'blog' as const; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /utils/date-utils.ts: -------------------------------------------------------------------------------- 1 | import Ms from 'ms'; 2 | 3 | export function formatDate(date: Date) { 4 | const day = date.getDate().toString().padStart(2, '0'); 5 | const month = (date.getMonth() + 1).toString().padStart(2, '0'); 6 | const year = date.getFullYear(); 7 | return `${day}-${month}-${year}`; 8 | } 9 | 10 | export function isToday(date: Date) { 11 | return Date.now() - date.getTime() < Ms('1 day'); 12 | } 13 | 14 | export function isThisWeek(date: Date) { 15 | return Date.now() - date.getTime() < Ms('1 week'); 16 | } 17 | 18 | const isoDateRegExp = 19 | /(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/; 20 | export function isIsoDate(str: string) { 21 | return isoDateRegExp.test(str); 22 | } 23 | -------------------------------------------------------------------------------- /utils/excerpt-utils.ts: -------------------------------------------------------------------------------- 1 | import { decode } from 'html-entities'; 2 | import Xss from 'xss'; 3 | 4 | const EXCERPT_MAX_WORDS = 50; 5 | 6 | export function removeShortWordsFromTheEndReducer( 7 | { done, text }: { readonly done: boolean; readonly text: string }, 8 | lastWord: string, 9 | ) { 10 | if (done || lastWord.length >= 3) { 11 | return { done: true, text: lastWord + ' ' + text }; 12 | } 13 | return { done: false, text }; 14 | } 15 | 16 | export function createExcerpt(text: string) { 17 | return decode(Xss(text, { stripIgnoreTag: true, whiteList: {} })) 18 | .trim() 19 | .split(/\s+/) 20 | .filter((word) => word) 21 | .slice(0, EXCERPT_MAX_WORDS) 22 | .reduceRight(removeShortWordsFromTheEndReducer, { done: false, text: '' }).text; 23 | } 24 | 25 | type ObjectWithDescription = { 26 | readonly description: string | null; 27 | }; 28 | 29 | export function addExcerptToArticle(article: T) { 30 | return { 31 | ...article, 32 | excerpt: article.description ? createExcerpt(article.description) : '', 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /utils/fetchAdminBlogsList.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { openConnection } from '../api-helpers/prisma/db'; 4 | 5 | import { formatDate } from './date-utils'; 6 | 7 | import type { BlogsType } from '../types'; 8 | 9 | export const fetchAdminBlogsList = async (blogsType: BlogsType | undefined) => { 10 | const prisma = openConnection(); 11 | 12 | const blogs = await prisma.blog.findMany({ 13 | where: { 14 | isPublic: detectBlogsType(blogsType), 15 | }, 16 | orderBy: { 17 | id: 'desc', 18 | }, 19 | }); 20 | 21 | const data = blogs.map((blog) => ({ 22 | ...blog, 23 | isPublic: blog.isPublic ? '✅' : '❌', 24 | lastArticlePublishedAt: blog.lastArticlePublishedAt 25 | ? formatDate(blog.lastArticlePublishedAt) 26 | : '', 27 | createdAt: formatDate(blog.createdAt), 28 | link: ( 29 | 30 | Link 31 | 32 | ), 33 | edit: Edytuj, 34 | })); 35 | 36 | return data; 37 | }; 38 | 39 | const detectBlogsType = (blogsType: BlogsType | undefined) => { 40 | switch (blogsType) { 41 | case 'public': 42 | return true; 43 | 44 | case 'nonpublic': 45 | return false; 46 | 47 | default: 48 | return undefined; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /utils/fetchArticleBySlug.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | 3 | import { getArticleBySlug } from '../api-helpers/articles'; 4 | import { HTTPNotFound } from '../api-helpers/errors'; 5 | import { openConnection } from '../api-helpers/prisma/db'; 6 | 7 | import { addSanitizedDescriptionToArticle } from './sanitize-utils'; 8 | 9 | export const fetchArticleBySlug = async (slug: string | undefined) => { 10 | if (!slug) { 11 | return notFound(); 12 | } 13 | 14 | try { 15 | const prisma = openConnection(); 16 | 17 | const article = await getArticleBySlug(prisma, slug); 18 | const sanitizedArticle = addSanitizedDescriptionToArticle(article); 19 | 20 | return sanitizedArticle; 21 | } catch (err) { 22 | if (err instanceof HTTPNotFound) { 23 | return notFound(); 24 | } 25 | 26 | throw err; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /utils/fetchArticlesForList.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | 3 | import { getArticlesForList, getLastArticlePage } from '../api-helpers/articles'; 4 | import { HTTPNotFound } from '../api-helpers/errors'; 5 | import { openConnection } from '../api-helpers/prisma/db'; 6 | 7 | import { addExcerptToArticle } from './excerpt-utils'; 8 | import { pageValidGuard } from './pageValidGuard'; 9 | 10 | export const fetchArticlesForList = async (page?: string) => { 11 | try { 12 | const prisma = openConnection(); 13 | 14 | const lastPage = await getLastArticlePage(prisma); 15 | const pageNumber = pageValidGuard(page, lastPage); 16 | const { data: articlesFromDb } = await getArticlesForList(prisma, pageNumber); 17 | const articles = articlesFromDb.map(addExcerptToArticle); 18 | 19 | return { 20 | articles, 21 | isLastPage: pageNumber === lastPage, 22 | nextPage: Number(pageNumber) - 1, 23 | previousPage: Number(pageNumber) + 1, 24 | isFirstPage: Number(pageNumber) === 1, 25 | }; 26 | } catch (err) { 27 | if (err instanceof HTTPNotFound) { 28 | return notFound(); 29 | } 30 | 31 | throw err; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /utils/fetchBlogsForGrid.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | 3 | import { getArticlesForGrid, getLastBlogPage } from '../api-helpers/articles'; 4 | import { HTTPNotFound } from '../api-helpers/errors'; 5 | import { openConnection } from '../api-helpers/prisma/db'; 6 | 7 | import { addExcerptToArticle } from './excerpt-utils'; 8 | import { pageValidGuard } from './pageValidGuard'; 9 | 10 | export const fetchBlogsForGrid = async (page?: string) => { 11 | try { 12 | const prisma = openConnection(); 13 | 14 | const lastPage = await getLastBlogPage(prisma); 15 | const pageNumber = pageValidGuard(page, lastPage); 16 | const { data: blogsFromDb } = await getArticlesForGrid(prisma, pageNumber); 17 | 18 | const blogs = blogsFromDb.map((blog) => { 19 | return { 20 | ...blog, 21 | articles: blog.articles.map(addExcerptToArticle), 22 | }; 23 | }); 24 | 25 | return { 26 | blogs, 27 | isLastPage: pageNumber === lastPage, 28 | nextPage: Number(pageNumber) - 1, 29 | previousPage: Number(pageNumber) + 1, 30 | isFirstPage: Number(pageNumber) === 1, 31 | }; 32 | } catch (err) { 33 | if (err instanceof HTTPNotFound) { 34 | return notFound(); 35 | } 36 | 37 | throw err; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPMethod } from '../api-helpers/api-hofs'; 2 | import type { Schema, InferType } from 'yup'; 3 | 4 | type FetcherConfig = { 5 | readonly method: HTTPMethod; 6 | readonly schema: S; 7 | readonly body?: object; 8 | readonly config?: RequestInit; 9 | }; 10 | 11 | export async function fetcher( 12 | path: string, 13 | { method, body, config, schema }: FetcherConfig, 14 | ): Promise; 15 | 16 | export async function fetcher( 17 | path: string, 18 | { method, body, config, schema }: FetcherConfig, 19 | ): Promise>; 20 | 21 | export async function fetcher( 22 | path: string, 23 | { method, body, config, schema }: FetcherConfig, 24 | ) { 25 | try { 26 | const response = await fetch(path, { 27 | ...config, 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | }, 31 | credentials: 'include', 32 | method, 33 | ...(body && { body: JSON.stringify(body) }), 34 | }); 35 | if (response.ok) { 36 | if (!schema) { 37 | return null; 38 | } 39 | return schema.cast(await response.json().catch(() => {})); 40 | } 41 | throw new ResponseError(response.statusText, response.status); 42 | } catch (err) { 43 | if (err instanceof ResponseError) { 44 | throw err; 45 | } 46 | throw new ResponseError('Something went wrong during fetching!'); 47 | } 48 | } 49 | 50 | export class ResponseError extends Error { 51 | constructor(message: string, public readonly status?: number) { 52 | super(message); 53 | // eslint-disable-next-line functional/no-this-expression -- TypeScript class 54 | this.name = 'ResponseError'; 55 | // eslint-disable-next-line functional/no-this-expression -- TypeScript class 56 | Object.setPrototypeOf(this, ResponseError.prototype); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /utils/link-utils.ts: -------------------------------------------------------------------------------- 1 | export const addTrackingToLink = ( 2 | href: string, 3 | { 4 | utm_source = 'polskifrontend.pl', 5 | utm_medium, 6 | }: { readonly utm_source?: string; readonly utm_medium?: string } = {}, 7 | ) => { 8 | try { 9 | const url = new URL(href); 10 | if (utm_source) { 11 | url.searchParams.set('utm_source', utm_source); 12 | } else { 13 | url.searchParams.delete('utm_source'); 14 | } 15 | if (utm_medium) { 16 | url.searchParams.set('utm_medium', utm_medium); 17 | } else { 18 | url.searchParams.delete('utm_medium'); 19 | } 20 | return url.toString(); 21 | } catch { 22 | return href; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /utils/pageValidGuard.ts: -------------------------------------------------------------------------------- 1 | import { HTTPNotFound } from '../api-helpers/errors'; 2 | 3 | export const pageValidGuard = (page: string | undefined, lastPage: number) => { 4 | if (page === undefined) return lastPage; 5 | if (Number(page) === 0 || isNaN(Number(page))) throw new HTTPNotFound(); 6 | if (Number(page) > lastPage) throw new HTTPNotFound(); 7 | return Number(page); 8 | }; 9 | -------------------------------------------------------------------------------- /utils/sanitize-utils.ts: -------------------------------------------------------------------------------- 1 | import Xss from 'xss'; 2 | 3 | import type { getArticleBySlug } from '../api-helpers/articles'; 4 | 5 | type ArticleBySlug = Awaited>; 6 | 7 | export function addSanitizedDescriptionToArticle(article: T) { 8 | return { 9 | ...article, 10 | sanitizedDescription: Xss(article.description || '', { 11 | stripIgnoreTag: true, 12 | whiteList: { 13 | a: ['href', 'target'], 14 | p: [], 15 | }, 16 | }), 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "regions": ["dub1"] 3 | } 4 | --------------------------------------------------------------------------------