diff --git a/.claude/commands/do-test.md b/.claude/commands/do-test.md new file mode 100644 index 00000000..fb65663e --- /dev/null +++ b/.claude/commands/do-test.md @@ -0,0 +1,52 @@ +--- +description: Run development verification checks (lint, build, and optionally e2e tests) +argument-hint: "[e2e]" +--- + +## Development Test Suite + +Run comprehensive development verification checks for the Codu project. + +## Current Context + +Branch: !`git branch --show-current` +Status: !`git status --short | head -10` + +## Task + +Run the following verification steps in order: + +### 1. Lint Check +Run ESLint and verify there are **0 errors** (warnings are acceptable): +```bash +npm run lint +``` +Report the error/warning counts. + +### 2. TypeScript Compilation +Verify TypeScript compiles without errors: +```bash +npx tsc --noEmit +``` + +### 3. Build Check +Verify the Next.js build completes successfully: +```bash +npm run build +``` + +### 4. E2E Tests (if requested) +If `$ARGUMENTS` includes "e2e", also run E2E tests: +```bash +npm run test:e2e +``` + +## Output + +Provide a clear summary: +- Lint: PASS/FAIL (X errors, Y warnings) +- TypeScript: PASS/FAIL +- Build: PASS/FAIL +- E2E Tests: PASS/FAIL/SKIPPED + +If any check fails, provide details and suggest fixes. diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 57efadbf..00000000 --- a/.eslintrc +++ /dev/null @@ -1,23 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "./tsconfig.json" - }, - "plugins": ["@typescript-eslint", "jsx-a11y"], - "extends": [ - "next/core-web-vitals", - "plugin:@typescript-eslint/recommended", - "prettier", - "plugin:jsx-a11y/recommended", - "plugin:playwright/recommended" - ], - "rules": { - "@typescript-eslint/consistent-type-imports": "warn", - "@typescript-eslint/ban-ts-comment": "warn", - "@next/next/no-img-element": "off", - "no-unused-vars": "warn", - "@typescript-eslint/no-unused-vars": "warn", - "no-console": "warn" - }, - "ignorePatterns": ["next.config.js", "*/lambdas/**/*.js"] -} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1bd77156..4df4870b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,19 +1,25 @@ # ✨ Codu Pull Request 💻 Fixes #(issue) + ## Pull Request details -- INFO ABOUT YOUR PULL REQUEST GOES HERE (Please be as descriptive as possible) 🤜 -- Use bullet points to make it easy to read. + ## Any Breaking changes -- IF ANYTHING YOU'RE COMMITTING WOULD BREAK SOMETHING, INCLUDE HERE WHAT WOULD BREAK -- IF YOU HAVE NO BREAKING CHANGES, ENTER 'None' + ## Associated Screenshots -- IF YOU HAVE ANY SCREENSHOTS, INCLUDE THEM HERE. _( Welcome file extensions include gifs/png screenshots of your feature in action )_ -- IF YOU HAVE NO SCREENSHOTS, ENTER 'None' + + +## [Optional] What gif best describes this PR or how it makes you feel + + diff --git a/.github/workflows/alt-text-bot.yml b/.github/workflows/alt-text-bot.yml index 3fe35936..edba0194 100644 --- a/.github/workflows/alt-text-bot.yml +++ b/.github/workflows/alt-text-bot.yml @@ -1,4 +1,5 @@ -name: Accessibility-alt-text-bot +name: Accessibility Alt Text Bot + on: issues: types: [opened, edited] @@ -11,6 +12,10 @@ on: discussion_comment: types: [created, edited] +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number || github.ref }} + cancel-in-progress: true + permissions: issues: write pull-requests: write @@ -18,8 +23,8 @@ permissions: jobs: accessibility_alt_text_bot: - name: Check alt text is set on issue or pull requests + name: Check alt text on issues and pull requests runs-on: ubuntu-latest steps: - - name: Get action 'github/accessibility-alt-text-bot' - uses: github/accessibility-alt-text-bot@v1.4.0 # Set to latest + - name: Run Alt Text Bot + uses: github/accessibility-alt-text-bot@v1.4.0 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000..79da9231 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,109 @@ +name: E2E Tests + +on: + push: + branches: + - develop + pull_request_target: + types: [opened, synchronize, reopened] + branches: + - develop + +# Cancel old builds on new commit for same workflow + branch/PR +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + initial-checks: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "npm" + + - name: Install dependencies + run: npm ci + + e2e: + needs: initial-checks + runs-on: ubuntu-latest + environment: production + + env: + DATABASE_URL: "postgresql://postgres:secret@localhost:5432/postgres" + NEXTAUTH_URL: http://localhost:3000/api/auth + GITHUB_ID: ${{ secrets.E2E_GITHUB_ID }} + GITHUB_SECRET: ${{ secrets.E2E_GITHUB_SECRET }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "npm" + + - name: Cache Playwright browsers + uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Run docker-compose + uses: isbang/compose-action@v1.5.1 + with: + compose-file: "./docker-compose.yml" + down-flags: "--volumes" + services: | + db + + - name: Wait for DB to be ready + run: | + timeout 60s bash -c 'until nc -z localhost 5432; do echo "Waiting for database connection..."; sleep 2; done' + shell: bash + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps + if: steps.playwright-cache.outputs.cache-hit != 'true' + + - name: Seed database + run: | + npm run db:migrate + npm run db:seed + + - name: Run Playwright tests + id: playwright-tests + run: npx playwright test + continue-on-error: true + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + - name: Check test results + if: steps.playwright-tests.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index e404107c..f9c10ab7 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -1,4 +1,4 @@ -name: 'Welcome New Contributors' +name: "Welcome New Contributors" on: issues: @@ -6,13 +6,20 @@ on: pull_request_target: types: [opened] +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: welcome-new-contributor: runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write steps: - - name: 'Greet the contributor' + - name: "Greet the contributor" uses: garg3133/welcome-new-contributors@v1.2 with: token: ${{ secrets.GITHUB_TOKEN }} - issue-message: 'Hello @contributor_name, thanks for opening your first issue! your contribution is valuable to us. The maintainers will review this issue and provide feedback as soon as possible.' - pr-message: 'Hello @contributor_name, thanks for opening your first Pull Request. The maintainers will review this Pull Request and provide feedback as soon as possible. Keep up the great work!' + issue-message: "Hello @contributor_name, thanks for opening your first issue! Your contribution is valuable to us. The maintainers will review this issue and provide feedback as soon as possible." + pr-message: "Hello @contributor_name, thanks for opening your first Pull Request. The maintainers will review this Pull Request and provide feedback as soon as possible. Keep up the great work!" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index f339ff8f..2da5fc1e 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -1,23 +1,39 @@ -name: Check the pull request +name: Code Quality Checks on: pull_request: types: [opened, synchronize, reopened, edited] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: - check-pull-request: - name: Check code styling and run tests + lint-and-format: + name: Run ESLint and Prettier runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - - name: Use the correct Node.js version + + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: "20.17.0" + node-version-file: ".nvmrc" + cache: "npm" + - name: Install dependencies run: npm ci + - name: Run ESLint run: npm run lint + - name: Run Prettier run: npm run prettier + + - name: Check for uncommitted changes + run: | + git diff --exit-code || \ + (echo "Detected uncommitted changes after build. See status below:" && \ + git diff && \ + exit 1) diff --git a/.gitignore b/.gitignore index 705a6d5c..2951c3a5 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,7 @@ ssmSetup.zsh # open-next -.open-next \ No newline at end of file +.open-next + +# Snyk Security Extension - AI Rules (auto-generated) +.github/instructions/snyk_rules.instructions.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..a86d7c2c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"], + "env": { + "CONTEXT7_API_KEY": "ctx7sk-1d829fe1-62b2-4697-b7f4-673ae5047efd" + } + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "puppeteer-mcp-server"], + "env": {} + }, + "next-devtools": { + "command": "npx", + "args": ["-y", "next-devtools-mcp@latest"] + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + }, + "Sentry": { + "url": "https://mcp.sentry.dev/mcp/assemble-pro/javascript-nextjs" + } + } +} diff --git a/.nvmrc b/.nvmrc index 016e34ba..54c65116 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.17.0 +v24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ea072a8..addefd7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ For e2e tests we are using playwright After making changes make sure that tests passes -To create a e2e test make a file in `/e2e` directory +To create a e2e test make a file in `/e2e` directory **1.** Start the codu application by typing this command: @@ -136,7 +136,6 @@ yarn test:e2e Read Playwright [documentation](https://playwright.dev/) - --- ## Style Guide for Git Commit Messages :memo: diff --git a/E2E Overview.md b/E2E Overview.md new file mode 100644 index 00000000..ba014663 --- /dev/null +++ b/E2E Overview.md @@ -0,0 +1,36 @@ +# End-to-End Testing with Playwright + +To run the end-to-end tests using Playwright, you need to configure your environment and follow these steps: + +### Session and User Setup + +First, you need to add your E2E test user to your locally running database. Do this by running the following script if you haven't already: + +```bash +npm run db:seed +``` + +This will create a user and session for your E2E tests. Details of the E2E user created can be seen in `drizzle/seedE2E.ts`. + +### Running the Tests + +You can run the end-to-end tests using one of the following commands: + +For headless mode: + +```bash +npx playwright test +``` + +For UI mode: + +```bash +npx playwright test --ui +``` + +### Additional E2E constants + +- **E2E_USER_ONE_SESSION_ID**: This is the session token UUID for one E2E user. +- **E2E_USER_TWO_SESSION_ID**: This is the session token UUID for another E2E user. +- **E2E_USER_ONE_ID**: The user ID of one of the E2E users. +- **E2E_USER_TWO_ID**: The user ID of another E2E user. diff --git a/EDITOR_SHORTCUTS.MD b/EDITOR_SHORTCUTS.MD index 9b761ead..81435ec9 100644 --- a/EDITOR_SHORTCUTS.MD +++ b/EDITOR_SHORTCUTS.MD @@ -4,20 +4,20 @@ This guide provides an overview of the available hotkeys and shortcuts for the c ## Hotkeys -| Hotkey | Description | -|-----------------|------------------------------| -| `Tab` | Trigger shortcuts (see below)| -| `Backspace` | Select Previous word | -| `Meta+(1-6)` | Heading 1 - 6 | -| `Meta+b` | Bold | -| `Meta+i` | Italic | -| `Meta+shift+b` | Bold & Italic | -| `Meta+s` | Code Snippet | -| `Meta+shift+c` | Code Block | -| `Meta+Shift+.` | Block Quote | -| `Meta+u` | URL | -| `Meta+l` | Link | -| `Meta+Shift+i` | Image | +| Hotkey | Description | +| -------------- | ----------------------------- | +| `Tab` | Trigger shortcuts (see below) | +| `Backspace` | Select Previous word | +| `Meta+(1-6)` | Heading 1 - 6 | +| `Meta+b` | Bold | +| `Meta+i` | Italic | +| `Meta+shift+b` | Bold & Italic | +| `Meta+s` | Code Snippet | +| `Meta+shift+c` | Code Block | +| `Meta+Shift+.` | Block Quote | +| `Meta+u` | URL | +| `Meta+l` | Link | +| `Meta+Shift+i` | Image | ## How to Use Hotkeys @@ -25,22 +25,21 @@ This guide provides an overview of the available hotkeys and shortcuts for the c 2. You can also highlight the word and then use the hotkey combination and for this you can double click the word or phrase or press meta+backspace a few times to highlight the required selection of text. 3. For Links and images, select the text and then use the hotkey combination. You will be prompted for the url. - ## Markdown Shortcuts -| Shortcut | Description | Example | -|----------|----------------------------------------|-------------------------------| -| `/link` | Create a link with text and URL | `[text](url)` | -| `/image` | Insert an image with alt text and URL | `![text](url)` | +| Shortcut | Description | Example | +| -------- | ------------------------------------- | -------------- | +| `/link` | Create a link with text and URL | `[text](url)` | +| `/image` | Insert an image with alt text and URL | `![text](url)` | ## Custom Tag Shortcuts -| Shortcut | Description | Example | -|---------------|------------------------------------|-------------------------------| -| `/media` | Embed a media file with src | `{% media src="url" /%}` | -| `/youtube` | Embed a YouTube video with src | `{% youtube src="url" /%}` | -| `/codepen` | Embed a CodePen project with src | `{% codepen src="url" /%}` | -| `/codesandbox`| Embed a CodeSandbox project with src | `{% codesandbox src="url" /%}`| +| Shortcut | Description | Example | +| -------------- | ------------------------------------ | ------------------------------ | +| `/media` | Embed a media file with src | `{% media src="url" /%}` | +| `/youtube` | Embed a YouTube video with src | `{% youtube src="url" /%}` | +| `/codepen` | Embed a CodePen project with src | `{% codepen src="url" /%}` | +| `/codesandbox` | Embed a CodeSandbox project with src | `{% codesandbox src="url" /%}` | ## How to Use Shortcuts @@ -48,6 +47,6 @@ This guide provides an overview of the available hotkeys and shortcuts for the c 2. Type the shortcut (e.g., `/link`). 3. Press the `Tab` key. 4. For Markdown shortcuts `/link` and `/image`, you'll be prompted to enter the text and URL. -5. For custom tag shortcuts, you'll be prompted to enter the URL for the `src` attribute. +5. For custom tag shortcuts, you'll be prompted to enter the URL for the `src` attribute. The editor will automatically replace the shortcut with the corresponding content. diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index fad72169..096edb97 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -1,27 +1,27 @@ ## Context -Please provide any relevant information about your setup + ## Expected Behavior -Please describe the behavior you are expecting + ## Current Behavior -What is the current behavior? + ## Screenshots -Drag and drop screenshots here to better describe your issue + ## Steps to reproduce -Please provide detailed steps for reproducing the issue - + ## Additional info -Provide any additional information here + diff --git a/LICENSE.md b/LICENSE.md index 827e8119..1d67f706 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -2,180 +2,180 @@ Version 2.0, January 2004 http://www.apache.org/licenses/ - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" @@ -186,16 +186,16 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 Codú (Codú Software Solutions Limited). +Copyright 2023 Codú (Codú Software Solutions Limited). - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 50b1151b..a366c088 100644 --- a/README.md +++ b/README.md @@ -41,31 +41,35 @@ GITHUB_SECRET=YOUR_GITHUB_APP_SECRET NEXTAUTH_URL=http://localhost:3000/api/auth ``` -For a more detailed how to guide on setting them up [go to the Environment Variables section](#environment-variables). +For a more detailed guide on setting them up [go to the Environment Variables section](#environment-variables). -6. [Make sure your database is running](#database_url) and setup the tables in the database with Drizzle by running: +**NOTE:** Before proceeding, [make sure your database is running](#database_url). + +6. Setup the tables in the database with Drizzle by running: ```bash -npm run db:push +npm run db:migrate ``` +The full command can be seen in our [package.json](/package.json#16) file. + 7. Seed the database with some mock data by running: ```bash npm run db:seed ``` +The full command can be seen in our [package.json](/package.json#19) file. + 8. Finally, run the development server: ```bash npm run dev ``` -After completion of above commands, now - +After completion of the above commands, navigate to [http://localhost:3000](http://localhost:3000) in your browser to see the result. -Navigate to [http://localhost:3000](http://localhost:3000) in your browser to see the result. - -You can start your journey by modifying `pages/index.tsx`. With Auto-update feature, pages updates as you edit the file. +You can start your journey by modifying `pages/index.tsx`. With the auto-update feature, pages update as you edit the file. The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. @@ -83,36 +87,39 @@ To run this file, make sure you have [Docker installed](https://docs.docker.com/ Run the command `docker compose up`. -Alternatively, if you have PostgreSQL running locally then you can use your local connection string or grab one from a free service like [Supabase](https://supabase.com/docs/guides/database/connecting-to-postgres#finding-your-connection-string). +Alternatively, if you have PostgreSQL running locally, you can use your local connection string or grab one from a free service like [Supabase](https://supabase.com/docs/guides/database/connecting-to-postgres#finding-your-connection-string). ### GITHUB_ID and GITHUB_SECRET -Currently, we only allow authentication via GitHub. To enable this you need to have a `GITHUB_ID` and `GITHUB_SECRET` value. +Currently, we only allow authentication via GitHub. To enable this, you need to have a `GITHUB_ID` and `GITHUB_SECRET` value. -Setup your GitHub ID & Secret on GitHub: +Set up your GitHub ID & Secret on GitHub: -- [https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps](https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps) +- [Click here](https://github.com/settings/applications/new) to set up a New OAuth App and fill in the details as shown below. -**Shortcut** - [Click here](https://github.com/settings/applications/new) to setup New OAuth App. +For development, make sure you set this up with a **Homepage URL** of -For development, make sure you setup this with a **Homepage URL** of `http://localhost:3000/` and **Authorization callback URL** of `http://localhost:3000/api/auth`. +``` +http://localhost:3000/ +``` -![Screenshot 2022-10-25 at 08 22 03](https://user-images.githubusercontent.com/12615742/197709325-50766dc2-2245-438c-8f71-09064fc3b123.png) +and an **Authorization callback URL** of -After you click the "Register application" button you should see the `GITHUB_ID` and be able to generate a new client secret. You can see this in the screenshot below. 👇 -![Screenshot 2022-10-25 at 08 23 22](https://user-images.githubusercontent.com/12615742/197710695-d3ef9cb7-fe66-4a53-8b3e-d66064434068.png) -After generating the secret, make sure you copy this value to your `.env` file as this value can not be seen again once you refresh the page. 👇 -![Screenshot 2022-10-25 at 08 26 04](https://user-images.githubusercontent.com/12615742/197710697-ef791d9e-b205-4667-a97c-477148917897.png) +``` +http://localhost:3000/api/auth +``` + +After you click the "Register application" button, you should see the `GITHUB_ID` and be able to generate a new client secret. After generating the secret, make sure you copy this value to your `.env` file as this value cannot be seen again once you refresh the page. -### Setting up Passwordless auth locally +More info on Authorizing OAuth in the GitHub documentation [here](https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps). -In order to use Passwordless login locally you need to have a `ACCESS_KEY` and `SECRET_KEY` value. +### Setting up Passwordless Auth Locally -Niall has written a [tutorial](https://www.codu.co/articles/sending-emails-with-aws-ses-and-nodemailer-in-node-js-xfuucrri) on how to send emails with AWS SES and shows how to get these values. +In order to use Passwordless login locally, you need to have an `ACCESS_KEY` and `SECRET_KEY` value. -Check out the example .env file [here](./sample.env) to see how to populate these values +Check out the example `.env` file [here](./sample.env) to see how to populate these values. -**Note: Currenly the AWS region of the SNS service is hardcoded to "eu-west-1" it may be necessary to change this if your SNS service is in a different region** +**Note:** Currently, the AWS region of the SNS service is hardcoded to "eu-west-1"; it may be necessary to change this if your SNS service is in a different region. ### NEXTAUTH_URL @@ -122,17 +129,16 @@ You shouldn't need to change the default value here. This is a variable used by NEXTAUTH_URL=http://localhost:3000/api/auth ``` -For more information, you can read the documentation [here](https://next-auth.js.org/configuration/options). -**Example .env file can be found [here](./sample.env). You can rename this to .env to get started** +**Example .env file can be found [here](./sample.env). You can rename this to `.env` to get started.** ## 👨‍💻 Contribution Guidelines - Contributions are **greatly appreciated**. Contributions make the open-source community an amazing place to learn, inspire, and create. -- Check out our [contribution guidelines](/CONTRIBUTING.md) for contributiong to our repo. It includes +- Check out our [contribution guidelines](/CONTRIBUTING.md) for contributing to our repo. It includes - How to Contribute - How to create a Pull Request - Run Tests - - Also, Style Guide for Commit Messages + - Style Guide for Commit Messages ## 📙 Prerequisite Skills to Contribute @@ -159,10 +165,14 @@ To learn more about Next.js, take a look at the following resources: ### Editor Doc -To learn about the editor shortcuts and hotkeys you can check out this document +To learn about the editor shortcuts and hotkeys, you can check out this document: + +- [Markdoc Editor Hotkeys and Shortcuts](/EDITOR_SHORTCUTS.MD) + +## 🧪 E2E Testing -- [Markdoc Editor Hotkeys and Shortcus](/EDITOR_SHORTCUTS.MD) +For information on E2E testing, please refer to our dedicated documentation [here](./E2E%20Overview.md). ## 💥 Issues -You are welcome to [open issues](https://github.com/codu-code/codu/issues/new/choose) to discuss ideas about improving our Codú. Enhancements are encouraged and appreciated. +You are welcome to [open issues](https://github.com/codu-code/codu/issues/new/choose) to discuss ideas about improving Codú. Enhancements are encouraged and appreciated. diff --git a/app/(app)/(tsandcs)/privacy/page.mdx b/app/(app)/(tsandcs)/privacy/page.mdx index 2fa94cc3..5235a5d7 100644 --- a/app/(app)/(tsandcs)/privacy/page.mdx +++ b/app/(app)/(tsandcs)/privacy/page.mdx @@ -1,13 +1,15 @@ # Privacy Policy -Last updated March 01, 2024 +Last updated January 02, 2026 This privacy notice for Codú Limited (doing business as Codú) ("**we**," "**us**," or "**our**"), describes how and why we might collect, store, use, and/or share ("**process**") your information when you use our services ("**Services**"), such as when you: -This Policy supplements and is governed by our [Terms of Service](http://www.codu.co/terms) (“Terms”). +This Policy supplements and is governed by our [Terms of Service](http://www.codu.co/terms) ("Terms") and [Newsletter Terms of Use](http://www.codu.co/tou). - Visit our website at [http://www.codu.co](http://www.codu.co), or any website of ours that links to this privacy notice +- Subscribe to or read our newsletter at [https://newsletter.codu.co](https://newsletter.codu.co) + - Engage with us in other related ways, including any sales, marketing, or events **Questions or concerns?** Reading this privacy notice will help you understand your privacy rights and choices. If you do not agree with our policies and practices, please do not use our Services. If you still have any questions or concerns, please contact us at niall@codu.co. @@ -70,7 +72,7 @@ Want to learn more about what we do with any information we collect? Review the **Personal information you disclose to us** -**_In Short:_** *We collect personal information that you provide to us.* +***In Short:*** *We collect personal information that you provide to us.* We collect personal information that you voluntarily provide to us when you register on the Services, express an interest in obtaining information about us or our products and Services, when you participate in activities on the Services, or otherwise when you contact us. @@ -90,13 +92,25 @@ We collect personal information that you voluntarily provide to us when you regi **Sensitive Information.** We do not process sensitive information. +**Newsletter Subscriber Data.** When you subscribe to our newsletter, we collect and process the following information: + +- Email address (required for subscription) +- Subscription date and source +- Email engagement data (whether you open emails, which links you click) +- Geographic location at country/region level (derived from IP address) +- Device and email client information +- Survey responses (if you participate in newsletter surveys) +- Referral information (if you were referred by another subscriber) + +This data helps us deliver relevant content, understand our audience, and improve our newsletter. We use Beehiiv as our newsletter platform, and your data is processed in accordance with their privacy practices. You can unsubscribe at any time using the link in any newsletter email. + **Social Media Login Data.** We may provide you with the option to register with us using your existing social media account details, like your Facebook, Twitter, or other social media account. If you choose to register in this way, we will collect the information described in the section called "HOW DO WE HANDLE YOUR SOCIAL LOGINS?" below. All personal information that you provide to us must be true, complete, and accurate, and you must notify us of any changes to such personal information. **2\. HOW DO WE PROCESS YOUR INFORMATION?** -**_In Short:_** *We process your information to provide, improve, and administer our Services, communicate with you, for security and fraud prevention, and to comply with law. We may also process your information for other purposes with your consent.* +***In Short:*** *We process your information to provide, improve, and administer our Services, communicate with you, for security and fraud prevention, and to comply with law. We may also process your information for other purposes with your consent.* **We process your personal information for a variety of reasons, depending on how you interact with our Services, including:** @@ -158,7 +172,7 @@ In some exceptional cases, we may be legally permitted under applicable law to p **4\. WHEN AND WITH WHOM DO WE SHARE YOUR PERSONAL INFORMATION?** -**_In Short:_** *We may share information in specific situations described in this section and/or with the following third parties.* +***In Short:*** *We may share information in specific situations described in this section and/or with the following third parties.* We may need to share your personal information in the following situations: @@ -168,7 +182,7 @@ We may need to share your personal information in the following situations: **5\. HOW DO WE HANDLE YOUR SOCIAL LOGINS?** -**_In Short:_** *If you choose to register or log in to our Services using a social media account, we may have access to certain information about you.* +***In Short:*** *If you choose to register or log in to our Services using a social media account, we may have access to certain information about you.* Our Services offer you the ability to register and log in using your third-party social media account details (like your Facebook or Twitter logins). Where you choose to do this, we will receive certain profile information about you from your social media provider. The profile information we receive may vary depending on the social media provider concerned, but will often include your name, email address, friends list, and profile picture, as well as other information you choose to make public on such a social media platform. @@ -176,7 +190,7 @@ We will use the information we receive only for the purposes that are described **6\. HOW LONG DO WE KEEP YOUR INFORMATION?** -**_In Short:_** *We keep your information for as long as necessary to fulfill the purposes outlined in this privacy notice unless otherwise required by law.* +***In Short:*** *We keep your information for as long as necessary to fulfill the purposes outlined in this privacy notice unless otherwise required by law.* We will only keep your personal information for as long as it is necessary for the purposes set out in this privacy notice, unless a longer retention period is required or permitted by law (such as tax, accounting, or other legal requirements). No purpose in this notice will require us keeping your personal information for longer than the period of time in which users have an account with us. @@ -184,19 +198,19 @@ When we have no ongoing legitimate business need to process your personal inform **7\. HOW DO WE KEEP YOUR INFORMATION SAFE?** -**_In Short:_** *We aim to protect your personal information through a system of organizational and technical security measures.* +***In Short:*** *We aim to protect your personal information through a system of organizational and technical security measures.* We have implemented appropriate and reasonable technical and organizational security measures designed to protect the security of any personal information we process. However, despite our safeguards and efforts to secure your information, no electronic transmission over the Internet or information storage technology can be guaranteed to be 100% secure, so we cannot promise or guarantee that hackers, cybercriminals, or other unauthorized third parties will not be able to defeat our security and improperly collect, access, steal, or modify your information. Although we will do our best to protect your personal information, transmission of personal information to and from our Services is at your own risk. You should only access the Services within a secure environment. **8\. DO WE COLLECT INFORMATION FROM MINORS?** -**_In Short:_** *We do not knowingly collect data from or market to children under 18 years of age.* +***In Short:*** *We do not knowingly collect data from or market to children under 18 years of age.* We do not knowingly solicit data from or market to children under 18 years of age. By using the Services, you represent that you are at least 18 or that you are the parent or guardian of such a minor and consent to such minor dependent’s use of the Services. If we learn that personal information from users less than 18 years of age has been collected, we will deactivate the account and take reasonable measures to promptly delete such data from our records. If you become aware of any data we may have collected from children under age 18, please contact us at niall@codu.co. **9\. WHAT ARE YOUR PRIVACY RIGHTS?** -**_In Short:_** *In some regions, such as the European Economic Area (EEA), United Kingdom (UK), Switzerland, and Canada, you have rights that allow you greater access to and control over your personal information. You may review, change, or terminate your account at any time.* +***In Short:*** *In some regions, such as the European Economic Area (EEA), United Kingdom (UK), Switzerland, and Canada, you have rights that allow you greater access to and control over your personal information. You may review, change, or terminate your account at any time.* In some regions (like the EEA, UK, Switzerland, and Canada), you have certain rights under applicable data protection laws. These may include the right (i) to request access and obtain a copy of your personal information, (ii) to request rectification or erasure; (iii) to restrict the processing of your personal information; (iv) if applicable, to data portability; and (v) not to be subject to automated decision-making. In certain circumstances, you may also have the right to object to the processing of your personal information. You can make such a request by contacting us by using the contact details provided in the section "HOW CAN YOU CONTACT US ABOUT THIS NOTICE?" below. @@ -228,7 +242,7 @@ Most web browsers and some mobile operating systems and mobile applications incl **11\. DO UNITED STATES RESIDENTS HAVE SPECIFIC PRIVACY RIGHTS?** -**_In Short:_** *If you are a resident of California, Colorado, Connecticut, Utah or Virginia, you are granted specific rights regarding access to your personal information.* +***In Short:*** *If you are a resident of California, Colorado, Connecticut, Utah or Virginia, you are granted specific rights regarding access to your personal information.* **What categories of personal information do we collect?** diff --git a/app/(app)/(tsandcs)/tou/page.mdx b/app/(app)/(tsandcs)/tou/page.mdx new file mode 100644 index 00000000..d6ee270a --- /dev/null +++ b/app/(app)/(tsandcs)/tou/page.mdx @@ -0,0 +1,106 @@ +# Newsletter Terms of Use + +Last updated January 02, 2026 + +Welcome to the Codú Newsletter. By subscribing to or reading our newsletter, you accept and agree to be bound by these Terms of Use. If you do not agree to these terms, please do not subscribe to or use our newsletter. + +## 1. Acceptance and Eligibility + +By subscribing to the Codú Newsletter, you confirm that you are at least 16 years of age and have the legal capacity to enter into these terms. If you are subscribing on behalf of an organization, you represent that you have the authority to bind that organization to these terms. + +## 2. Newsletter Service + +The Codú Newsletter provides content related to web development, programming, and technology. We aim to deliver valuable insights, tutorials, and industry news to help you grow as a developer. + +**Service includes:** + +- Weekly email newsletters +- Curated content and resources +- Community updates and announcements + +## 3. Subscription and Unsubscription + +- You may subscribe to our newsletter by providing your email address through our website or partner platforms +- You may unsubscribe at any time by clicking the unsubscribe link in any newsletter email +- We will process your unsubscription request promptly + +## 4. Content and Intellectual Property + +All content in our newsletters, including text, graphics, logos, and images, is owned by Codú Limited or its content suppliers and is protected by intellectual property laws. + +**You may:** + +- Read and share newsletter content for personal, non-commercial purposes +- Share links to our content with proper attribution + +**You may not:** + +- Reproduce, distribute, or republish newsletter content without permission +- Use our content for commercial purposes without prior written consent +- Remove any copyright or proprietary notices from our content + +## 5. User Conduct + +As a subscriber, you agree not to: + +- Provide false or misleading information when subscribing +- Use automated systems to subscribe multiple email addresses +- Attempt to interfere with our newsletter delivery systems +- Use our newsletter content to spam or harass others + +## 6. Third-Party Links and Content + +Our newsletters may contain links to third-party websites or resources. We are not responsible for: + +- The availability or accuracy of such external sites or resources +- Any content, advertising, products, or services on or available from such sites +- Any damage or loss caused by your use of such content or resources + +## 7. Disclaimer of Warranties + +Our newsletter is provided on an "AS IS" and "AS AVAILABLE" basis without warranties of any kind, either express or implied. + +We do not warrant that: + +- The newsletter will be uninterrupted or error-free +- The content will be accurate, complete, or current +- Any errors will be corrected + +## 8. Limitation of Liability + +To the maximum extent permitted by law, Codú Limited shall not be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of or inability to use our newsletter. + +## 9. Privacy + +Your privacy is important to us. Please review our [Privacy Policy](/privacy) to understand how we collect, use, and protect your information when you subscribe to our newsletter. + +**Newsletter-specific data we collect:** + +- Email address +- Subscription preferences +- Email engagement metrics (opens, clicks) +- Geographic location (country/region level) + +## 10. Modifications to Terms + +We reserve the right to modify these terms at any time. Changes will be effective immediately upon posting. Your continued subscription after changes constitutes acceptance of the modified terms. + +## 11. Termination + +We may terminate or suspend your subscription at any time, without prior notice, for conduct that we believe: + +- Violates these terms +- Is harmful to other subscribers or third parties +- Is fraudulent or illegal + +## 12. Governing Law + +These terms shall be governed by and construed in accordance with the laws of Ireland, without regard to its conflict of law provisions. + +## 13. Contact Information + +If you have any questions about these Terms of Use, please contact us: + +**Codú Limited** +Email: niall@codu.co +Website: [https://www.codu.co](https://www.codu.co) diff --git a/app/(app)/[username]/[slug]/_feedArticleContent.tsx b/app/(app)/[username]/[slug]/_feedArticleContent.tsx new file mode 100644 index 00000000..dbe9e19a --- /dev/null +++ b/app/(app)/[username]/[slug]/_feedArticleContent.tsx @@ -0,0 +1,439 @@ +"use client"; + +import Link from "next/link"; +import * as Sentry from "@sentry/nextjs"; +import { + ArrowTopRightOnSquareIcon, + BookmarkIcon, + ChatBubbleLeftIcon, + ChevronUpIcon, + ChevronDownIcon, + ShareIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { signIn, useSession } from "next-auth/react"; +import { toast } from "sonner"; +import { Temporal } from "@js-temporal/polyfill"; +import DiscussionArea from "@/components/Discussion/DiscussionArea"; + +type Props = { + sourceSlug: string; + articleSlug: string; +}; + +// Get favicon URL from a website +const getFaviconUrl = ( + websiteUrl: string | null | undefined, +): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; + } catch { + return null; + } +}; + +// Get hostname from URL +const getHostname = (urlString: string): string => { + try { + const url = new URL(urlString); + return url.hostname; + } catch { + return urlString; + } +}; + +// Ensure image URL uses https +const ensureHttps = (url: string | null | undefined): string | null => { + if (!url) return null; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + return url; +}; + +const FeedArticleContent = ({ sourceSlug, articleSlug }: Props) => { + const { data: session } = useSession(); + const utils = api.useUtils(); + + const { data: article, status } = api.feed.getBySourceAndArticleSlug.useQuery( + { + sourceSlug, + articleSlug, + }, + ); + + const { data: discussionCount } = + api.discussion.getContentDiscussionCount.useQuery( + { contentId: article?.id ?? "" }, + { enabled: !!article?.id }, + ); + + const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({ + onSuccess: () => { + utils.feed.getBySourceAndArticleSlug.invalidate({ + sourceSlug, + articleSlug, + }); + utils.content.getFeed.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + }); + + const { mutate: bookmark, status: bookmarkStatus } = + api.feed.bookmark.useMutation({ + onSuccess: () => { + utils.feed.getBySourceAndArticleSlug.invalidate({ + sourceSlug, + articleSlug, + }); + utils.feed.getFeed.invalidate(); + utils.feed.mySavedArticles.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + }); + + const { mutate: trackClick } = api.feed.trackClick.useMutation(); + + const handleVote = (voteType: "up" | "down" | null) => { + if (!session) { + signIn(); + return; + } + if (article) { + vote({ contentId: article.id, voteType }); + } + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + if (article) { + bookmark({ articleId: article.id, setBookmarked: !article.isBookmarked }); + } + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}/${sourceSlug}/${articleSlug}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const handleExternalClick = () => { + if (article) { + trackClick({ articleId: article.id }); + } + }; + + if (status === "pending") { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (status === "error" || !article) { + return ( +
+ + Back to Feed + +
+

+ Post Not Found +

+

+ This post may have been removed or the link is invalid. +

+
+
+ ); + } + + const dateTime = article.publishedAt + ? Temporal.Instant.from(new Date(article.publishedAt).toISOString()) + : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "long", + day: "numeric", + }) + : null; + + const faviconUrl = getFaviconUrl( + article.source?.websiteUrl || article.externalUrl, + ); + const hostname = article.externalUrl + ? getHostname(article.externalUrl) + : null; + const score = article.upvotes - article.downvotes; + + return ( +
+ {/* Breadcrumb */} + + + {/* Article card */} +
+ {/* Source info */} +
+ + {article.source?.logoUrl ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {article.source?.name?.charAt(0).toUpperCase() || "?"} +
+ )} + + {article.source?.name || "Unknown Source"} + + + {article.sourceAuthor && + article.sourceAuthor.trim() && + !["by", "by,", "by ,"].includes( + article.sourceAuthor.trim().toLowerCase(), + ) && ( + <> + + + {article.sourceAuthor.replace(/^by\s+/i, "").trim()} + + + )} + {readableDate && ( + <> + + + + )} +
+ + {/* Title */} +

+ {article.title} +

+ + {/* Excerpt */} + {article.excerpt && ( +

+ {article.excerpt} +

+ )} + + {/* Thumbnail image */} + {ensureHttps(article.imageUrl) && article.externalUrl && ( + + +
+ + {hostname} +
+
+ )} + + {/* Read article CTA */} + {article.externalUrl && ( + + + Read Full Article at {hostname} + + )} + + {/* Inline source info - styled like author bio */} + {article.source && ( +
+ + {article.source.logoUrl ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {article.source.name?.charAt(0).toUpperCase() || "?"} +
+ )} + +
+
+ + {article.source.name} + + + @{sourceSlug} + +
+ {article.source.description && ( +

+ {article.source.description} +

+ )} +
+
+ )} + + {/* Action bar - just above discussion */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-500 dark:text-neutral-400" + }`} + > + {score} + + +
+ + {/* Comments count */} + + + {discussionCount ?? 0} comments + + + {/* Save button */} + + + {/* Share button */} + +
+ + {/* Discussion section - inside the card */} +
+ +
+
+
+ ); +}; + +export default FeedArticleContent; diff --git a/app/(app)/[username]/[slug]/_linkContentDetail.tsx b/app/(app)/[username]/[slug]/_linkContentDetail.tsx new file mode 100644 index 00000000..f24a3bba --- /dev/null +++ b/app/(app)/[username]/[slug]/_linkContentDetail.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Link from "next/link"; +import { + ArrowTopRightOnSquareIcon, + ChatBubbleLeftIcon, + ChevronUpIcon, + ChevronDownIcon, + ShareIcon, +} from "@heroicons/react/20/solid"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { Temporal } from "@js-temporal/polyfill"; +import DiscussionArea from "@/components/Discussion/DiscussionArea"; +import { useSession, signIn } from "next-auth/react"; + +type Props = { + sourceSlug: string; + contentSlug: string; +}; + +// Get favicon URL from a website +const getFaviconUrl = ( + websiteUrl: string | null | undefined, +): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; + } catch { + return null; + } +}; + +// Get hostname from URL +const getHostname = (urlString: string): string => { + try { + const url = new URL(urlString); + return url.hostname; + } catch { + return urlString; + } +}; + +// Ensure image URL uses https +const ensureHttps = (url: string | null | undefined): string | null => { + if (!url) return null; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + return url; +}; + +const LinkContentDetail = ({ sourceSlug, contentSlug }: Props) => { + const { data: session } = useSession(); + const { data: linkContent, status } = + api.feed.getLinkContentBySourceAndSlug.useQuery({ + sourceSlug, + contentSlug, + }); + + const { data: discussionCount } = + api.discussion.getContentDiscussionCount.useQuery( + { contentId: linkContent?.id ?? "" }, + { enabled: !!linkContent?.id }, + ); + + // Vote state management - derive initial values from query data + const initialVoteState = useMemo( + () => ({ + userVote: linkContent?.userVote ?? null, + upvotes: linkContent?.upvotes ?? 0, + downvotes: linkContent?.downvotes ?? 0, + }), + [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes], + ); + + const [userVote, setUserVote] = useState<"up" | "down" | null>( + initialVoteState.userVote, + ); + const [votes, setVotes] = useState({ + upvotes: initialVoteState.upvotes, + downvotes: initialVoteState.downvotes, + }); + + // Sync state when server data changes (e.g., after mutation invalidation) + const currentUserVote = linkContent?.userVote ?? null; + const currentUpvotes = linkContent?.upvotes ?? 0; + const currentDownvotes = linkContent?.downvotes ?? 0; + + // Use refs to track if we need to sync + const serverVoteKey = `${currentUserVote}-${currentUpvotes}-${currentDownvotes}`; + const [lastSyncedKey, setLastSyncedKey] = useState(serverVoteKey); + + if (serverVoteKey !== lastSyncedKey && linkContent) { + setUserVote(currentUserVote); + setVotes({ upvotes: currentUpvotes, downvotes: currentDownvotes }); + setLastSyncedKey(serverVoteKey); + } + + const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({ + onMutate: async ({ voteType }) => { + const oldVote = userVote; + setUserVote(voteType); + setVotes((prev) => { + let newUpvotes = prev.upvotes; + let newDownvotes = prev.downvotes; + if (oldVote === "up") newUpvotes--; + if (oldVote === "down") newDownvotes--; + if (voteType === "up") newUpvotes++; + if (voteType === "down") newDownvotes++; + return { upvotes: newUpvotes, downvotes: newDownvotes }; + }); + }, + onError: () => { + setUserVote(linkContent?.userVote ?? null); + setVotes({ + upvotes: linkContent?.upvotes ?? 0, + downvotes: linkContent?.downvotes ?? 0, + }); + toast.error("Failed to update vote"); + }, + }); + + const handleVote = (voteType: "up" | "down" | null) => { + if (!session) { + signIn(); + return; + } + if (!linkContent) return; + vote({ contentId: linkContent.id, voteType }); + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}/${sourceSlug}/${contentSlug}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + if (status === "pending") { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (status === "error" || !linkContent) { + return ( +
+ + Back to Feed + +
+

+ Content Not Found +

+

+ This link may have been removed or the URL is invalid. +

+
+
+ ); + } + + const externalUrl = linkContent.externalUrl || ""; + const dateTime = linkContent.publishedAt + ? Temporal.Instant.from(new Date(linkContent.publishedAt).toISOString()) + : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "long", + day: "numeric", + }) + : null; + + const faviconUrl = getFaviconUrl( + linkContent.source?.websiteUrl || externalUrl, + ); + const hostname = externalUrl ? getHostname(externalUrl) : null; + const score = votes.upvotes - votes.downvotes; + + return ( +
+ {/* Breadcrumb */} + + + {/* Content card */} +
+ {/* Source info */} +
+ + {linkContent.source?.logoUrl ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {linkContent.source?.name?.charAt(0).toUpperCase() || "?"} +
+ )} + + {linkContent.source?.name || "Unknown Source"} + + + {linkContent.sourceAuthor && linkContent.sourceAuthor.trim() && ( + <> + + {linkContent.sourceAuthor} + + )} + {readableDate && ( + <> + + + + )} +
+ + {/* Title */} +

+ {linkContent.title} +

+ + {/* Excerpt */} + {linkContent.excerpt && ( +

+ {linkContent.excerpt} +

+ )} + + {/* Thumbnail image */} + {ensureHttps(linkContent.imageUrl) && externalUrl && ( + + + {hostname && ( +
+ + {hostname} +
+ )} +
+ )} + + {/* Visit link CTA */} + {externalUrl && hostname && ( + + + Visit Link at {hostname} + + )} + + {/* Inline source info - styled like author bio */} + {linkContent.source && ( +
+ + {linkContent.source.logoUrl ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {linkContent.source.name?.charAt(0).toUpperCase() || "?"} +
+ )} + +
+
+ + {linkContent.source.name} + + + @{sourceSlug} + +
+ {linkContent.source.description && ( +

+ {linkContent.source.description} +

+ )} +
+
+ )} + + {/* Action bar - just above discussion */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {/* Comments count */} + + + {discussionCount ?? 0} comments + + + {/* Share button */} + +
+ + {/* Discussion section */} +
+ +
+
+
+ ); +}; + +export default LinkContentDetail; diff --git a/app/(app)/[username]/[slug]/_userLinkDetail.tsx b/app/(app)/[username]/[slug]/_userLinkDetail.tsx new file mode 100644 index 00000000..49e748a5 --- /dev/null +++ b/app/(app)/[username]/[slug]/_userLinkDetail.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Link from "next/link"; +import { + ArrowTopRightOnSquareIcon, + ChatBubbleLeftIcon, + ChevronUpIcon, + ChevronDownIcon, + ShareIcon, +} from "@heroicons/react/20/solid"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { Temporal } from "@js-temporal/polyfill"; +import DiscussionArea from "@/components/Discussion/DiscussionArea"; +import { useSession, signIn } from "next-auth/react"; +import { InlineAuthorBio } from "@/components/ContentDetail"; + +type Props = { + username: string; + contentSlug: string; +}; + +// Get favicon URL from a website +const getFaviconUrl = ( + websiteUrl: string | null | undefined, +): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; + } catch { + return null; + } +}; + +// Get hostname from URL +const getHostname = (urlString: string): string => { + try { + const url = new URL(urlString); + return url.hostname; + } catch { + return urlString; + } +}; + +// Ensure image URL uses https +const ensureHttps = (url: string | null | undefined): string | null => { + if (!url) return null; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + return url; +}; + +const UserLinkDetail = ({ username, contentSlug }: Props) => { + const { data: session } = useSession(); + const { data: linkContent, status } = api.content.getUserLinkBySlug.useQuery({ + username, + slug: contentSlug, + }); + + const { data: discussionCount } = + api.discussion.getContentDiscussionCount.useQuery( + { contentId: linkContent?.id ?? "" }, + { enabled: !!linkContent?.id }, + ); + + // Vote state management - derive initial values from query data + const initialVoteState = useMemo( + () => ({ + userVote: linkContent?.userVote ?? null, + upvotes: linkContent?.upvotes ?? 0, + downvotes: linkContent?.downvotes ?? 0, + }), + [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes], + ); + + const [userVote, setUserVote] = useState<"up" | "down" | null>( + initialVoteState.userVote, + ); + const [votes, setVotes] = useState({ + upvotes: initialVoteState.upvotes, + downvotes: initialVoteState.downvotes, + }); + + // Sync state when server data changes + const currentUserVote = linkContent?.userVote ?? null; + const currentUpvotes = linkContent?.upvotes ?? 0; + const currentDownvotes = linkContent?.downvotes ?? 0; + + const serverVoteKey = `${currentUserVote}-${currentUpvotes}-${currentDownvotes}`; + const [lastSyncedKey, setLastSyncedKey] = useState(serverVoteKey); + + if (serverVoteKey !== lastSyncedKey && linkContent) { + setUserVote(currentUserVote); + setVotes({ upvotes: currentUpvotes, downvotes: currentDownvotes }); + setLastSyncedKey(serverVoteKey); + } + + const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({ + onMutate: async ({ voteType }) => { + const oldVote = userVote; + setUserVote(voteType); + setVotes((prev) => { + let newUpvotes = prev.upvotes; + let newDownvotes = prev.downvotes; + if (oldVote === "up") newUpvotes--; + if (oldVote === "down") newDownvotes--; + if (voteType === "up") newUpvotes++; + if (voteType === "down") newDownvotes++; + return { upvotes: newUpvotes, downvotes: newDownvotes }; + }); + }, + onError: () => { + setUserVote(linkContent?.userVote ?? null); + setVotes({ + upvotes: linkContent?.upvotes ?? 0, + downvotes: linkContent?.downvotes ?? 0, + }); + toast.error("Failed to update vote"); + }, + }); + + const handleVote = (voteType: "up" | "down" | null) => { + if (!session) { + signIn(); + return; + } + if (!linkContent) return; + vote({ contentId: linkContent.id, voteType }); + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}/${username}/${contentSlug}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + if (status === "pending") { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (status === "error" || !linkContent) { + return ( +
+ + Back to Feed + +
+

+ Content Not Found +

+

+ This link may have been removed or the URL is invalid. +

+
+
+ ); + } + + const externalUrl = linkContent.externalUrl || ""; + const dateTime = linkContent.publishedAt + ? Temporal.Instant.from(new Date(linkContent.publishedAt).toISOString()) + : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "long", + day: "numeric", + }) + : null; + + const faviconUrl = getFaviconUrl(externalUrl); + const hostname = externalUrl ? getHostname(externalUrl) : null; + const score = votes.upvotes - votes.downvotes; + + return ( +
+ {/* Breadcrumb */} + + + {/* Content card */} +
+ {/* Author info */} +
+ + {linkContent.author?.image ? ( + + ) : ( +
+ {linkContent.author?.name?.charAt(0).toUpperCase() || "?"} +
+ )} + + {linkContent.author?.name || "Unknown"} + + + {readableDate && ( + <> + + + + )} + {hostname && ( + <> + + {hostname} + + )} +
+ + {/* Title */} +

+ {linkContent.title} +

+ + {/* Excerpt */} + {linkContent.excerpt && ( +

+ {linkContent.excerpt} +

+ )} + + {/* Thumbnail image */} + {ensureHttps(linkContent.coverImage) && externalUrl && ( + + + {hostname && ( +
+ + {hostname} +
+ )} +
+ )} + + {/* Visit link CTA */} + {externalUrl && hostname && ( + + + Visit Link at {hostname} + + )} + + {/* Inline author bio */} + {linkContent.author && ( +
+ +
+ )} + + {/* Action bar - just above discussion */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {/* Comments count */} + + + {discussionCount ?? 0} comments + + + {/* Share button */} + +
+ + {/* Discussion section */} +
+ {linkContent.showComments ? ( + + ) : ( +
+

+ Comments are disabled for this link +

+
+ )} +
+
+
+ ); +}; + +export default UserLinkDetail; diff --git a/app/(app)/[username]/[slug]/page.tsx b/app/(app)/[username]/[slug]/page.tsx new file mode 100644 index 00000000..94664b55 --- /dev/null +++ b/app/(app)/[username]/[slug]/page.tsx @@ -0,0 +1,792 @@ +import React from "react"; +import type { RenderableTreeNode } from "@markdoc/markdoc"; +import Markdoc from "@markdoc/markdoc"; +import Link from "next/link"; +import { markdocComponents } from "@/markdoc/components"; +import { config } from "@/markdoc/config"; +import DiscussionArea from "@/components/Discussion/DiscussionArea"; +import { ArticleActionBarWrapper } from "@/components/ArticleActionBar"; +import { InlineAuthorBio } from "@/components/ContentDetail"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; +import { getServerAuthSession } from "@/server/auth"; +import ArticleAdminPanel from "@/components/ArticleAdminPanel/ArticleAdminPanel"; +import { type Metadata } from "next"; +import { getCamelCaseFromLower } from "@/utils/utils"; +import { generateHTML } from "@tiptap/core"; +import { RenderExtensions } from "@/components/editor/editor/extensions/render-extensions"; +import sanitizeHtml from "sanitize-html"; +import type { JSONContent } from "@tiptap/core"; +import NotFound from "@/components/NotFound/NotFound"; +import { db } from "@/server/db"; +import { posts, user, feed_sources, post_tags, tag } from "@/server/db/schema"; +import { eq, and, lte } from "drizzle-orm"; +import FeedArticleContent from "./_feedArticleContent"; +import LinkContentDetail from "./_linkContentDetail"; +import UserLinkDetail from "./_userLinkDetail"; + +type Props = { params: Promise<{ username: string; slug: string }> }; + +// Helper to fetch user article by username and slug (uses new posts table) +async function getUserPost(username: string, postSlug: string) { + const userRecord = await db.query.user.findFirst({ + columns: { id: true }, + where: eq(user.username, username), + }); + + if (!userRecord) return null; + + // Then find published article by slug that belongs to this user - using explicit JOIN + const postResults = await db + .select({ + id: posts.id, + title: posts.title, + body: posts.body, + status: posts.status, + publishedAt: posts.publishedAt, + updatedAt: posts.updatedAt, + readingTime: posts.readingTime, + slug: posts.slug, + excerpt: posts.excerpt, + canonicalUrl: posts.canonicalUrl, + showComments: posts.showComments, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + type: posts.type, + // Author info via JOIN + authorId: user.id, + authorName: user.name, + authorImage: user.image, + authorUsername: user.username, + authorBio: user.bio, + }) + .from(posts) + .leftJoin(user, eq(posts.authorId, user.id)) + .where( + and( + eq(posts.slug, postSlug), + eq(posts.authorId, userRecord.id), + eq(posts.status, "published"), + eq(posts.type, "article"), + lte(posts.publishedAt, new Date().toISOString()), + ), + ) + .limit(1); + + if (postResults.length === 0) return null; + + const postRecord = postResults[0]; + + // Fetch tags separately using explicit JOIN + const tagsResult = await db + .select({ title: tag.title }) + .from(post_tags) + .innerJoin(tag, eq(post_tags.tagId, tag.id)) + .where(eq(post_tags.postId, postRecord.id)); + + // Map to expected shape for backwards compatibility + return { + ...postRecord, + published: postRecord.publishedAt, + readTimeMins: postRecord.readingTime, + upvotes: postRecord.upvotesCount, + downvotes: postRecord.downvotesCount, + tags: tagsResult.map((t) => ({ tag: { title: t.title } })), + user: { + id: postRecord.authorId, + name: postRecord.authorName, + image: postRecord.authorImage, + username: postRecord.authorUsername, + bio: postRecord.authorBio, + }, + }; +} + +// Helper to fetch user-created link post by username and slug (user shared a link) +async function getUserLinkPost(username: string, postSlug: string) { + const userRecord = await db.query.user.findFirst({ + columns: { id: true }, + where: eq(user.username, username), + }); + + if (!userRecord) return null; + + // Find published link post by slug that belongs to this user (no sourceId) + const linkPostResults = await db + .select({ + id: posts.id, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + slug: posts.slug, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + status: posts.status, + publishedAt: posts.publishedAt, + updatedAt: posts.updatedAt, + readingTime: posts.readingTime, + showComments: posts.showComments, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + type: posts.type, + // Author info via JOIN + authorId: user.id, + authorName: user.name, + authorImage: user.image, + authorUsername: user.username, + authorBio: user.bio, + }) + .from(posts) + .leftJoin(user, eq(posts.authorId, user.id)) + .where( + and( + eq(posts.slug, postSlug), + eq(posts.authorId, userRecord.id), + eq(posts.status, "published"), + eq(posts.type, "link"), + lte(posts.publishedAt, new Date().toISOString()), + ), + ) + .limit(1); + + if (linkPostResults.length === 0) return null; + + const linkPost = linkPostResults[0]; + + // Fetch tags separately using explicit JOIN + const tagsResult = await db + .select({ title: tag.title }) + .from(post_tags) + .innerJoin(tag, eq(post_tags.tagId, tag.id)) + .where(eq(post_tags.postId, linkPost.id)); + + // Map to expected shape + return { + ...linkPost, + published: linkPost.publishedAt, + readTimeMins: linkPost.readingTime, + upvotes: linkPost.upvotesCount, + downvotes: linkPost.downvotesCount, + tags: tagsResult.map((t) => ({ tag: { title: t.title } })), + user: { + id: linkPost.authorId, + name: linkPost.authorName, + image: linkPost.authorImage, + username: linkPost.authorUsername, + bio: linkPost.authorBio, + }, + }; +} + +// Helper to fetch link post by source slug and article slug (uses new posts table) +async function getFeedArticle( + sourceSlug: string, + articleSlugOrShortId: string, +) { + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), + }); + + if (!source) return null; + + // Find link post by slug that belongs to this source - using explicit JOIN + const linkPostResults = await db + .select({ + id: posts.id, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + slug: posts.slug, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + publishedAt: posts.publishedAt, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + showComments: posts.showComments, + // Source info + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + }) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .where( + and( + eq(posts.slug, articleSlugOrShortId), + eq(posts.sourceId, source.id), + eq(posts.type, "link"), + eq(posts.status, "published"), + ), + ) + .limit(1); + + if (linkPostResults.length === 0) return null; + + const linkPost = linkPostResults[0]; + + // Map to expected shape for backwards compatibility + return { + ...linkPost, + shortId: linkPost.slug.split("-").pop() || "", + imageUrl: linkPost.coverImage, + ogImageUrl: linkPost.coverImage, + upvotes: linkPost.upvotesCount, + downvotes: linkPost.downvotesCount, + source: { + name: linkPost.sourceName, + slug: linkPost.sourceSlug, + logoUrl: linkPost.sourceLogo, + websiteUrl: linkPost.sourceWebsite, + }, + }; +} + +// Helper to fetch link content (uses new posts table - same as getFeedArticle) +async function getLinkContent(sourceSlug: string, contentSlug: string) { + // Delegate to getFeedArticle since they query the same table now + return getFeedArticle(sourceSlug, contentSlug); +} + +// Helper to fetch user article content (uses new posts table - same as getUserPost) +async function getUserArticleContent(username: string, contentSlug: string) { + // Delegate to getUserPost since they query the same table now + return getUserPost(username, contentSlug); +} + +export async function generateMetadata(props: Props): Promise { + const params = await props.params; + const { username, slug } = params; + + // First try user post (legacy Post table) + const userPost = await getUserPost(username, slug); + if (userPost) { + const tags = userPost.tags.map((tag) => tag.tag.title); + const host = (await headers()).get("host") || ""; + const authorName = userPost.user.name || "Unknown"; + + return { + title: `${userPost.title} | by ${authorName} | Codú`, + authors: { + name: authorName, + url: `https://www.${host}/${userPost.user.username}`, + }, + keywords: tags, + description: userPost.excerpt ?? undefined, + openGraph: { + description: userPost.excerpt ?? undefined, + type: "article", + images: [ + `/og?title=${encodeURIComponent( + userPost.title, + )}&readTime=${userPost.readTimeMins}&author=${encodeURIComponent( + authorName, + )}&date=${userPost.updatedAt}`, + ], + siteName: "Codú", + }, + twitter: { + description: userPost.excerpt ?? undefined, + images: [`/og?title=${encodeURIComponent(userPost.title)}`], + }, + alternates: { + canonical: userPost.canonicalUrl, + }, + }; + } + + // Then try user ARTICLE content (new unified Content table) + const userArticle = await getUserArticleContent(username, slug); + if (userArticle && userArticle.user) { + const tags = userArticle.tags?.map((t) => t.tag.title) || []; + const host = (await headers()).get("host") || ""; + const articleAuthorName = userArticle.user.name || "Unknown"; + + return { + title: `${userArticle.title} | by ${articleAuthorName} | Codú`, + authors: { + name: articleAuthorName, + url: `https://www.${host}/${userArticle.user.username}`, + }, + keywords: tags, + description: userArticle.excerpt, + openGraph: { + description: userArticle.excerpt || "", + type: "article", + images: [ + `/og?title=${encodeURIComponent( + userArticle.title, + )}&readTime=${userArticle.readTimeMins || 5}&author=${encodeURIComponent( + userArticle.user.name || "", + )}&date=${userArticle.updatedAt}`, + ], + siteName: "Codú", + }, + twitter: { + description: userArticle.excerpt || "", + images: [`/og?title=${encodeURIComponent(userArticle.title)}`], + }, + alternates: { + canonical: userArticle.canonicalUrl, + }, + }; + } + + // Try user-created link post (user shared a link) + const userLinkPost = await getUserLinkPost(username, slug); + if (userLinkPost && userLinkPost.user) { + const host = (await headers()).get("host") || ""; + const linkAuthorName = userLinkPost.user.name || "Unknown"; + + return { + title: `${userLinkPost.title} | shared by ${linkAuthorName} | Codú`, + authors: { + name: linkAuthorName, + url: `https://www.${host}/${userLinkPost.user.username}`, + }, + description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`, + openGraph: { + title: userLinkPost.title, + description: userLinkPost.excerpt || `Link shared by ${linkAuthorName}`, + images: userLinkPost.coverImage ? [userLinkPost.coverImage] : undefined, + siteName: "Codú", + }, + }; + } + + // Then try feed article (legacy aggregated_article table) + const feedArticle = await getFeedArticle(username, slug); + if (feedArticle) { + return { + title: `${feedArticle.title} | Codú Feed`, + description: + feedArticle.excerpt || `Discussion about ${feedArticle.title}`, + openGraph: { + title: feedArticle.title, + description: + feedArticle.excerpt || `Discussion about ${feedArticle.title}`, + images: + feedArticle.ogImageUrl || feedArticle.imageUrl + ? [feedArticle.ogImageUrl || feedArticle.imageUrl!] + : undefined, + }, + }; + } + + // Try unified content table (new LINK type items) + const linkContent = await getLinkContent(username, slug); + if (linkContent) { + return { + title: `${linkContent.title} | Codú Feed`, + description: + linkContent.excerpt || `Discussion about ${linkContent.title}`, + openGraph: { + title: linkContent.title, + description: + linkContent.excerpt || `Discussion about ${linkContent.title}`, + images: + linkContent.ogImageUrl || linkContent.imageUrl + ? [linkContent.ogImageUrl || linkContent.imageUrl!] + : undefined, + }, + }; + } + + return { title: "Content Not Found" }; +} + +const parseJSON = (str: string): JSONContent | null => { + try { + return JSON.parse(str); + } catch { + return null; + } +}; + +const renderSanitizedTiptapContent = (jsonContent: JSONContent) => { + const rawHtml = generateHTML(jsonContent, [...RenderExtensions]); + return sanitizeHtml(rawHtml, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat([ + "img", + "iframe", + "h1", + "h2", + ]), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ["src", "alt", "title", "width", "height", "class"], + iframe: ["src", "width", "height", "frameborder", "allowfullscreen"], + "*": ["class", "id", "style"], + }, + allowedIframeHostnames: [ + "www.youtube.com", + "youtube.com", + "www.youtube-nocookie.com", + ], + }); +}; + +const UnifiedPostPage = async (props: Props) => { + const params = await props.params; + const session = await getServerAuthSession(); + const { username, slug } = params; + + const host = (await headers()).get("host") || ""; + + // First try user post + const userPost = await getUserPost(username, slug); + + if (userPost) { + // Render user article + const bodyContent = userPost.body ?? ""; + const parsedBody = parseJSON(bodyContent); + const isTiptapContent = parsedBody?.type === "doc"; + + let renderedContent: string | RenderableTreeNode; + + if (isTiptapContent && parsedBody) { + const jsonContent = parsedBody; + renderedContent = renderSanitizedTiptapContent(jsonContent); + } else { + const ast = Markdoc.parse(bodyContent); + const transformedContent = Markdoc.transform(ast, config); + renderedContent = Markdoc.renderers.react(transformedContent, React, { + components: markdocComponents, + }) as unknown as string; + } + + return ( + <> +
+ {/* Breadcrumb navigation */} + + + {/* Article card - contains everything in one cohesive unit */} +
+ {/* Author info */} +
+ + {userPost.user.image ? ( + + ) : ( +
+ {userPost.user.name?.charAt(0).toUpperCase() || "?"} +
+ )} + {userPost.user.name} + + {userPost.published && ( + <> + + + + )} + {userPost.readTimeMins && ( + <> + + {userPost.readTimeMins} min read + + )} +
+ + {/* Article content */} +
+ {!isTiptapContent &&

{userPost.title}

} + + {isTiptapContent ? ( +
, + }} + className="tiptap-content" + /> + ) : ( +
+ {Markdoc.renderers.react(renderedContent, React, { + components: markdocComponents, + })} +
+ )} +
+ + {/* Tags */} + {userPost.tags.length > 0 && ( +
+ {userPost.tags.map(({ tag }) => ( + + {getCamelCaseFromLower(tag.title)} + + ))} +
+ )} + + {/* Compact inline author bio */} +
+ +
+ + {/* Action bar - just above discussion */} +
+ +
+ + {/* Discussion section - inside the card */} +
+ {userPost.showComments ? ( + + ) : ( +
+

+ Comments are disabled for this post +

+
+ )} +
+
+
+ + {session && session?.user?.role === "ADMIN" && ( + + )} + + ); + } + + // Then try user ARTICLE content (new unified Content table) + const userArticle = await getUserArticleContent(username, slug); + + if (userArticle && userArticle.user && userArticle.body) { + // Render user article from Content table + const parsedBody = parseJSON(userArticle.body); + const isTiptapContent = parsedBody?.type === "doc"; + + let renderedContent: string | RenderableTreeNode; + + if (isTiptapContent && parsedBody) { + const jsonContent = parsedBody; + renderedContent = renderSanitizedTiptapContent(jsonContent); + } else { + const ast = Markdoc.parse(userArticle.body); + const transformedContent = Markdoc.transform(ast, config); + renderedContent = Markdoc.renderers.react(transformedContent, React, { + components: markdocComponents, + }) as unknown as string; + } + + return ( + <> +
+ {/* Breadcrumb navigation */} + + + {/* Article card - contains everything in one cohesive unit */} +
+ {/* Author info */} +
+ + {userArticle.user.image ? ( + + ) : ( +
+ {userArticle.user.name?.charAt(0).toUpperCase() || "?"} +
+ )} + {userArticle.user.name} + + {userArticle.publishedAt && ( + <> + + + + )} + {userArticle.readTimeMins && ( + <> + + {userArticle.readTimeMins} min read + + )} +
+ + {/* Article content */} +
+ {!isTiptapContent &&

{userArticle.title}

} + + {isTiptapContent ? ( +
, + }} + className="tiptap-content" + /> + ) : ( +
+ {Markdoc.renderers.react(renderedContent, React, { + components: markdocComponents, + })} +
+ )} +
+ + {/* Tags */} + {userArticle.tags && userArticle.tags.length > 0 && ( +
+ {userArticle.tags.map(({ tag }) => ( + + {getCamelCaseFromLower(tag.title)} + + ))} +
+ )} + + {/* Compact inline author bio */} +
+ +
+ + {/* Action bar - just above discussion */} +
+ +
+ + {/* Discussion section - inside the card */} +
+ {userArticle.showComments ? ( + + ) : ( +
+

+ Comments are disabled for this article +

+
+ )} +
+
+
+ + {session && session?.user?.role === "ADMIN" && ( + + )} + + ); + } + + // Try user-created link post (user shared a link) + const userLinkPost = await getUserLinkPost(username, slug); + + if (userLinkPost && userLinkPost.user) { + // Render user link post + return ; + } + + // Then try feed article (legacy aggregated_article table) + const feedArticle = await getFeedArticle(username, slug); + + if (feedArticle) { + // Render feed article + return ; + } + + // Try unified content table (new LINK type items) + const linkContent = await getLinkContent(username, slug); + + if (linkContent) { + // Render link content + return ; + } + + // Nothing found + return notFound(); +}; + +export default UnifiedPostPage; diff --git a/app/(app)/[username]/_sourceProfileClient.tsx b/app/(app)/[username]/_sourceProfileClient.tsx new file mode 100644 index 00000000..f695dddc --- /dev/null +++ b/app/(app)/[username]/_sourceProfileClient.tsx @@ -0,0 +1,223 @@ +"use client"; + +import Link from "next/link"; +import { LinkIcon } from "@heroicons/react/20/solid"; +import { api } from "@/server/trpc/react"; +import { useInView } from "react-intersection-observer"; +import { useEffect } from "react"; +import { Heading } from "@/components/ui-components/heading"; +import { UnifiedContentCard } from "@/components/UnifiedContentCard"; + +type Props = { + sourceSlug: string; +}; + +// Get favicon URL from a website +const getFaviconUrl = ( + websiteUrl: string | null | undefined, +): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=128`; + } catch { + return null; + } +}; + +function getDomainFromUrl(url: string) { + const domain = url.replace(/(https?:\/\/)?(www.)?/i, ""); + if (domain[domain.length - 1] === "/") { + return domain.slice(0, domain.length - 1); + } + return domain; +} + +const SourceProfileContent = ({ sourceSlug }: Props) => { + const { ref: loadMoreRef, inView } = useInView({ threshold: 0 }); + + const { data: source, status: sourceStatus } = + api.feed.getSourceBySlug.useQuery({ slug: sourceSlug }); + + const { + data: articlesData, + status: articlesStatus, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = api.feed.getArticlesBySource.useInfiniteQuery( + { sourceSlug, sort: "recent", limit: 25 }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (sourceStatus === "pending") { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (sourceStatus === "error" || !source) { + return ( +
+
+

+ Source Not Found +

+

+ This source may have been removed or the link is invalid. +

+ + Back to Feed + +
+
+ ); + } + + const faviconUrl = getFaviconUrl(source.websiteUrl); + const articles = articlesData?.pages.flatMap((page) => page.articles) ?? []; + + return ( + <> +
+ {/* Profile header - matching user profile pattern exactly */} +
+
+ {source.logoUrl ? ( + {`Avatar + ) : faviconUrl ? ( + {`Avatar + ) : ( +
+ {source.name?.charAt(0).toUpperCase() || "?"} +
+ )} +
+
+

{source.name}

+

+ @{sourceSlug} +

+

{source.description || ""}

+ {source.websiteUrl && ( + + +

+ {getDomainFromUrl(source.websiteUrl)} +

+ + )} +
+
+ + {/* Articles header - matching user profile */} +
+ {`Articles (${source.articleCount})`} +
+ + {/* Articles list using UnifiedContentCard */} +
+ {articlesStatus === "pending" ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : articles.length === 0 ? ( +

Nothing published yet... 🥲

+ ) : ( + <> + {articles.map((article) => { + // Use slug for SEO-friendly URLs, fallback to shortId for legacy articles + const articleSlug = article.slug || article.shortId; + + return ( + + ); + })} + + {/* Load more trigger */} +
+ {isFetchingNextPage && ( +
+ Loading more articles... +
+ )} + {!hasNextPage && articles.length > 0 && ( +
+ No more articles +
+ )} +
+ + )} +
+
+ + ); +}; + +export default SourceProfileContent; diff --git a/app/(app)/[username]/_usernameClient.tsx b/app/(app)/[username]/_usernameClient.tsx index bfe9cda7..0ddaa0c6 100644 --- a/app/(app)/[username]/_usernameClient.tsx +++ b/app/(app)/[username]/_usernameClient.tsx @@ -3,12 +3,12 @@ import * as Sentry from "@sentry/nextjs"; import React from "react"; import Link from "next/link"; -import ArticlePreview from "@/components/ArticlePreview/ArticlePreview"; +import { UnifiedContentCard } from "@/components/UnifiedContentCard"; import { LinkIcon } from "@heroicons/react/20/solid"; import { api } from "@/server/trpc/react"; import { useRouter, useSearchParams } from "next/navigation"; import type { Session } from "next-auth"; -import { Tabs } from "@/components/Tabs"; +import { Heading } from "@/components/ui-components/heading"; import { toast } from "sonner"; type Props = { @@ -16,11 +16,11 @@ type Props = { isOwner: boolean; profile: { posts: { - published: string | null; + publishedAt: string | null; title: string; - excerpt: string; + excerpt: string | null; slug: string; - readTimeMins: number; + readingTime: number | null; id: string; }[]; accountLocked: boolean; @@ -71,25 +71,13 @@ const Profile = ({ profile, isOwner, session }: Props) => { } }; - const selectedTab = - tabFromParams && ["groups", "articles"].includes(tabFromParams) - ? tabFromParams - : "articles"; - - const [ARTICLES] = ["articles"]; - const tabs = [ - { - name: `Articles (${posts.length})`, - value: ARTICLES, - href: `?tab=${ARTICLES}`, - current: selectedTab === ARTICLES, - }, - ]; + const ARTICLES = "articles"; + const selectedTab = tabFromParams === ARTICLES ? ARTICLES : ARTICLES; return ( <>
-
+
{image && ( { )}
-
+
{accountLocked ? (
-

Account locked 🔒

+ Account locked 🔒
) : ( -
- +
+ {`Articles (${posts.length})`}
)} {(() => { @@ -139,36 +127,38 @@ const Profile = ({ profile, isOwner, session }: Props) => { slug, title, excerpt, - readTimeMins, - published, + readingTime, + publishedAt, id, }) => { - if (!published) return; + if (!publishedAt) return null; return ( - +
+ + {isOwner && ( + + Edit + + )} +
); }, ) @@ -179,10 +169,6 @@ const Profile = ({ profile, isOwner, session }: Props) => { )}
); - case GROUPS: - return ( -

Groups are coming soon!

- ); default: return null; } @@ -211,7 +197,7 @@ const Profile = ({ profile, isOwner, session }: Props) => { rows={4} name="note" id="note" - className="block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset ring-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-inset dark:ring-gray-300 sm:text-sm sm:leading-6" + className="block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset ring-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-inset dark:ring-gray-300 sm:text-sm sm:leading-6" defaultValue={""} />
diff --git a/app/(app)/[username]/page.tsx b/app/(app)/[username]/page.tsx index 69d5011a..e8aeb4ac 100644 --- a/app/(app)/[username]/page.tsx +++ b/app/(app)/[username]/page.tsx @@ -1,15 +1,20 @@ import React from "react"; import { notFound } from "next/navigation"; import Content from "./_usernameClient"; +import SourceProfileContent from "./_sourceProfileClient"; import { getServerAuthSession } from "@/server/auth"; import { type Metadata } from "next"; import { db } from "@/server/db"; +import { feed_sources } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; -type Props = { params: { username: string } }; +type Props = { params: Promise<{ username: string }> }; -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const params = await props.params; const username = params.username; + // First check if it's a user const profile = await db.query.user.findFirst({ columns: { bio: true, @@ -18,42 +23,71 @@ export async function generateMetadata({ params }: Props): Promise { where: (users, { eq }) => eq(users.username, username), }); - if (!profile) { - notFound(); + if (profile) { + const { bio, name } = profile; + const title = `${name || username} - Codú Profile | Codú - The Web Developer Community`; + const description = `${name || username}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`; + + return { + title, + description, + openGraph: { + title, + description, + type: "profile", + images: [ + { + url: "/images/og/home-og.png", + width: 1200, + height: 630, + alt: `${name || username}'s profile on Codú`, + }, + ], + siteName: "Codú", + }, + twitter: { + card: "summary_large_image", + title, + description, + images: ["/images/og/home-og.png"], + }, + }; } - const { bio, name } = profile; - const title = `@${username} ${name ? `(${name}) -` : " -"} Codú`; + // Check if it's a feed source + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, username), + }); - const description = `Read writing from ${name}. ${bio}`; + if (source) { + return { + title: `${source.name} | Codú Feed`, + description: + source.description || `Articles from ${source.name} on Codú Feed`, + openGraph: { + title: source.name, + description: + source.description || `Articles from ${source.name} on Codú Feed`, + images: source.logoUrl ? [source.logoUrl] : undefined, + }, + }; + } - return { - title, - description, - openGraph: { - description, - type: "article", - images: [`/api/og?title=${encodeURIComponent(`${name} on Codú`)}`], - siteName: "Codú", - }, - twitter: { - description, - images: [`/api/og?title=${encodeURIComponent(`${name} on Codú`)}`], - }, - }; + // Neither user nor source found + return { title: "Profile Not Found" }; } -export default async function Page({ - params, -}: { - params: { username: string }; +export default async function Page(props: { + params: Promise<{ username: string }>; }) { + const params = await props.params; const username = params?.username; if (!username) { notFound(); } + // First check if it's a user const profile = await db.query.user.findFirst({ columns: { bio: true, @@ -69,56 +103,53 @@ export default async function Page({ title: true, excerpt: true, slug: true, - readTimeMins: true, - published: true, + readingTime: true, + publishedAt: true, id: true, }, - where: (posts, { isNotNull, and, lte }) => + where: (posts, { eq, and, lte }) => and( - isNotNull(posts.published), - lte(posts.published, new Date().toISOString()), + eq(posts.status, "published"), + lte(posts.publishedAt, new Date().toISOString()), ), - orderBy: (posts, { desc }) => [desc(posts.published)], - }, - BannedUsers: { - columns: { - id: true, - }, + orderBy: (posts, { desc }) => [desc(posts.publishedAt)], }, }, where: (users, { eq }) => eq(users.username, username), }); - if (!profile) { - notFound(); + if (profile) { + const bannedUser = await db.query.banned_users.findFirst({ + where: (bannedUsers, { eq }) => eq(bannedUsers.userId, profile.id), + }); + + const accountLocked = !!bannedUser; + const session = await getServerAuthSession(); + const isOwner = session?.user?.id === profile.id; + + const shapedProfile = { + ...profile, + posts: accountLocked ? [] : profile.posts, + accountLocked, + }; + + return ( + <> +

{`${shapedProfile.name || shapedProfile.username}'s Coding Profile`}

+ + + ); + } + + // Check if it's a feed source + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, username), + }); + + if (source) { + return ; } - const accountLocked = !!profile.BannedUsers; - const session = await getServerAuthSession(); - const isOwner = session?.user?.id === profile.id; - - type MakeOptional = Omit & - Partial; - - type Profile = typeof profile; - const cleanedProfile: MakeOptional = { - ...profile, - }; - - delete cleanedProfile.BannedUsers; - - const shapedProfile = { - ...cleanedProfile, - posts: accountLocked - ? [] - : profile.posts.map((post) => ({ - ...post, - published: post.published, - })), - accountLocked, - }; - - return ( - - ); + // Neither user nor source found + notFound(); } diff --git a/app/(app)/admin/_client.tsx b/app/(app)/admin/_client.tsx new file mode 100644 index 00000000..b1824840 --- /dev/null +++ b/app/(app)/admin/_client.tsx @@ -0,0 +1,214 @@ +"use client"; + +import Link from "next/link"; +import { + UsersIcon, + DocumentTextIcon, + FlagIcon, + RssIcon, + ShieldExclamationIcon, + NewspaperIcon, +} from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; + +const colorClasses = { + blue: "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400", + green: "bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400", + yellow: + "bg-yellow-50 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400", + red: "bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400", + purple: + "bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400", + orange: + "bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400", +}; + +const StatCard = ({ + title, + value, + icon: Icon, + href, + color = "blue", + isLoading, +}: { + title: string; + value: number | undefined; + icon: React.ComponentType<{ className?: string }>; + href?: string; + color?: "blue" | "green" | "yellow" | "red" | "purple" | "orange"; + isLoading?: boolean; +}) => { + const content = ( +
+
+
+ +
+
+

+ {title} +

+

+ {isLoading ? ( + + ) : ( + (value ?? 0) + )} +

+
+
+
+ ); + + if (href) { + return {content}; + } + + return content; +}; + +const AdminDashboard = () => { + const { data: stats, isLoading } = api.admin.getStats.useQuery(); + const { data: reportCounts } = api.report.getCounts.useQuery(); + + return ( +
+
+

+ Admin Dashboard +

+

+ Manage and monitor the Codú platform +

+
+ + {/* Stats Grid */} +
+ + + + +
+ + {/* Moderation Stats */} +
+

+ Moderation +

+
+ + + + +
+
+ + {/* Quick Links */} +
+

+ Quick Actions +

+
+ + +
+

+ Moderation Queue +

+

+ Review reported content +

+
+ + + + +
+

+ User Management +

+

+ Search and manage users +

+
+ + + + +
+

+ Feed Sources +

+

+ Manage RSS feed sources +

+
+ +
+
+
+ ); +}; + +export default AdminDashboard; diff --git a/app/(app)/admin/moderation/_client.tsx b/app/(app)/admin/moderation/_client.tsx new file mode 100644 index 00000000..0352831c --- /dev/null +++ b/app/(app)/admin/moderation/_client.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + FlagIcon, + CheckCircleIcon, + XCircleIcon, + ExclamationTriangleIcon, + ArrowLeftIcon, +} from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; + +type ReportStatus = "PENDING" | "REVIEWED" | "DISMISSED" | "ACTIONED"; +type ReportReason = + | "SPAM" + | "HARASSMENT" + | "HATE_SPEECH" + | "MISINFORMATION" + | "COPYRIGHT" + | "NSFW" + | "OFF_TOPIC" + | "OTHER"; + +const reasonLabels: Record = { + SPAM: "Spam", + HARASSMENT: "Harassment", + HATE_SPEECH: "Hate Speech", + MISINFORMATION: "Misinformation", + COPYRIGHT: "Copyright", + NSFW: "NSFW", + OFF_TOPIC: "Off Topic", + OTHER: "Other", +}; + +const reasonColors: Record = { + SPAM: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", + HARASSMENT: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + HATE_SPEECH: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + MISINFORMATION: + "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400", + COPYRIGHT: + "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400", + NSFW: "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400", + OFF_TOPIC: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400", + OTHER: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400", +}; + +const statusColors: Record = { + PENDING: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", + REVIEWED: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400", + DISMISSED: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400", + ACTIONED: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", +}; + +const ModerationQueue = () => { + const [statusFilter, setStatusFilter] = useState( + "PENDING", + ); + const utils = api.useUtils(); + + const { data, isLoading } = api.report.getAll.useQuery({ + status: statusFilter, + limit: 20, + }); + + const { data: counts } = api.report.getCounts.useQuery(); + + const { mutate: reviewReport, isPending: isReviewing } = + api.report.review.useMutation({ + onSuccess: () => { + toast.success("Report updated"); + utils.report.getAll.invalidate(); + utils.report.getCounts.invalidate(); + }, + onError: () => { + toast.error("Failed to update report"); + }, + }); + + const handleDismiss = (reportId: number) => { + reviewReport({ + reportId, + status: "DISMISSED", + actionTaken: "Report dismissed by admin", + }); + }; + + const handleAction = (reportId: number) => { + reviewReport({ + reportId, + status: "ACTIONED", + actionTaken: "Content removed or user warned", + }); + }; + + const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }; + + return ( +
+
+ + + +
+

+ Moderation Queue +

+

+ Review and manage reported content +

+
+
+ + {/* Status Tabs */} +
+ + + + +
+ + {/* Reports List */} +
+ {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ )} + + {!isLoading && data?.reports.length === 0 && ( +
+ +

+ No reports found +

+

+ {statusFilter + ? `No ${statusFilter.toLowerCase()} reports` + : "All caught up!"} +

+
+ )} + + {data?.reports.map((report) => ( +
+ {/* Header */} +
+ + {reasonLabels[report.reason as ReportReason]} + + + {report.status} + + + {getRelativeTime(report.createdAt!)} + +
+ + {/* Content Preview */} +
+ {report.content && ( +
+

+ {report.content.type} by @{report.content.user?.username} +

+

+ {report.content.title} +

+
+ )} + {report.discussion && ( +
+

+ Comment by @{report.discussion.user?.username} +

+

+ {report.discussion.body} +

+
+ )} +
+ + {/* Reporter Details */} + {report.details && ( +
+

+ Details: {report.details} +

+
+ )} + +
+

+ Reported by @{report.reporter?.username || "unknown"} +

+ + {/* Actions */} + {report.status === "PENDING" && ( +
+ + +
+ )} + + {report.status !== "PENDING" && report.reviewedBy && ( +

+ Reviewed by @{report.reviewedBy.username} +

+ )} +
+
+ ))} +
+
+ ); +}; + +export default ModerationQueue; diff --git a/app/(app)/admin/moderation/page.tsx b/app/(app)/admin/moderation/page.tsx new file mode 100644 index 00000000..e2dbe2af --- /dev/null +++ b/app/(app)/admin/moderation/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import ModerationQueue from "./_client"; + +export const metadata = { + title: "Moderation Queue - Codú Admin", + description: "Review and manage reported content", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx new file mode 100644 index 00000000..4e26b90f --- /dev/null +++ b/app/(app)/admin/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import AdminDashboard from "./_client"; + +export const metadata = { + title: "Admin Dashboard - Codú", + description: "Admin dashboard for managing Codú platform", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/sources/_client.tsx b/app/(app)/admin/sources/_client.tsx new file mode 100644 index 00000000..afa25e73 --- /dev/null +++ b/app/(app)/admin/sources/_client.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { + PlusIcon, + CheckCircleIcon, + XCircleIcon, + PauseCircleIcon, + TrashIcon, + ArrowPathIcon, + CloudArrowDownIcon, +} from "@heroicons/react/20/solid"; + +const statusColors = { + ACTIVE: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + PAUSED: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + ERROR: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", +}; + +const statusIcons = { + ACTIVE: CheckCircleIcon, + PAUSED: PauseCircleIcon, + ERROR: XCircleIcon, +}; + +const AdminSourcesPage = () => { + const [showAddForm, setShowAddForm] = useState(false); + const [syncingAll, setSyncingAll] = useState(false); + const [syncingSourceId, setSyncingSourceId] = useState(null); + const [formData, setFormData] = useState({ + name: "", + url: "", + websiteUrl: "", + logoUrl: "", + category: "", + }); + + const utils = api.useUtils(); + + // Sync all sources + const handleSyncAll = async () => { + setSyncingAll(true); + try { + const response = await fetch("/api/admin/sync-feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await response.json(); + + if (data.success) { + toast.success( + `Synced ${data.stats.sourcesProcessed} sources. Added ${data.stats.articlesAdded} articles.`, + ); + if (data.stats.errors.length > 0) { + toast.error(`${data.stats.errors.length} sources had errors`); + } + refetch(); + } else { + toast.error(data.error || "Sync failed"); + } + } catch { + toast.error("Failed to sync feeds"); + } finally { + setSyncingAll(false); + } + }; + + // Sync single source + const handleSyncSource = async (sourceId: number, sourceName: string) => { + setSyncingSourceId(sourceId); + try { + const response = await fetch("/api/admin/sync-feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId }), + }); + const data = await response.json(); + + if (data.success) { + toast.success( + `${sourceName}: Added ${data.stats.articlesAdded} articles`, + ); + refetch(); + } else { + toast.error(data.error || "Sync failed"); + } + } catch { + toast.error(`Failed to sync ${sourceName}`); + } finally { + setSyncingSourceId(null); + } + }; + + // Fetch sources with stats + const { data: sources, status, refetch } = api.feed.getSourceStats.useQuery(); + + // Mutations + const createSource = api.feed.createSource.useMutation({ + onSuccess: () => { + toast.success("Feed source added successfully"); + setShowAddForm(false); + setFormData({ + name: "", + url: "", + websiteUrl: "", + logoUrl: "", + category: "", + }); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to add feed source"); + }, + }); + + const updateSource = api.feed.updateSource.useMutation({ + onSuccess: () => { + toast.success("Feed source updated"); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to update feed source"); + }, + }); + + const deleteSource = api.feed.deleteSource.useMutation({ + onSuccess: () => { + toast.success("Feed source deleted"); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete feed source"); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createSource.mutate({ + name: formData.name, + url: formData.url, + websiteUrl: formData.websiteUrl || undefined, + logoUrl: formData.logoUrl || undefined, + category: formData.category || undefined, + }); + }; + + const handleStatusToggle = (id: number, currentStatus: string) => { + // Status is now lowercase in the new schema, but UpdateFeedSourceSchema still expects uppercase + const newStatus = currentStatus === "active" ? "PAUSED" : "ACTIVE"; + updateSource.mutate({ + id, + status: newStatus as "ACTIVE" | "PAUSED" | "ERROR", + }); + }; + + const handleDelete = (id: number, name: string) => { + if ( + confirm( + `Are you sure you want to delete "${name}"? This will also delete all associated articles.`, + ) + ) { + deleteSource.mutate({ id }); + } + }; + + return ( +
+
+
+

+ Feed Sources +

+

+ Manage RSS feed sources for the content aggregator +

+
+
+ + +
+
+ + {/* Add Source Form */} + {showAddForm && ( +
+

+ Add New Feed Source +

+
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + required + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="e.g., Josh Comeau's Blog" + /> +
+
+ + + setFormData({ ...formData, url: e.target.value }) + } + required + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com/rss.xml" + /> +
+
+ + + setFormData({ ...formData, websiteUrl: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com" + /> +
+
+ + + setFormData({ ...formData, logoUrl: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com/logo.png" + /> +
+
+ + + setFormData({ ...formData, category: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="e.g., frontend, react, career" + /> +
+
+ + +
+
+
+ )} + + {/* Sources Table */} + {status === "pending" && ( +
+ +
+ )} + + {status === "error" && ( +
+ Failed to load feed sources. Please refresh the page. +
+ )} + + {status === "success" && ( +
+ + + + + + + + + + + + + + {sources?.map((source) => { + const StatusIcon = + statusIcons[source.status as keyof typeof statusIcons]; + return ( + + + + + + + + + + ); + })} + +
+ Source + + Category + + Status + + Articles + + Last Fetched + + Errors + + Actions +
+
+ {source.sourceName} +
+
+ {/* Category would need to be fetched separately or added to stats */} + - + + + + {source.status} + + + {source.articleCount} + + {source.lastFetchedAt + ? new Date(source.lastFetchedAt).toLocaleDateString() + : "Never"} + + {source.errorCount} + +
+ + + +
+
+ {sources?.length === 0 && ( +
+ No feed sources yet. Add your first source above. +
+ )} +
+ )} +
+ ); +}; + +export default AdminSourcesPage; diff --git a/app/(app)/admin/sources/page.tsx b/app/(app)/admin/sources/page.tsx new file mode 100644 index 00000000..c16ee4dd --- /dev/null +++ b/app/(app)/admin/sources/page.tsx @@ -0,0 +1,19 @@ +import Content from "./_client"; +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; + +export const metadata = { + title: "Feed Sources - Admin", + description: "Manage RSS feed sources for the content aggregator", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + // Redirect non-admin users + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/users/_client.tsx b/app/(app)/admin/users/_client.tsx new file mode 100644 index 00000000..457ef548 --- /dev/null +++ b/app/(app)/admin/users/_client.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + MagnifyingGlassIcon, + ArrowLeftIcon, + ShieldExclamationIcon, + ShieldCheckIcon, +} from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { useSearchParams } from "next/navigation"; + +const UserManagement = () => { + const searchParams = useSearchParams(); + const initialFilter = searchParams?.get("filter"); + const [search, setSearch] = useState(""); + const [showBannedOnly, setShowBannedOnly] = useState( + initialFilter === "banned", + ); + const [banNote, setBanNote] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(null); + const utils = api.useUtils(); + + const { data: usersData, isLoading } = api.admin.getUsers.useQuery({ + search: search || undefined, + limit: 20, + }); + + const { data: bannedUsers } = api.admin.getBannedUsers.useQuery(undefined, { + enabled: showBannedOnly, + }); + + const { mutate: banUser, isPending: isBanning } = api.admin.ban.useMutation({ + onSuccess: () => { + toast.success("User banned successfully"); + utils.admin.getUsers.invalidate(); + utils.admin.getBannedUsers.invalidate(); + setSelectedUserId(null); + setBanNote(""); + }, + onError: () => { + toast.error("Failed to ban user"); + }, + }); + + const { mutate: unbanUser, isPending: isUnbanning } = + api.admin.unban.useMutation({ + onSuccess: () => { + toast.success("User unbanned successfully"); + utils.admin.getUsers.invalidate(); + utils.admin.getBannedUsers.invalidate(); + }, + onError: () => { + toast.error("Failed to unban user"); + }, + }); + + const handleBan = (userId: string) => { + if (!banNote.trim()) { + toast.error("Please provide a reason for the ban"); + return; + } + banUser({ userId, note: banNote }); + }; + + const handleUnban = (userId: string) => { + unbanUser({ userId }); + }; + + const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffDays < 1) return "today"; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; + return date.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }); + }; + + const displayUsers = showBannedOnly + ? bannedUsers?.map((b) => ({ + ...b.user, + isBanned: true, + bannedAt: b.createdAt, + banNote: b.note, + bannedBy: b.bannedBy, + })) + : usersData?.users; + + return ( +
+
+ + + +
+

+ User Management +

+

+ Search and manage platform users +

+
+
+ + {/* Search and Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-neutral-300 bg-white py-2 pl-10 pr-4 text-neutral-900 placeholder-neutral-400 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500" + /> +
+ + +
+ + {/* Users List */} +
+ {isLoading && ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {!isLoading && displayUsers?.length === 0 && ( +
+

+ No users found +

+

+ {search + ? "Try a different search term" + : showBannedOnly + ? "No banned users" + : "No users yet"} +

+
+ )} + + {displayUsers?.map((user) => ( +
+
+
+ +
+
+ + {user.name || user.username} + + {"role" in user && user.role === "ADMIN" && ( + + Admin + + )} + {"isBanned" in user && user.isBanned && ( + + Banned + + )} +
+

+ @{user.username} · {user.email} + {"createdAt" in user && user.createdAt && ( + <> · Joined {getRelativeTime(user.createdAt)} + )} +

+
+
+ +
+ {"isBanned" in user && user.isBanned ? ( + + ) : !("role" in user) || user.role !== "ADMIN" ? ( + selectedUserId === user.id ? ( +
+ setBanNote(e.target.value)} + className="w-48 rounded-lg border border-neutral-300 px-2 py-1 text-sm dark:border-neutral-600 dark:bg-neutral-700" + /> + + +
+ ) : ( + + ) + ) : null} +
+
+ + {/* Ban details if banned */} + {"banNote" in user && user.banNote && ( +
+

+ Ban reason:{" "} + {user.banNote} +

+ {"bannedBy" in user && user.bannedBy && ( +

+ Banned by @{user.bannedBy.username} +

+ )} +
+ )} +
+ ))} +
+
+ ); +}; + +export default UserManagement; diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx new file mode 100644 index 00000000..37d62e58 --- /dev/null +++ b/app/(app)/admin/users/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import UserManagement from "./_client"; + +export const metadata = { + title: "User Management - Codú Admin", + description: "Search and manage users", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/advertise/_client.tsx b/app/(app)/advertise/_client.tsx new file mode 100644 index 00000000..4b69368c --- /dev/null +++ b/app/(app)/advertise/_client.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { + HeroSection, + MetricsSection, + OfferingsSection, + SocialProofSection, + ContactSection, +} from "@/components/Sponsorship"; + +export function AdvertiseClient() { + return ( + <> + + + + + + + ); +} diff --git a/app/(app)/advertise/page.tsx b/app/(app)/advertise/page.tsx new file mode 100644 index 00000000..6e3fd36a --- /dev/null +++ b/app/(app)/advertise/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { AdvertiseClient } from "./_client"; + +export const metadata: Metadata = { + title: "Advertise with Codú - Reach Ireland's Developer Community", + description: + "Partner with Codú to reach 100,000+ monthly developer visits. Job postings, newsletter ads, event branding, and more. Connect with Ireland's largest web developer community.", + openGraph: { + title: "Advertise with Codú", + description: + "Connect your brand with Ireland's most engaged developer community. Sponsorship packages for job postings, newsletter advertising, and event branding.", + }, +}; + +export default function AdvertisePage() { + return ; +} diff --git a/app/(app)/alpha/additional-details/_actions.ts b/app/(app)/alpha/additional-details/_actions.ts index 78d300d3..dc133a64 100644 --- a/app/(app)/alpha/additional-details/_actions.ts +++ b/app/(app)/alpha/additional-details/_actions.ts @@ -23,14 +23,12 @@ export async function slideOneSubmitAction(dataInput: TypeSlideOneSchema) { } try { - const { firstName, surname, username, location } = - slideOneSchema.parse(dataInput); + const { name, username, location } = slideOneSchema.parse(dataInput); await db .update(user) .set({ - firstName, - surname, + name, username, location, }) @@ -39,7 +37,7 @@ export async function slideOneSubmitAction(dataInput: TypeSlideOneSchema) { return true; } catch (error) { if (error instanceof z.ZodError) { - console.error("Validation error:", error.errors); + console.error("Validation error:", error.issues); } else { console.error("Error updating the User model:", error); } @@ -67,7 +65,7 @@ export async function slideTwoSubmitAction(dataInput: TypeSlideTwoSchema) { return true; } catch (error) { if (error instanceof z.ZodError) { - console.error("Validation error:", error.errors); + console.error("Validation error:", error.issues); } else { console.error("Error updating the User model:", error); } @@ -99,7 +97,7 @@ export async function slideThreeSubmitAction(dataInput: TypeSlideThreeSchema) { return true; } catch (error) { if (error instanceof z.ZodError) { - console.error("Validation error:", error.errors); + console.error("Validation error:", error.issues); } else { console.error("Error updating the User model:", error); } diff --git a/app/(app)/alpha/additional-details/_client.tsx b/app/(app)/alpha/additional-details/_client.tsx index 5c2a664b..4fa0acea 100644 --- a/app/(app)/alpha/additional-details/_client.tsx +++ b/app/(app)/alpha/additional-details/_client.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { redirect, useRouter, useSearchParams } from "next/navigation"; import { useSession } from "next-auth/react"; import { @@ -42,8 +42,7 @@ import { Divider } from "@/components/ui-components/divider"; type UserDetails = { username: string; - firstName: string; - surname: string; + name: string; gender: string; dateOfBirth: string; location: string; @@ -63,8 +62,7 @@ export default function AdditionalSignUpDetails({ const searchParams = useSearchParams(); const { - surname, - firstName, + name, username, location, dateOfBirth, @@ -75,7 +73,7 @@ export default function AdditionalSignUpDetails({ let slide: number; if (searchParams.get("slide")) { slide = Number(searchParams.get("slide")); - } else if (!surname || !firstName || !username || !location) { + } else if (!name || !username || !location) { slide = 1; } else if (!dateOfBirth || !gender) { slide = 2; @@ -102,7 +100,7 @@ export default function AdditionalSignUpDetails({ function SlideOne({ details }: { details: UserDetails }) { const router = useRouter(); - const { username, firstName, surname, location } = details; + const { username, name, location } = details; const { register, @@ -110,7 +108,7 @@ function SlideOne({ details }: { details: UserDetails }) { formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(slideOneSchema), - defaultValues: { username, firstName, surname, location }, + defaultValues: { username, name, location }, }); const onFormSubmit = async (data: TypeSlideOneSchema) => { @@ -139,53 +137,30 @@ function SlideOne({ details }: { details: UserDetails }) {
- + - {errors?.firstName && ( + {errors?.name && ( - {errors.firstName.message} + {errors.name.message} )}
-
+
- + - {errors?.surname && ( - - {errors.surname.message} - - )} - -
- -
- - -
- - codu.co/ - - -
{errors?.username && ( {errors.username.message} @@ -253,23 +228,21 @@ function SlideTwo({ details }: { details: UserDetails }) { parsedDateOfBirth?.getDate(), ); - const [listOfDaysInSelectedMonth, setListOfDaysInSelectedMonth] = useState([ - 0, - ]); - - useEffect(() => { - // If year or month change, recalculate how many days are in the specified month + // Compute days in month directly from year/month (no state needed) + const listOfDaysInSelectedMonth = useMemo(() => { if (year && month !== undefined) { // Returns the last day of the month, by creating a date with day 0 of the following month. - const nummberOfDaysInMonth = new Date(year, month + 1, 0).getDate(); - const daysArray = Array.from( - { length: nummberOfDaysInMonth }, + const numberOfDaysInMonth = new Date(year, month + 1, 0).getDate(); + return Array.from( + { length: numberOfDaysInMonth }, (_, index) => index + 1, ); - setListOfDaysInSelectedMonth(daysArray); } + return [0]; + }, [year, month]); - // Update the date object when year, month or date change + // Update the date object when year, month or day change + useEffect(() => { if (year && month !== undefined && day) { let selectedDate: Date; @@ -282,7 +255,7 @@ function SlideTwo({ details }: { details: UserDetails }) { } setValue("dateOfBirth", selectedDate.toISOString()); } - }, [year, month, day]); + }, [year, month, day, setValue]); const startYearAgeDropdown = 1950; const endYearAgeDropdown = 2010; @@ -307,9 +280,9 @@ function SlideTwo({ details }: { details: UserDetails }) { }; return ( -
+
-
+
Demographic This information is private, but helps us improve @@ -317,7 +290,7 @@ function SlideTwo({ details }: { details: UserDetails }) {
-
+
-
+
Work and education This information is private but helpful to tailor our events and @@ -486,7 +459,7 @@ function SlideThree({ details }: { details: UserDetails }) {
- + - + - + {children}; } diff --git a/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx b/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx index bd24c4a5..e262d709 100644 --- a/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx +++ b/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx @@ -10,7 +10,6 @@ import { } from "@headlessui/react"; import { ChevronUpIcon } from "@heroicons/react/20/solid"; import Editor from "@/components/editor/editor"; -import RenderPost from "@/components/editor/editor/RenderPost"; import useCreatePage from "@/hooks/useCreatePage"; import { usePrompt } from "@/components/PromptService"; @@ -48,7 +47,8 @@ const Create = () => { useEffect(() => { _setUnsaved(hasUnsavedChanges); - }, [hasUnsavedChanges, _setUnsaved]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasUnsavedChanges]); return ( <> @@ -129,7 +129,7 @@ const Create = () => { to find and know what your story is about.

-
+
{({ open }) => ( <> @@ -198,8 +198,8 @@ const Create = () => {
- {dataStatus === "loading" && postId && ( -
+ {dataStatus === "pending" && postId && ( +
@@ -214,69 +214,57 @@ const Create = () => {
)} -
-
- {/* Left sidebar & main wrapper */} -
-
-
- {/* Start main area*/} -
-
-
- {!body && ( - ( - - )} - /> - )} - {body && body.length > 0 && ( - ( - - )} - /> - )} +
+
+ {!body && ( + ( + + )} + /> + )} + {body && body.length > 0 && ( + ( + + )} + /> + )} -
- <> - {saveStatus === "loading" &&

Auto-saving...

} - {saveStatus === "error" && savedTime && ( -

- {`Error saving, last saved: ${savedTime.toString()}`} -

- )} - {saveStatus === "success" && savedTime && ( -

- {`Saved: ${savedTime.toString()}`} -

- )} - -
- -
- -
-
-
-
-
- {/* End main area */} -
+
+
+ {saveStatus === "pending" && ( +

Auto-saving...

+ )} + {saveStatus === "error" && savedTime && ( +

+ Error saving, last saved: {savedTime.toString()} +

+ )} + {saveStatus === "success" && savedTime && ( +

+ Saved: {savedTime.toString()} +

+ )}
+ +
diff --git a/app/(app)/alpha/settings/_client.tsx b/app/(app)/alpha/settings/_client.tsx deleted file mode 100644 index 4b08de9c..00000000 --- a/app/(app)/alpha/settings/_client.tsx +++ /dev/null @@ -1,453 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Description, Field, Label, Switch } from "@headlessui/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import type { SubmitHandler } from "react-hook-form"; -import { useForm } from "react-hook-form"; -import { api } from "@/server/trpc/react"; -import { toast } from "sonner"; -import type { saveSettingsInput } from "./profile"; -import { saveSettingsSchema } from "./profile"; - -import { uploadFile } from "@/utils/s3helpers"; -import type { user } from "@/server/db/schema"; - -function classNames(...classes: string[]) { - return classes.filter(Boolean).join(" "); -} - -type User = Pick< - typeof user.$inferSelect, - | "name" - | "username" - | "bio" - | "location" - | "websiteUrl" - | "emailNotifications" - | "newsletter" - | "image" ->; - -type ProfilePhoto = { - status: "success" | "error" | "loading" | "idle"; - url: string; -}; - -const Settings = ({ profile }: { profile: User }) => { - const { - register, - handleSubmit, - watch, - formState: { errors }, - } = useForm({ - resolver: zodResolver(saveSettingsSchema), - defaultValues: { ...profile, username: profile.username || "" }, - }); - - const bio = watch("bio"); - const username = watch("username"); - - const { emailNotifications: eNotifications, newsletter } = profile; - - const [emailNotifications, setEmailNotifications] = useState(eNotifications); - const [weeklyNewsletter, setWeeklyNewsletter] = useState(newsletter); - - const [profilePhoto, setProfilePhoto] = useState({ - status: "idle", - url: profile.image, - }); - - const { mutate, isError, isSuccess, isLoading } = - api.profile.edit.useMutation(); - - useEffect(() => { - if (isSuccess) { - toast.success("Saved"); - } - if (isError) { - toast.error("Something went wrong saving settings."); - } - }, [isError, isSuccess]); - - const { mutate: getUploadUrl } = api.profile.getUploadUrl.useMutation(); - const { mutate: updateUserPhotoUrl } = - api.profile.updateProfilePhotoUrl.useMutation(); - - const onSubmit: SubmitHandler = (values) => { - mutate({ ...values, newsletter: weeklyNewsletter, emailNotifications }); - }; - - const uploadToUrl = async (signedUrl: string, file: File) => { - setProfilePhoto({ status: "loading", url: "" }); - - if (!file) { - setProfilePhoto({ status: "error", url: "" }); - toast.error("Invalid file upload."); - return; - } - - const response = await uploadFile(signedUrl, file); - const { fileLocation } = response; - await updateUserPhotoUrl({ - url: fileLocation, - }); - - return fileLocation; - }; - - const imageChange = async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - const file = e.target.files[0]; - const { size, type } = file; - - await getUploadUrl( - { size, type }, - { - onError(error) { - if (error) return toast.error(error.message); - return toast.error( - "Something went wrong uploading the photo, please retry.", - ); - }, - async onSuccess(signedUrl) { - const url = await uploadToUrl(signedUrl, file); - if (!url) { - return toast.error( - "Something went wrong uploading the photo, please retry.", - ); - } - setProfilePhoto({ status: "success", url }); - toast.success( - "Profile photo successfully updated. This may take a few minutes to update around the site.", - ); - }, - }, - ); - } - }; - - return ( -
-
-
- - {/* Profile section */} -
-
-

- Profile Settings -

-

- This information will be displayed publicly so be careful what - you share. -

-
- -
-
-
-
-
- - - {errors.name && ( -

- {`${errors.name.message || "Required"}`} -

- )} -
- -
- -
- - codu.co/ - - -
- {errors.username && ( -

- {`${errors.username.message || "Required"}`} -

- )} -
-
- - {/* Photo upload */} - -
- -
-
- -
- -
- {profilePhoto.status === "error" || - profilePhoto.status === "loading" ? ( -
- ) : ( - // TODO: review this - // eslint-disable-next-line jsx-a11y/img-redundant-alt - Profile photo upload section - )} - -
- - {/* Photo end */} -
- {/* */} -
-
- -
-