diff --git a/.env.example b/.env.example index eb34d65..b2b28d0 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,10 @@ VERCEL_AUTH_TOKEN=your-auth-token # Only required if you want to use Tinybird for hub data analytics TINYBIRD_API_URL=https://api.eu-central-1.aws.tinybird.co # Make sure to use the correct region TINYBIRD_API_KEY=your-api-key + +#? Trigger.dev +# Only required if you want to use Trigger.dev +TRIGGER_PROJECT_ID=your-project-id +TRIGGER_API_KEY=your-trigger-api-key +TRIGGER_API_URL=https://api.trigger.dev # This differes based if you're self-hosting or using the cloud version +NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY=your-public-api-key \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 1bd948e..1bb7f9e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,10 +1,10 @@ +// This configuration only applies to the package manager root. +/** @type {import("eslint").Linter.Config} */ module.exports = { - root: true, - // This tells ESLint to load the config from the package `eslint-config-custom` - extends: ['custom/library'], - settings: { - next: { - rootDir: ['apps/*/', 'packages/*/'], - }, + extends: ['@feedbase/eslint-config/library.js'], + ignorePatterns: ['apps/**', 'packages/**'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, }, }; diff --git a/.husky/pre-commit b/.husky/pre-commit index fdf7287..4e1995a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,3 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" - -pnpm run lint --fix && pnpm run ts && pnpm lint-staged +pnpm lint-staged diff --git a/apps/docs/api-reference/endpoint/changelog/delete-projects-changelogs.mdx b/apps/docs/api-reference/endpoint/changelog/delete-projects-changelogs.mdx index bc524cd..63189a6 100644 --- a/apps/docs/api-reference/endpoint/changelog/delete-projects-changelogs.mdx +++ b/apps/docs/api-reference/endpoint/changelog/delete-projects-changelogs.mdx @@ -1,4 +1,4 @@ --- -title: "Delete a Changelog" -openapi: delete /projects/{projectSlug}/changelogs/{changelogId} ---- \ No newline at end of file +title: 'Delete a Changelog' +openapi: delete /projects/{workspaceSlug}/changelogs/{changelogId} +--- diff --git a/apps/docs/api-reference/endpoint/changelog/get-projects-changelogs.mdx b/apps/docs/api-reference/endpoint/changelog/get-projects-changelogs.mdx index 59f620c..cbc8106 100644 --- a/apps/docs/api-reference/endpoint/changelog/get-projects-changelogs.mdx +++ b/apps/docs/api-reference/endpoint/changelog/get-projects-changelogs.mdx @@ -1,4 +1,4 @@ --- title: List Changelogs -openapi: get /projects/{projectSlug}/changelogs ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/changelogs +--- diff --git a/apps/docs/api-reference/endpoint/changelog/patch-projects-changelogs.mdx b/apps/docs/api-reference/endpoint/changelog/patch-projects-changelogs.mdx index f9bd53c..bd31595 100644 --- a/apps/docs/api-reference/endpoint/changelog/patch-projects-changelogs.mdx +++ b/apps/docs/api-reference/endpoint/changelog/patch-projects-changelogs.mdx @@ -1,4 +1,4 @@ --- -title: "Update a Changelog" -openapi: put /projects/{projectSlug}/changelogs/{changelogId} ---- \ No newline at end of file +title: 'Update a Changelog' +openapi: put /projects/{workspaceSlug}/changelogs/{changelogId} +--- diff --git a/apps/docs/api-reference/endpoint/changelog/post-projects-changelogs.mdx b/apps/docs/api-reference/endpoint/changelog/post-projects-changelogs.mdx index d02d2f2..d9bb486 100644 --- a/apps/docs/api-reference/endpoint/changelog/post-projects-changelogs.mdx +++ b/apps/docs/api-reference/endpoint/changelog/post-projects-changelogs.mdx @@ -1,4 +1,4 @@ --- -title: "Create a Changelog" -openapi: post /projects/{projectSlug}/changelogs ---- \ No newline at end of file +title: 'Create a Changelog' +openapi: post /projects/{workspaceSlug}/changelogs +--- diff --git a/apps/docs/api-reference/endpoint/feedback-comments/delete-projects-feedback-comments.mdx b/apps/docs/api-reference/endpoint/feedback-comments/delete-projects-feedback-comments.mdx index 9c97ed6..faab519 100644 --- a/apps/docs/api-reference/endpoint/feedback-comments/delete-projects-feedback-comments.mdx +++ b/apps/docs/api-reference/endpoint/feedback-comments/delete-projects-feedback-comments.mdx @@ -1,4 +1,4 @@ --- title: Delete a Comment -openapi: delete /projects/{projectSlug}/feedback/{feedbackId}/comments/{commentId} ---- \ No newline at end of file +openapi: delete /projects/{workspaceSlug}/feedback/{feedbackId}/comments/{commentId} +--- diff --git a/apps/docs/api-reference/endpoint/feedback-comments/get-projects-feedback-comments.mdx b/apps/docs/api-reference/endpoint/feedback-comments/get-projects-feedback-comments.mdx index eb6e8c9..1d646a0 100644 --- a/apps/docs/api-reference/endpoint/feedback-comments/get-projects-feedback-comments.mdx +++ b/apps/docs/api-reference/endpoint/feedback-comments/get-projects-feedback-comments.mdx @@ -1,4 +1,4 @@ --- title: List Feedback Comments -openapi: get /projects/{projectSlug}/feedback/{feedbackId}/comments ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/feedback/{feedbackId}/comments +--- diff --git a/apps/docs/api-reference/endpoint/feedback-tags/delete-projects-feedback-tags.mdx b/apps/docs/api-reference/endpoint/feedback-tags/delete-projects-feedback-tags.mdx index b6f6214..1fa9f1e 100644 --- a/apps/docs/api-reference/endpoint/feedback-tags/delete-projects-feedback-tags.mdx +++ b/apps/docs/api-reference/endpoint/feedback-tags/delete-projects-feedback-tags.mdx @@ -1,4 +1,4 @@ --- title: Delete a Feedback Tag -openapi: delete /projects/{projectSlug}/feedback/tags/{tagName} ---- \ No newline at end of file +openapi: delete /projects/{workspaceSlug}/feedback/tags/{tagName} +--- diff --git a/apps/docs/api-reference/endpoint/feedback-tags/get-projects-feedback-tags.mdx b/apps/docs/api-reference/endpoint/feedback-tags/get-projects-feedback-tags.mdx index 96b1172..410adc9 100644 --- a/apps/docs/api-reference/endpoint/feedback-tags/get-projects-feedback-tags.mdx +++ b/apps/docs/api-reference/endpoint/feedback-tags/get-projects-feedback-tags.mdx @@ -1,4 +1,4 @@ --- title: List Feedback Tags -openapi: get /projects/{projectSlug}/feedback/tags ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/feedback/tags +--- diff --git a/apps/docs/api-reference/endpoint/feedback-tags/post-projects-feedback-tags.mdx b/apps/docs/api-reference/endpoint/feedback-tags/post-projects-feedback-tags.mdx index f9184bd..73d7d71 100644 --- a/apps/docs/api-reference/endpoint/feedback-tags/post-projects-feedback-tags.mdx +++ b/apps/docs/api-reference/endpoint/feedback-tags/post-projects-feedback-tags.mdx @@ -1,4 +1,4 @@ --- title: Create a Feedback Tag -openapi: post /projects/{projectSlug}/feedback/tags ---- \ No newline at end of file +openapi: post /projects/{workspaceSlug}/feedback/tags +--- diff --git a/apps/docs/api-reference/endpoint/feedback/delete-projects-feedback.mdx b/apps/docs/api-reference/endpoint/feedback/delete-projects-feedback.mdx index 753d98c..c9a4f4d 100644 --- a/apps/docs/api-reference/endpoint/feedback/delete-projects-feedback.mdx +++ b/apps/docs/api-reference/endpoint/feedback/delete-projects-feedback.mdx @@ -1,4 +1,4 @@ --- title: Delete a Feedback -openapi: delete /projects/{projectSlug}/feedback/{feedbackId} ---- \ No newline at end of file +openapi: delete /projects/{workspaceSlug}/feedback/{feedbackId} +--- diff --git a/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-all.mdx b/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-all.mdx index 49287c5..2c889a7 100644 --- a/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-all.mdx +++ b/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-all.mdx @@ -1,4 +1,4 @@ --- title: List Project Feedback -openapi: get /projects/{projectSlug}/feedback ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/feedback +--- diff --git a/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-upvotes.mdx b/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-upvotes.mdx index 26ce89b..04b1580 100644 --- a/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-upvotes.mdx +++ b/apps/docs/api-reference/endpoint/feedback/get-projects-feedback-upvotes.mdx @@ -1,4 +1,4 @@ --- title: List Feedback Upvoters -openapi: get /projects/{projectSlug}/feedback/{feedbackId}/upvotes ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/feedback/{feedbackId}/upvotes +--- diff --git a/apps/docs/api-reference/endpoint/feedback/get-projects-feedback.mdx b/apps/docs/api-reference/endpoint/feedback/get-projects-feedback.mdx index 94811e7..81bc379 100644 --- a/apps/docs/api-reference/endpoint/feedback/get-projects-feedback.mdx +++ b/apps/docs/api-reference/endpoint/feedback/get-projects-feedback.mdx @@ -1,4 +1,4 @@ --- title: Retrieve a Feedback -openapi: get /projects/{projectSlug}/feedback/{feedbackId} ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/feedback/{feedbackId} +--- diff --git a/apps/docs/api-reference/endpoint/feedback/patch-projects-feedback.mdx b/apps/docs/api-reference/endpoint/feedback/patch-projects-feedback.mdx index 2022075..3063e98 100644 --- a/apps/docs/api-reference/endpoint/feedback/patch-projects-feedback.mdx +++ b/apps/docs/api-reference/endpoint/feedback/patch-projects-feedback.mdx @@ -1,4 +1,4 @@ --- title: Update a Feedback -openapi: patch /projects/{projectSlug}/feedback/{feedbackId} ---- \ No newline at end of file +openapi: patch /projects/{workspaceSlug}/feedback/{feedbackId} +--- diff --git a/apps/docs/api-reference/endpoint/feedback/post-projects-feedback.mdx b/apps/docs/api-reference/endpoint/feedback/post-projects-feedback.mdx index d63bb57..87db47f 100644 --- a/apps/docs/api-reference/endpoint/feedback/post-projects-feedback.mdx +++ b/apps/docs/api-reference/endpoint/feedback/post-projects-feedback.mdx @@ -1,4 +1,4 @@ --- title: Create a Feedback -openapi: post /projects/{projectSlug}/feedback ---- \ No newline at end of file +openapi: post /projects/{workspaceSlug}/feedback +--- diff --git a/apps/docs/api-reference/endpoint/project-config/get-projects-config.mdx b/apps/docs/api-reference/endpoint/project-config/get-projects-config.mdx index f73c62a..4f5873a 100644 --- a/apps/docs/api-reference/endpoint/project-config/get-projects-config.mdx +++ b/apps/docs/api-reference/endpoint/project-config/get-projects-config.mdx @@ -1,4 +1,4 @@ --- title: Retrieve a Project Config -openapi: get /projects/{projectSlug}/config ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/config +--- diff --git a/apps/docs/api-reference/endpoint/project-config/patch-projects-config.mdx b/apps/docs/api-reference/endpoint/project-config/patch-projects-config.mdx index b640c28..f2a52a8 100644 --- a/apps/docs/api-reference/endpoint/project-config/patch-projects-config.mdx +++ b/apps/docs/api-reference/endpoint/project-config/patch-projects-config.mdx @@ -1,4 +1,4 @@ --- title: Update a Project Config -openapi: patch /projects/{projectSlug}/config ---- \ No newline at end of file +openapi: patch /projects/{workspaceSlug}/config +--- diff --git a/apps/docs/api-reference/endpoint/project-invites/delete-projects-invites.mdx b/apps/docs/api-reference/endpoint/project-invites/delete-projects-invites.mdx index 1ad3972..1bffc1a 100644 --- a/apps/docs/api-reference/endpoint/project-invites/delete-projects-invites.mdx +++ b/apps/docs/api-reference/endpoint/project-invites/delete-projects-invites.mdx @@ -1,4 +1,4 @@ --- title: Delete a Project Invite -openapi: delete /projects/{projectSlug}/invites/{inviteId} ---- \ No newline at end of file +openapi: delete /projects/{workspaceSlug}/invites/{inviteId} +--- diff --git a/apps/docs/api-reference/endpoint/project-invites/get-projects-invites.mdx b/apps/docs/api-reference/endpoint/project-invites/get-projects-invites.mdx index ba0d58e..0b3a4a9 100644 --- a/apps/docs/api-reference/endpoint/project-invites/get-projects-invites.mdx +++ b/apps/docs/api-reference/endpoint/project-invites/get-projects-invites.mdx @@ -1,4 +1,4 @@ --- -title: "List Project Invites" -openapi: get /projects/{projectSlug}/invites ---- \ No newline at end of file +title: 'List Project Invites' +openapi: get /projects/{workspaceSlug}/invites +--- diff --git a/apps/docs/api-reference/endpoint/project-invites/post-projects-invites.mdx b/apps/docs/api-reference/endpoint/project-invites/post-projects-invites.mdx index 3bb3965..b418336 100644 --- a/apps/docs/api-reference/endpoint/project-invites/post-projects-invites.mdx +++ b/apps/docs/api-reference/endpoint/project-invites/post-projects-invites.mdx @@ -1,4 +1,4 @@ --- -title: "Create a Project Invite" -openapi: post /projects/{projectSlug}/invites ---- \ No newline at end of file +title: 'Create a Project Invite' +openapi: post /projects/{workspaceSlug}/invites +--- diff --git a/apps/docs/api-reference/endpoint/project-members/get-projects-members.mdx b/apps/docs/api-reference/endpoint/project-members/get-projects-members.mdx index da481bb..3216a8d 100644 --- a/apps/docs/api-reference/endpoint/project-members/get-projects-members.mdx +++ b/apps/docs/api-reference/endpoint/project-members/get-projects-members.mdx @@ -1,4 +1,4 @@ --- title: List Project Members -openapi: get /projects/{projectSlug}/members ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug}/members +--- diff --git a/apps/docs/api-reference/endpoint/project/delete-projects.mdx b/apps/docs/api-reference/endpoint/project/delete-projects.mdx index 12698c7..a40ce40 100644 --- a/apps/docs/api-reference/endpoint/project/delete-projects.mdx +++ b/apps/docs/api-reference/endpoint/project/delete-projects.mdx @@ -1,4 +1,4 @@ --- title: Delete a Project -openapi: delete /projects/{projectSlug} ---- \ No newline at end of file +openapi: delete /projects/{workspaceSlug} +--- diff --git a/apps/docs/api-reference/endpoint/project/get-projects.mdx b/apps/docs/api-reference/endpoint/project/get-projects.mdx index 0890cc7..e2e4aed 100644 --- a/apps/docs/api-reference/endpoint/project/get-projects.mdx +++ b/apps/docs/api-reference/endpoint/project/get-projects.mdx @@ -1,4 +1,4 @@ --- title: Retrieve a Project -openapi: get /projects/{projectSlug} ---- \ No newline at end of file +openapi: get /projects/{workspaceSlug} +--- diff --git a/apps/docs/api-reference/endpoint/project/patch-projects.mdx b/apps/docs/api-reference/endpoint/project/patch-projects.mdx index 6e1a89e..b6aa2ed 100644 --- a/apps/docs/api-reference/endpoint/project/patch-projects.mdx +++ b/apps/docs/api-reference/endpoint/project/patch-projects.mdx @@ -1,4 +1,4 @@ --- title: Update a Project -openapi: patch /projects/{projectSlug} ---- \ No newline at end of file +openapi: patch /projects/{workspaceSlug} +--- diff --git a/apps/docs/api-reference/endpoint/public/get--atom.mdx b/apps/docs/api-reference/endpoint/public/get--atom.mdx index 0dff71a..c302201 100644 --- a/apps/docs/api-reference/endpoint/public/get--atom.mdx +++ b/apps/docs/api-reference/endpoint/public/get--atom.mdx @@ -1,4 +1,4 @@ --- title: Retrieve Project Atom Feed -openapi: get /{projectSlug}/atom ---- \ No newline at end of file +openapi: get /{workspaceSlug}/atom +--- diff --git a/apps/docs/api-reference/endpoint/public/get--changelogs.mdx b/apps/docs/api-reference/endpoint/public/get--changelogs.mdx index ef6d2e7..1cfde5b 100644 --- a/apps/docs/api-reference/endpoint/public/get--changelogs.mdx +++ b/apps/docs/api-reference/endpoint/public/get--changelogs.mdx @@ -1,4 +1,4 @@ --- title: List Project Changelogs -openapi: get /{projectSlug}/changelogs ---- \ No newline at end of file +openapi: get /{workspaceSlug}/changelogs +--- diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..d5fce8d --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,7 @@ +{ + "name": "docs", + "version": "0.1.0", + "scripts": { + "dev": "mintlify dev --port 3001" + } +} diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index df11621..473f095 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -1,7 +1,20 @@ module.exports = { root: true, - extends: ['custom/next'], + extends: ['@feedbase/eslint-config/next.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + }, + ignorePatterns: ['dist', '.eslintrc.js', 'postcss.config.js', 'tailwind.config.js'], + settings: { + 'import/resolver': { + typescript: { + project: ['packages/*/tsconfig.json', 'apps/*/tsconfig.json', 'tsconfig.json'], + }, + }, + }, globals: { Messages: 'readonly', + NodeJS: true, }, }; diff --git a/apps/web/app/[project]/feedback/[id]/loading.tsx b/apps/web/app/[project]/feedback/[id]/loading.tsx deleted file mode 100644 index 33a46c8..0000000 --- a/apps/web/app/[project]/feedback/[id]/loading.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { Separator } from '@ui/components/ui/separator'; -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackPageLoading() { - return ( -
Upvotes
Status
Tags
Created
Author
Comments
- Have a suggestion or found a bug? Let us know! -
+
← Back to Changelog
{new Date(changelog.publish_date!).toLocaleDateString('en-US', { year: 'numeric', @@ -106,7 +116,7 @@ export default async function ChangelogPage({ params }: Props) { {/* Image */} - + {changelog.author.full_name[0]} @@ -127,7 +137,7 @@ export default async function ChangelogPage({ params }: Props) { {/* Name & Date */} {changelog.author.full_name} - + {new Date(changelog.publish_date!).toLocaleDateString('en-US', { year: 'numeric', @@ -146,7 +156,7 @@ export default async function ChangelogPage({ params }: Props) { className='text-foreground/70 hover:text-foreground/95 transition-all duration-200 hover:scale-110' href={`https://twitter.com/intent/tweet?text=Make sure to check out ${changelog.title} by ${ changelog.author.full_name - }!&url=${formatRootUrl(params.project, `/changelog/${changelog.slug}`)}`} + }!&url=${formatRootUrl(params.workspace, `/changelog/${changelog.slug}`)}`} target='_blank' rel='noopener noreferrer'> @@ -158,7 +168,7 @@ export default async function ChangelogPage({ params }: Props) { LUM-32 dangerouslySetInnerHTML={{ __html: changelog.content! }} /> @@ -178,7 +188,7 @@ export default async function ChangelogPage({ params }: Props) { + className='text-foreground/60 hover:text-foreground w-full text-sm transition-colors'> ← {changelogs[changelogIndex - 1]?.title} @@ -189,7 +199,7 @@ export default async function ChangelogPage({ params }: Props) { + className='text-foreground/60 hover:text-foreground w-full text-sm transition-colors'> {changelogs[changelogIndex + 1]?.title} → diff --git a/apps/web/app/[project]/changelog/loading.tsx b/apps/web/app/[workspace]/changelog/loading.tsx similarity index 92% rename from apps/web/app/[project]/changelog/loading.tsx rename to apps/web/app/[workspace]/changelog/loading.tsx index e60375d..f71b560 100644 --- a/apps/web/app/[project]/changelog/loading.tsx +++ b/apps/web/app/[workspace]/changelog/loading.tsx @@ -1,5 +1,5 @@ -import { Separator } from '@ui/components/ui/separator'; -import { Skeleton } from '@ui/components/ui/skeleton'; +import { Separator } from '@feedbase/ui/components/separator'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function ChangelogLoading() { return ( @@ -9,7 +9,7 @@ export default function ChangelogLoading() { - + diff --git a/apps/web/app/[project]/changelog/page.tsx b/apps/web/app/[workspace]/changelog/page.tsx similarity index 52% rename from apps/web/app/[project]/changelog/page.tsx rename to apps/web/app/[workspace]/changelog/page.tsx index 0e56224..cbfdb5e 100644 --- a/apps/web/app/[project]/changelog/page.tsx +++ b/apps/web/app/[workspace]/changelog/page.tsx @@ -2,36 +2,42 @@ import { Metadata } from 'next'; import Image from 'next/image'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { fontMono } from '@ui/styles/fonts'; -import { Separator } from 'ui/components/ui/separator'; -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; -import AnalyticsWrapper from '@/components/hub/analytics-wrapper'; -import SubscribeToEmailUpdates from '@/components/hub/modals/subscribe-email-modal'; +import { Separator } from '@feedbase/ui/components/separator'; +import { fontMono } from '@feedbase/ui/styles/fonts'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import SubscribeToEmailUpdates from '@/components/modals/subscribe-email-modal'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; type Props = { - params: { project: string }; + params: { workspace: string }; }; // Metadata export async function generateMetadata({ params }: Props): Promise { - // Get project - const { data: project, error } = await getProjectBySlug(params.project, 'server', true, false); + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); - // If project is undefined redirects to 404 - if (error?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { notFound(); } return { - title: `Changelog - ${project.name}`, - description: `All the latest updates, improvements, and fixes to ${project.name}.`, + title: `Changelog - ${workspace.name}`, + description: `All the latest updates, improvements, and fixes to ${workspace.name}.`, }; } export default async function Changelogs({ params }: Props) { // Get changelogs - const { data: changelogs, error } = await getPublicProjectChangelogs(params.project, 'server', true, false); + const { data: changelogs, error } = await getPublicWorkspaceChangelogs( + params.workspace, + 'server', + true, + false + ); // If error.status redirects to 404 if (error?.status === 404 || !changelogs) { @@ -40,82 +46,82 @@ export default async function Changelogs({ params }: Props) { // Sort changelogs by publish_date (newest first) changelogs.sort((a, b) => { - return new Date(b.publish_date!).getTime() - new Date(a.publish_date!).getTime(); + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); }); - // Get project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - params.project, + // Get workspace config + const { data: workspaceConfig, error: workspaceConfigError } = await getWorkspaceModuleConfig( + params.workspace, 'server', true, false ); // If error.status redirects to 404 - if (projectConfigError?.status === 404 || !projectConfig) { + if (workspaceConfigError?.status === 404 || !workspaceConfig) { notFound(); } - // Get project - const { data: project, error: projectError } = await getProjectBySlug( - params.project, + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug( + params.workspace, 'server', true, false ); - // If project is undefined redirects to 404 - if (projectError?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (workspaceError?.status === 404 || !workspace) { notFound(); } return ( - - - - Changelog - - All the latest updates, improvements, and fixes to {project.name}. + + + {/* Title, Description */} + + Changelog + + All the latest updates, improvements, and fixes to {workspace.name}.{' '} + - {/* Buttons */} - - {/* Email */} - - - Subscribe to Updates - - - - · - - {/* Twitter */} - {projectConfig.changelog_twitter_handle !== null && - projectConfig.changelog_twitter_handle !== '' && ( - - - Follow us on Twitter - - - · - - )} - - {/* RRS Update Feed */} - + {/* Email */} + + - Subscribe to Atom Feed - - + Subscribe to Updates + + + + · + + {/* Twitter */} + {workspaceConfig?.changelog_twitter_handle ? ( + + + Follow us on Twitter + + + · + + ) : null} + + {/* RRS Update Feed */} + + Subscribe to Atom Feed + @@ -134,9 +140,9 @@ export default async function Changelogs({ params }: Props) { {/* Date */} - - - {new Date(changelog.publish_date!).toLocaleDateString('en-US', { + + + {new Date(changelog.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', @@ -156,7 +162,7 @@ export default async function Changelogs({ params }: Props) { {/* Image */} {/* Summary */} - {projectConfig.changelog_preview_style === 'summary' && ( - + {workspaceConfig.changelog_preview_style === 'summary' && ( + {changelog.summary} )} - {projectConfig.changelog_preview_style === 'content' && ( + {workspaceConfig.changelog_preview_style === 'content' && ( )} @@ -198,8 +204,8 @@ export default async function Changelogs({ params }: Props) { {/* Empty State */} {changelogs.length === 0 && ( - No changelogs yet - + No changelogs yet + The latest updates, improvements, and fixes will be posted here. Stay tuned! diff --git a/apps/web/app/[project]/changelog/unsubscribe/page.tsx b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx similarity index 54% rename from apps/web/app/[project]/changelog/unsubscribe/page.tsx rename to apps/web/app/[workspace]/changelog/unsubscribe/page.tsx index a6af8c2..e6d305c 100644 --- a/apps/web/app/[project]/changelog/unsubscribe/page.tsx +++ b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx @@ -1,12 +1,12 @@ import { notFound, redirect } from 'next/navigation'; -import { getProjectBySlug } from '@/lib/api/projects'; -import UnsubscribeChangelogCard from '@/components/layout/unsubscribe-card'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import UnsubscribeChangelogCard from '@/components/changelog/unsubscribe-card'; export default async function ChangelogUnsubscribe({ params, searchParams, }: { - params: { project: string }; + params: { workspace: string }; searchParams: { subId: string }; }) { if (!searchParams.subId) { @@ -20,17 +20,17 @@ export default async function ChangelogUnsubscribe({ redirect('/'); } - // Get project - const { data: project, error } = await getProjectBySlug(params.project, 'server', true, false); + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); - // If project is undefined redirects to 404 - if (error?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { notFound(); } return ( - + ); } diff --git a/apps/web/app/[project]/feedback/[id]/page.tsx b/apps/web/app/[workspace]/feedback/[id]/page.tsx similarity index 70% rename from apps/web/app/[project]/feedback/[id]/page.tsx rename to apps/web/app/[workspace]/feedback/[id]/page.tsx index 54b8bd6..0a3d013 100644 --- a/apps/web/app/[project]/feedback/[id]/page.tsx +++ b/apps/web/app/[workspace]/feedback/[id]/page.tsx @@ -1,27 +1,31 @@ import { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { Avatar, AvatarFallback, AvatarImage } from '@ui/components/ui/avatar'; -import { Separator } from '@ui/components/ui/separator'; -import { cn } from '@ui/lib/utils'; -import { BadgeCheck, CheckCircle2, CircleDashed, CircleDot, CircleDotDashed, XCircle } from 'lucide-react'; -import { getCommentsForFeedbackById } from '@/lib/api/comments'; -import { getPublicProjectFeedback } from '@/lib/api/public'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Separator } from '@feedbase/ui/components/separator'; +import { cn } from '@feedbase/ui/lib/utils'; +import { BadgeCheck } from 'lucide-react'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; import { getCurrentUser } from '@/lib/api/user'; -import { PROSE_CN } from '@/lib/constants'; -import AnalyticsWrapper from '@/components/hub/analytics-wrapper'; -import CommentsList from '@/components/hub/feedback/comments/comments-list'; +import { PROSE_CN, STATUS_OPTIONS } from '@/lib/constants'; +import CommentsList from '@/components/feedback/hub/comments-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; type Props = { - params: { project: string; id: string }; + params: { workspace: string; id: string }; }; // Metadata export async function generateMetadata({ params }: Props): Promise { // Get feedback - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); - // If project is undefined redirects to 404 + // If workspace is undefined redirects to 404 if (error?.status === 404 || !feedbackList) { notFound(); } @@ -36,36 +40,17 @@ export async function generateMetadata({ params }: Props): Promise { return { title: feedback.title, - description: feedback.description, + description: feedback.content, }; } -// Status options -const statusOptions = [ - { - label: 'Backlog', - icon: CircleDashed, - }, - { - label: 'Planned', - icon: CircleDotDashed, - }, - { - label: 'In Progress', - icon: CircleDot, - }, - { - label: 'Completed', - icon: CheckCircle2, - }, - { - label: 'Rejected', - icon: XCircle, - }, -]; - export default async function FeedbackDetails({ params }: Props) { - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); if (error || !feedbackList) { return {error.message}; @@ -79,31 +64,19 @@ export default async function FeedbackDetails({ params }: Props) { notFound(); } - // Get comments - const { data: comments, error: commentsError } = await getCommentsForFeedbackById( - params.id, - params.project, - 'server', - false - ); - - if (commentsError) { - return {commentsError.message}; - } - // Get current user const { data: user } = await getCurrentUser('server'); return ( - + {/* // Row Splitting up date and Content */} {/* Back Button */} - - + + ← Back to Posts @@ -117,8 +90,8 @@ export default async function FeedbackDetails({ params }: Props) { {feedback.title} @@ -128,38 +101,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - - {currentStatus.label} - + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -171,7 +142,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -181,7 +152,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -193,9 +164,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -206,17 +177,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -226,19 +195,14 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} {/* Comments */} - + @@ -248,36 +212,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - {currentStatus.label} + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -289,7 +253,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -299,7 +263,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -311,9 +275,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -324,17 +288,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -345,7 +307,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} diff --git a/apps/web/app/[project]/page.tsx b/apps/web/app/[workspace]/feedback/page.tsx similarity index 73% rename from apps/web/app/[project]/page.tsx rename to apps/web/app/[workspace]/feedback/page.tsx index c6f6f88..2c59ed8 100644 --- a/apps/web/app/[project]/page.tsx +++ b/apps/web/app/[workspace]/feedback/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; -export default async function Hub() { +export default async function Feedback() { // Redirect to changelog // This page doesn't really get called as this is catched in layout.tsx but still needed to cause 404 - redirect(`/feedback`); + redirect(`/`); } diff --git a/apps/web/app/[workspace]/layout.tsx b/apps/web/app/[workspace]/layout.tsx new file mode 100644 index 0000000..da4072e --- /dev/null +++ b/apps/web/app/[workspace]/layout.tsx @@ -0,0 +1,160 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceTheme } from '@/lib/api/theme'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Header from '@/components/layout/nav-bar'; +import CustomThemeWrapper from '@/components/layout/theme-wrapper'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; + +type Props = { + children: React.ReactNode; + params: { workspace: string }; + searchParams: Record; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: workspace.name, + description: `Discover the latest updates, roadmaps, submit feedback, and explore more about ${workspace.name}.`, + icons: workspace.icon, + openGraph: { + images: [ + { + url: workspace.opengraph_image || '', + width: 1200, + height: 600, + alt: workspace.name, + }, + ], + }, + }; +} + +const tabs: { name: string; link: string; items?: { name: string; link: string }[] }[] = [ + { + name: 'Boards', + link: '/', + items: [], + }, + { + name: 'Roadmap', + link: '/roadmap', + }, + { + name: 'Changelog', + link: '/changelog', + }, +]; + +export default async function HubLayout({ children, params, searchParams }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + const hostname = headerList.get('host'); + + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + if (error?.status === 404 || !workspace) { + notFound(); + } + + // Get workspace boards + const { data: boards, error: boardsError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardsError || !boards) { + notFound(); + } + + // Set workspace boards to tabs + tabs[0].items = boards.map((board) => ({ + name: board.name, + link: `/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`, + })); + + // Get workspace config + const { data: config } = await getWorkspaceModuleConfig(params.workspace, 'server', true, false); + + // Get workspace theme + const { data: workspaceTheme } = await getWorkspaceTheme(params.workspace, 'server', true, false); + + if (!config || !workspaceTheme) { + notFound(); + } + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Check if custom domain is set and redirect to it + if (workspace.custom_domain && workspace.custom_domain_verified && hostname !== workspace.custom_domain) { + // Validate redirect rules + switch (workspace.custom_domain_redirect) { + case 'root_redirect': + return redirect(`https://${workspace.custom_domain}`); + case 'direct_redirect': + return redirect(`https://${workspace.custom_domain}${pathname}`); + case 'no_redirect': + break; + } + } + + // Get current tab + const currentTab = tabs.find((tab) => { + if (tab.link === pathname) { + return true; + } + if (tab.items) { + const subItem = tab.items.find((item) => item.link === pathname); + if (subItem) { + // Return the subitem directly + return subItem; + } + } + return false; + }); + + // Extract subItem if found, otherwise use the main tab + const foundItem = currentTab?.items?.find((item) => item.link === pathname) || currentTab; + + // Check if any modules are disabled and remove them from the tabs + if (!config.changelog_enabled) { + tabs.splice(1, 1); + } + + return ( + // + + {/* Header */} + + + {/* Main content */} + + {children} + + + ); +} diff --git a/apps/web/app/[workspace]/page.tsx b/apps/web/app/[workspace]/page.tsx new file mode 100644 index 0000000..ff32044 --- /dev/null +++ b/apps/web/app/[workspace]/page.tsx @@ -0,0 +1,98 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import FeedbackBoardList from '@/components/feedback/hub/board-list'; +import FeedbackHeader from '@/components/feedback/hub/button-header'; +import FeedbackList from '@/components/feedback/hub/feedback-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Feedback - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Fetch feedback boards + const { data: boards, error: boardError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardError) { + return {boardError.message}; + } + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + // Search for the initial board mathing the pathname by /board/board-name format + const initialBoard = boards.find( + (board) => pathname?.includes(`/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`) + ); + + return ( + + {/* Title, Description */} + + + Feedback + + Have a suggestion or found a bug? Let us know! + + + + + {/* Seperator */} + + + + {/* Filter Header, Feedback List */} + + {/* Filter Header */} + + {/* Feedback List */} + + + {/* Boards */} + + + + ); +} diff --git a/apps/web/app/[workspace]/roadmap/page.tsx b/apps/web/app/[workspace]/roadmap/page.tsx new file mode 100644 index 0000000..3648c14 --- /dev/null +++ b/apps/web/app/[workspace]/roadmap/page.tsx @@ -0,0 +1,69 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Roadmap from '@/components/roadmap/hub/roadmap'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Roadmap - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + return ( + + {/* Title, Description */} + + + Roadmap + + View what we're working on and what's coming next. + + + + + {/* Seperator */} + + + {/* Roadmap */} + + + ); +} diff --git a/apps/web/app/api/trigger/route.ts b/apps/web/app/api/trigger/route.ts new file mode 100644 index 0000000..0b08117 --- /dev/null +++ b/apps/web/app/api/trigger/route.ts @@ -0,0 +1,9 @@ +import { client } from '@feedbase/triggers'; +import { createAppRoute } from '@trigger.dev/nextjs'; +import '@feedbase/triggers/jobs'; + +//this route is used to send and receive data with Trigger.dev +export const { POST, dynamic } = createAppRoute(client); + +//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration +export const maxDuration = 60; diff --git a/apps/web/app/api/v1/[slug]/atom/route.ts b/apps/web/app/api/v1/[slug]/atom/route.ts index 1218c5b..198eaeb 100644 --- a/apps/web/app/api/v1/[slug]/atom/route.ts +++ b/apps/web/app/api/v1/[slug]/atom/route.ts @@ -1,20 +1,20 @@ import { NextResponse } from 'next/server'; -import { getProjectBySlug } from '@/lib/api/projects'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; /* - Generate atom feed for project changelog + Generate atom feed for workspace changelog */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project data - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route', true, false); + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - const { data: changelogs, error: changelogError } = await getPublicProjectChangelogs( + const { data: changelogs, error: changelogError } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, @@ -30,12 +30,12 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return new Response( ` - ${project.name} Changelog - ${project.name}'s Changelog + ${workspace.name} Changelog + ${workspace.name}'s Changelog ${changelogs[0].publish_date} - ${project.id}${changelogs + ${workspace.id}${changelogs .map((post) => { return ` diff --git a/apps/web/app/api/v1/[slug]/changelogs/route.ts b/apps/web/app/api/v1/[slug]/changelogs/route.ts index 8650092..1f46ed6 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getPublicProjectChangelogs( + const { data: changelogs, error } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, diff --git a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts index f225b75..0f96984 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import { subscribeToProjectChangelogs, unsubscribeFromProjectChangelogs } from '@/lib/api/public'; +import { subscribeToWorkspaceChangelogs, unsubscribeFromWorkspaceChangelogs } from '@/lib/api/public'; /* - Subscribe to project changelogs + Subscribe to workspace changelogs POST /api/v1/:slug/changelogs/subscribers { email: string, @@ -16,8 +16,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'email is required.' }, { status: 400 }); } - // Subscribe to project changelogs - const { data: subscriber, error } = await subscribeToProjectChangelogs(context.params.slug, email); + // Subscribe to workspace changelogs + const { data: subscriber, error } = await subscribeToWorkspaceChangelogs(context.params.slug, email); // If any errors thrown, return error if (error) { @@ -29,7 +29,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Unsubscribe from project changelogs + Unsubscribe from workspace changelogs DELETE /api/v1/:slug/changelogs/subscribers { subId: string, @@ -43,8 +43,8 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: 'subId is required.' }, { status: 400 }); } - // Unsubscribe from project changelogs - const { error } = await unsubscribeFromProjectChangelogs(context.params.slug, subId); + // Unsubscribe from workspace changelogs + const { error } = await unsubscribeFromWorkspaceChangelogs(context.params.slug, subId); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/[slug]/feedback/route.ts b/apps/web/app/api/v1/[slug]/feedback/route.ts new file mode 100644 index 0000000..51e8479 --- /dev/null +++ b/apps/web/app/api/v1/[slug]/feedback/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; + +/* + Get public feedback + GET /api/v1/[slug]/feedback + */ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: feedback, error } = await getPublicWorkspaceFeedback( + context.params.slug, + 'route', + true, + false + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/[slug]/sso/route.ts b/apps/web/app/api/v1/[slug]/sso/route.ts index 889291e..92ead6c 100644 --- a/apps/web/app/api/v1/[slug]/sso/route.ts +++ b/apps/web/app/api/v1/[slug]/sso/route.ts @@ -2,7 +2,7 @@ import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@supabase/supabase-js'; import { JwtPayload, verify } from 'jsonwebtoken'; -import { getProjectBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; export async function GET(req: NextRequest, context: { params: { slug: string } }) { const redirectTo = req.nextUrl.searchParams.get('redirect_to'); @@ -18,33 +18,33 @@ export async function GET(req: NextRequest, context: { params: { slug: string } const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY); - // Get project by slug - const { data: project, error: projectError } = await getProjectBySlug( + // Get workspace by slug + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug( context.params.slug, 'route', true, false ); - if (projectError) { - return NextResponse.json(projectError, { status: 500 }); + if (workspaceError) { + return NextResponse.json(workspaceError, { status: 500 }); } - // Get projects jwt secret - const { data: projectConfig, error: projectConfigError } = await supabase - .from('project_configs') + // Get workspaces jwt secret + const { data: workspaceConfig, error: workspaceConfigError } = await supabase + .from('workspace_configs') .select('integration_sso_secret') - .eq('project_id', project.id) + .eq('workspace_id', workspace.id) .single(); - if (projectConfigError) { - return NextResponse.json(projectConfigError.message, { status: 500 }); + if (workspaceConfigError) { + return NextResponse.json(workspaceConfigError.message, { status: 500 }); } // Verify jwt let payload: JwtPayload; try { - const decoded = verify(jwtPayload, projectConfig?.integration_sso_secret as string); + const decoded = verify(jwtPayload, workspaceConfig?.integration_sso_secret as string); payload = decoded as JwtPayload; } catch (error) { if (error instanceof Error) { @@ -59,7 +59,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid payload' }, { status: 500 }); } - const email = (payload.email as string).replace('@', `+${project.id}@`); + const email = (payload.email as string).replace('@', `+${workspace.id}@`); // Create user based on jwt payload const { error } = await supabase.auth.admin.createUser({ diff --git a/apps/web/app/api/v1/[slug]/views/route.ts b/apps/web/app/api/v1/[slug]/views/route.ts index 9b038e7..ec0681e 100644 --- a/apps/web/app/api/v1/[slug]/views/route.ts +++ b/apps/web/app/api/v1/[slug]/views/route.ts @@ -3,7 +3,7 @@ import { recordClick } from '@/lib/tinybird'; /* Record page view - POST /api/v1/[project]/views + POST /api/v1/[workspace]/views { "feedbackId": "string", "changelogId": "string", @@ -19,7 +19,7 @@ export async function POST(req: NextRequest, context: { params: { slug: string } const data = await recordClick({ req, - projectId: context.params.slug, + workspaceId: context.params.slug, feedbackId, changelogId, }); diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts b/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts deleted file mode 100644 index 72a5472..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; - -/* - Update SSO configuration - PATCH /api/v1/projects/:slug/config/integrations/sso - { - status: boolean, - url: string, - secret: string, - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, url, secret } = await req.json(); - - if (status && (!url || !secret)) { - return NextResponse.json( - { error: 'url and secret are required when enabling SSO integration.' }, - { status: 400 } - ); - } - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - integration_sso_status: status, - integration_sso_url: status ? url : null, - integration_sso_secret: status ? secret : null, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/config/route.ts b/apps/web/app/api/v1/projects/[slug]/config/route.ts deleted file mode 100644 index 938fdfb..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; -import { ProjectConfigProps } from '@/lib/types'; - -/* - Get Project Config by slug - GET /api/v1/projects/[slug]/config -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: projectConfig, error } = await getProjectConfigBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project config - return NextResponse.json(projectConfig, { status: 200 }); -} - -/* - Update Project Config by slug - PATCH /api/v1/projects/[slug]/config - { - changelog_preview_style: string; - changelog_twitter_handle: string; - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - } = (await req.json()) as ProjectConfigProps['Update']; - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts b/apps/web/app/api/v1/projects/[slug]/feedback/route.ts deleted file mode 100644 index 7c24e3d..0000000 --- a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createFeedback, getAllProjectFeedback } from '@/lib/api/feedback'; -import { FeedbackWithUserInputProps } from '@/lib/types'; - -export const runtime = 'edge'; - -/* - Create Feedback - POST /api/v1/projects/[slug]/feedback - { - title: string; - description: string; - status: string; - tags: [id, id, id] - } -*/ -export async function POST(req: Request, context: { params: { slug: string } }) { - const { title, description, status, tags, user } = (await req.json()) as FeedbackWithUserInputProps; - - // Validate Request Body - if (!title) { - return NextResponse.json({ error: 'title is required when creating feedback.' }, { status: 400 }); - } - - const { data: feedback, error } = await createFeedback( - context.params.slug, - { - title: title || '', - description: description || '', - status: status || '', - project_id: 'dummy-id', - user_id: 'dummy-id', - tags: tags || [], - user: user !== null ? user : undefined, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} - -/* - Get Project Feedback - GET /api/v1/projects/[slug]/feedback -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route', false); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/route.ts b/apps/web/app/api/v1/projects/[slug]/route.ts deleted file mode 100644 index ddfa07b..0000000 --- a/apps/web/app/api/v1/projects/[slug]/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextResponse } from 'next/server'; -import { deleteProjectBySlug, getProjectBySlug, updateProjectBySlug } from '@/lib/api/projects'; -import { ProjectProps } from '@/lib/types'; - -/* - Get project by slug - GET /api/v1/projects/[slug] -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route'); - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project - return NextResponse.json({ project }, { status: 200 }); -} - -/* - Update project by slug - PATCH /api/v1/projects/[slug] - { - name: string, - slug: string, - icon: string, - icon_radius: string, - og_image: string - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - name, - slug, - icon, - icon_radius: iconRadius, - og_image: OGImage, - } = (await req.json()) as ProjectProps['Update']; - - const { data: updatedProject, error } = await updateProjectBySlug( - context.params.slug, - { name, slug, icon, icon_radius: iconRadius, og_image: OGImage }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project - return NextResponse.json(updatedProject, { status: 200 }); -} - -/* - Delete project by slug - DELETE /api/v1/projects/[slug] -*/ -export async function DELETE(req: Request, context: { params: { slug: string } }) { - const { data, error } = await deleteProjectBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return success - return NextResponse.json({ data }, { status: 200 }); -} diff --git a/apps/web/app/api/v1/route.ts b/apps/web/app/api/v1/route.ts index 56224ea..eb89aaf 100644 --- a/apps/web/app/api/v1/route.ts +++ b/apps/web/app/api/v1/route.ts @@ -23,16 +23,16 @@ export function GET(): NextResponse { }, ], paths: { - '/{projectSlug}/atom': { + '/{workspaceSlug}/atom': { get: { - description: 'Generate atom feed for project changelog', - operationId: 'getProjectChangelogsAtom', + description: 'Generate atom feed for workspace changelog', + operationId: 'getWorkspaceChangelogsAtom', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -51,7 +51,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -61,7 +61,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -73,16 +73,16 @@ export function GET(): NextResponse { }, }, }, - '/{projectSlug}/changelogs': { + '/{workspaceSlug}/changelogs': { get: { - description: 'Get public project changelogs', - operationId: 'getPublicProjectChangelogs', + description: 'Get public workspace changelogs', + operationId: 'getPublicWorkspaceChangelogs', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -104,7 +104,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -114,7 +114,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -126,15 +126,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}': { + '/workspaces/{workspaceSlug}': { get: { - description: 'Get a project by slug', - operationId: 'getProjectBySlug', + description: 'Get a workspace by slug', + operationId: 'getWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -147,13 +147,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -163,7 +163,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -175,13 +175,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update a project by slug', - operationId: 'updateProjectBySlug', + description: 'Update a workspace by slug', + operationId: 'updateWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -189,12 +189,12 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project object that needs to be updated', + description: 'Workspace object that needs to be updated', required: true, content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectUpdate', + $ref: '#/components/schemas/WorkspaceUpdate', }, }, }, @@ -205,13 +205,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -221,7 +221,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -233,13 +233,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete a project by slug', - operationId: 'deleteProjectBySlug', + description: 'Delete a workspace by slug', + operationId: 'deleteWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -252,13 +252,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -268,7 +268,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -280,15 +280,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs': { + '/workspaces/{workspaceSlug}/changelogs': { get: { - description: 'Get all project changelogs', - operationId: 'getProjectChangelogs', + description: 'Get all workspace changelogs', + operationId: 'getWorkspaceChangelogs', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -310,7 +310,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -320,7 +320,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -332,13 +332,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project changelog', - operationId: 'createProjectChangelog', + description: 'Create workspace changelog', + operationId: 'createWorkspaceChangelog', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -368,7 +368,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -378,7 +378,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -390,15 +390,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs/{changelogId}': { + '/workspaces/{workspaceSlug}/changelogs/{changelogId}': { put: { - description: 'Update project changelog by id', - operationId: 'updateProjectChangelogById', + description: 'Update workspace changelog by id', + operationId: 'updateWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -437,21 +437,21 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, delete: { - description: 'Delete project changelog by id', - operationId: 'deleteProjectChangelogById', + description: 'Delete workspace changelog by id', + operationId: 'deleteWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -479,23 +479,23 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, }, - '/projects/{projectSlug}/config': { + '/workspaces/{workspaceSlug}/config': { get: { - description: 'Get project config', - operationId: 'getProjectConfig', + description: 'Get workspace config', + operationId: 'getWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -508,13 +508,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -524,7 +524,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -536,13 +536,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project config', - operationId: 'updateProjectConfig', + description: 'Update workspace config', + operationId: 'updateWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -550,13 +550,13 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project config object that needs to be updated', + description: 'Workspace config object that needs to be updated', required: true, content: { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfigUpdate', + $ref: '#/components/schemas/WorkspaceConfigUpdate', }, }, }, @@ -568,13 +568,13 @@ export function GET(): NextResponse { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -584,7 +584,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -596,15 +596,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback': { + '/workspaces/{workspaceSlug}/feedback': { get: { - description: 'Get all project feedback', - operationId: 'getProjectFeedback', + description: 'Get all workspace feedback', + operationId: 'getWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -626,7 +626,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -636,7 +636,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -648,13 +648,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project feedback', - operationId: 'createProjectFeedback', + description: 'Create workspace feedback', + operationId: 'createWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -684,7 +684,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -694,7 +694,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -706,15 +706,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}': { get: { - description: 'Get project feedback by id', - operationId: 'getProjectFeedbackById', + description: 'Get workspace feedback by id', + operationId: 'getWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -742,7 +742,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -764,13 +764,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project feedback by id', - operationId: 'updateProjectFeedbackById', + description: 'Update workspace feedback by id', + operationId: 'updateWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -809,7 +809,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -831,13 +831,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete project feedback by id', - operationId: 'deleteProjectFeedbackById', + description: 'Delete workspace feedback by id', + operationId: 'deleteWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -865,7 +865,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -887,15 +887,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments': { get: { description: 'Get feedback comments', operationId: 'getFeedbackComments', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -926,7 +926,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -936,7 +936,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -948,15 +948,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments/{commentId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments/{commentId}': { delete: { - description: 'Delete project feedback comment by id', - operationId: 'deleteProjectFeedbackCommentById', + description: 'Delete workspace feedback comment by id', + operationId: 'deleteWorkspaceFeedbackCommentById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -993,7 +993,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1003,7 +1003,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -1015,15 +1015,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/upvotes': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/upvotes': { get: { description: 'Get feedback upvoters', operationId: 'getFeedbackUpvoters', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1054,7 +1054,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1076,15 +1076,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags': { + '/workspaces/{workspaceSlug}/feedback/tags': { get: { description: 'Get feedback tags', operationId: 'getFeedbackTags', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1106,7 +1106,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1116,7 +1116,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1132,9 +1132,9 @@ export function GET(): NextResponse { operationId: 'createFeedbackTag', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1164,7 +1164,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1174,7 +1174,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1186,15 +1186,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags/{tagName}': { + '/workspaces/{workspaceSlug}/feedback/tags/{tagName}': { delete: { description: 'Delete feedback tag by name', operationId: 'deleteFeedbackTagByName', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1222,7 +1222,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or tag name supplied', + description: 'Invalid workspace slug or tag name supplied', content: { 'application/json': { schema: { @@ -1232,7 +1232,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or tag not found', + description: 'Workspace or tag not found', content: { 'application/json': { schema: { @@ -1244,15 +1244,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites': { + '/workspaces/{workspaceSlug}/invites': { get: { - description: 'List project team invites', - operationId: 'getProjectInvites', + description: 'List workspace team invites', + operationId: 'getWorkspaceInvites', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1267,14 +1267,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ExtendedProjectInvite', + $ref: '#/components/schemas/ExtendedWorkspaceInvite', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1284,7 +1284,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1296,13 +1296,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create a project invite', - operationId: 'createProjectInvite', + description: 'Create a workspace invite', + operationId: 'createWorkspaceInvite', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1310,7 +1310,7 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project invite object that needs to be created', + description: 'Workspace invite object that needs to be created', required: true, content: { 'application/json': { @@ -1331,13 +1331,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1347,7 +1347,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1359,15 +1359,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites/{inviteId}': { + '/workspaces/{workspaceSlug}/invites/{inviteId}': { delete: { - description: 'Delete project invite by id', - operationId: 'deleteProjectInviteById', + description: 'Delete workspace invite by id', + operationId: 'deleteWorkspaceInviteById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1389,13 +1389,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1405,7 +1405,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1417,15 +1417,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/members': { + '/workspaces/{workspaceSlug}/members': { get: { - description: 'Get project members', - operationId: 'getProjectMembers', + description: 'Get workspace members', + operationId: 'getWorkspaceMembers', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1441,14 +1441,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ProjectMember', + $ref: '#/components/schemas/WorkspaceMember', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1458,7 +1458,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1473,7 +1473,7 @@ export function GET(): NextResponse { }, components: { schemas: { - Project: { + Workspace: { type: 'object', properties: { id: { @@ -1501,7 +1501,7 @@ export function GET(): NextResponse { }, }, }, - ProjectUpdate: { + WorkspaceUpdate: { type: 'object', properties: { name: { @@ -1528,7 +1528,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1567,7 +1567,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1667,14 +1667,14 @@ export function GET(): NextResponse { }, }, }, - ProjectConfig: { + WorkspaceConfig: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1705,7 +1705,7 @@ export function GET(): NextResponse { }, }, }, - ProjectConfigUpdate: { + WorkspaceConfigUpdate: { type: 'object', properties: { changelog_preview_style: { @@ -1723,7 +1723,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1759,7 +1759,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1998,7 +1998,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2025,14 +2025,14 @@ export function GET(): NextResponse { }, }, }, - ProjectInvite: { + WorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2052,18 +2052,18 @@ export function GET(): NextResponse { }, }, }, - ExtendedProjectInvite: { + ExtendedWorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, - project: { + workspace: { type: 'object', properties: { name: { @@ -2101,7 +2101,7 @@ export function GET(): NextResponse { }, }, }, - ProjectMember: { + WorkspaceMember: { type: 'object', properties: { id: { diff --git a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts similarity index 71% rename from apps/web/app/api/v1/projects/[slug]/config/domain/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts index c5a2d2b..cc55d24 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts @@ -1,21 +1,21 @@ import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; /* - GET /api/v1/projects/:slug/config/domain + GET /api/v1/workspaces/:slug/domain */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } const [configResponse, domainResponse] = await Promise.all([ fetch( - `https://api.vercel.com/v6/domains/${data.custom_domain}/config${ + `https://api.vercel.com/v6/domains/${workspace.custom_domain}/config${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -27,9 +27,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } ), fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data.custom_domain}${ - process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' - }`, + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ + workspace.custom_domain + }${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'GET', headers: { @@ -58,7 +58,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { if (!domainData.verified && !configData.misconfigured) { const verifyResponse = await fetch( `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ - data.custom_domain + workspace.custom_domain }/verify${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'POST', @@ -102,9 +102,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { ); } - // If verification succeeded, update project config + // If verification succeeded, update workspace config if (domainData?.verified && !configData.misconfigured) { - const { error: updateError } = await updateProjectConfigBySlug( + const { error: updateError } = await updateWorkspaceBySlug( context.params.slug, { custom_domain_verified: true }, 'route' @@ -128,7 +128,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - POST /api/v1/projects/:slug/config/domain + POST /api/v1/workspaces/:slug/domain { "name": "example.com" } @@ -140,20 +140,17 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'Name is required' }, { status: 400 }); } - // Get project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - context.params.slug, - 'route' - ); + // Get workspace config + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (projectConfigError) { - return NextResponse.json({ error: projectConfigError.message }, { status: projectConfigError.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } - // If project already has a domain, return error - if (projectConfig.custom_domain) { - return NextResponse.json({ error: 'Project already has a custom domain' }, { status: 409 }); + // If workspace already has a domain, return error + if (workspace.custom_domain) { + return NextResponse.json({ error: 'Workspace already has a custom domain' }, { status: 409 }); } const response = await fetch( @@ -188,8 +185,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) ); } - // Update project config - const { error } = await updateProjectConfigBySlug( + // Update workspace config + const { error } = await updateWorkspaceBySlug( context.params.slug, { custom_domain: responseData.name, custom_domain_verified: false }, 'route' @@ -205,25 +202,25 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - DELETE /api/v1/projects/:slug/config/domain + DELETE /api/v1/workspaces/:slug/domain */ export async function DELETE(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } // If no domain, return error - if (!data?.custom_domain) { - return NextResponse.json({ error: 'Project does not have a custom domain' }, { status: 400 }); + if (!workspace?.custom_domain) { + return NextResponse.json({ error: 'Workspace does not have a custom domain' }, { status: 400 }); } // Delete domain from Vercel const response = await fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data?.custom_domain}${ + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${workspace?.custom_domain}${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -242,21 +239,21 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: responseData.error.message }, { status: 400 }); } - // Update project config - const { data: updatedProjectConfig, error: updatedProjectConfigError } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error: updatedWorkspaceConfigError } = await updateWorkspaceBySlug( context.params.slug, - { custom_domain: null, custom_domain_verified: null }, + { custom_domain: null, custom_domain_verified: false }, 'route' ); // If any errors thrown, return error - if (updatedProjectConfigError) { + if (updatedWorkspaceConfigError) { return NextResponse.json( - { error: updatedProjectConfigError.message }, - { status: updatedProjectConfigError.status } + { error: updatedWorkspaceConfigError.message }, + { status: updatedWorkspaceConfigError.status } ); } // Return response - return NextResponse.json(updatedProjectConfig, { status: 200 }); + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts index 2c483cf..003e70b 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts @@ -1,37 +1,37 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Discord integration - PATCH /api/v1/projects/:slug/config/integrations/discord + PATCH /api/v1/workspaces/:slug/config/integrations/discord { - status: boolean, + enabled: boolean, webhook: string, roleId: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook, roleId } = (await req.json()) as { - status: boolean; + const { enabled, webhook, roleId } = (await req.json()) as { + enabled: boolean; webhook: string; - roleId: string; + roleId: number; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Discord integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_discord_status: status, - integration_discord_webhook: status ? webhook : null, - integration_discord_role_id: status ? roleId : null, + discord_enabled: enabled, + discord_webhook: enabled ? webhook : null, + discord_role_id: enabled ? roleId : null, }, 'route' ); @@ -41,6 +41,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts index 25496b4..2c310e4 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts @@ -1,34 +1,34 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Slack integration - PATCH /api/v1/projects/:slug/config/integrations/slack + PATCH /api/v1/workspaces/:slug/config/integrations/slack { status: boolean, webhook: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook } = (await req.json()) as { - status: boolean; + const { enabled, webhook } = (await req.json()) as { + enabled: boolean; webhook: string; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Slack integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_slack_status: status, - integration_slack_webhook: status ? webhook : null, + slack_enabled: enabled, + slack_webhook: enabled ? webhook : null, }, 'route' ); @@ -38,6 +38,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts new file mode 100644 index 0000000..700e0f0 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; + +/* + Get workspace module config + GET /api/v1/workspaces/[slug]/modules +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace module config + const { data: moduleConfig, error } = await getWorkspaceModuleConfig(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(moduleConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts new file mode 100644 index 0000000..36ed41c --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { updateWorkspaceBySlug } from '@/lib/api/workspace'; + +/* + Update SSO configuration + PATCH /api/v1/workspaces/:slug/config/integrations/sso + { + enabled: boolean, + url: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { enabled, url } = await req.json(); + + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceBySlug( + context.params.slug, + { + sso_auth_enabled: enabled, + sso_auth_url: url, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts new file mode 100644 index 0000000..eeb54b4 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { createWorkspaceSSOSecret } from '@/lib/api/workspace'; + +/* + Generate random jwt secret + POST /api/v1/workspaces/:slug/config/integrations/sso/secret +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { data, error } = await createWorkspaceSSOSecret(context.params.slug, 'route'); + + // If there is an error, return it + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(data, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts new file mode 100644 index 0000000..0f5eb8b --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceTheme, updateWorkspaceTheme } from '@/lib/api/theme'; + +/* + Get workspace theme + GET /api/v1/workspaces/:slug/theme +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace theme + const { data: theme, error } = await getWorkspaceTheme(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(theme, { status: 200 }); +} + +/* + Update workspace theme + PATCH /api/v1/workspaces/:slug/theme + { + "accent": "string", + "background": "string", + "border": "string", + "foreground": "string", + "root": "string", + "secondary_background": "string", + "theme": "string" + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + } = await req.json(); + + // Update workspace theme + const { data: updatedTheme, error } = await updateWorkspaceTheme( + context.params.slug, + { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + }, + 'route' + ); + + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + return NextResponse.json(updatedTheme, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/analytics/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts index 03d0ba4..eb95e97 100644 --- a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getProjectAnalytics } from '@/lib/api/projects'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; /* Get Analytics - GET /api/v1/projects/:slug/analytics + GET /api/v1/workspaces/:slug/analytics */ export async function GET(req: NextRequest, context: { params: { slug: string } }) { // Check if tinybird variables are set @@ -20,7 +20,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid start or end date.' }, { status: 400 }); } - const { data: analyticsData, error } = await getProjectAnalytics(context.params.slug, 'route'); + const { data: analyticsData, error } = await getWorkspaceAnalytics(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts index 436bbf4..bcf2640 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { deleteProjectApiKey } from '@/lib/api/projects'; +import { deleteWorkspaceApiKey } from '@/lib/api/api-key'; /* - Delete api key for a project - DELETE /api/v1/projects/:slug/config/api/:token + Delete api key for a workspace + DELETE /api/v1/workspaces/:slug/config/api/:token */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { - const { error } = await deleteProjectApiKey(context.params.slug, context.params.id, 'route'); + const { error } = await deleteWorkspaceApiKey(context.params.slug, context.params.id, 'route'); if (error) { return NextResponse.json({ error }, { status: error.status }); diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts similarity index 65% rename from apps/web/app/api/v1/projects/[slug]/api-keys/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts index b40c674..28aab04 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts @@ -1,13 +1,14 @@ import { NextResponse } from 'next/server'; -import { createProjectApiKey, getProjectApiKeys } from '@/lib/api/projects'; +import { createWorkspaceApiKey, getWorkspaceApiKeys } from '@/lib/api/api-key'; +import { ApiKeyPermissions } from '@/lib/types'; /* - Get all API keys for a project - GET /api/v1/projects/:slug/config/api + Get all API keys for a workspace + GET /api/v1/workspaces/:slug/config/api */ export async function GET(req: Request, context: { params: { slug: string } }) { // Get all api keys - const { data: apiKeys, error } = await getProjectApiKeys(context.params.slug, 'route'); + const { data: apiKeys, error } = await getWorkspaceApiKeys(context.params.slug, 'route'); if (error) { return NextResponse.json(error, { status: error.status }); @@ -17,15 +18,15 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Create a new API key for a project - POST /api/v1/projects/:slug/config/api + Create a new API key for a workspace + POST /api/v1/workspaces/:slug/config/api { "name": "string", "permissions": "string" } */ export async function POST(req: Request, context: { params: { slug: string } }) { - const { name, permission } = (await req.json()) as { name: string; permission: string }; + const { name, permission } = (await req.json()) as { name: string; permission: ApiKeyPermissions }; // Validate input if (!name || !permission) { @@ -33,7 +34,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } // Create api key - const { data: apiKey, error } = await createProjectApiKey( + const { data: apiKey, error } = await createWorkspaceApiKey( context.params.slug, { name, permission }, 'route' diff --git a/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts new file mode 100644 index 0000000..3af8b5f --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceBoards } from '@/lib/api/boards'; + +/* + Get all workspace boards + GET /api/v1/workspaces/[slug]/boards +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: boards, error } = await getWorkspaceBoards(context.params.slug, 'route', true, false); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return boards + return NextResponse.json(boards, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts similarity index 88% rename from apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts index c454d15..842afd2 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server'; -import { deleteChangelog, updateChangelog } from '@/lib/api/changelogs'; +import { deleteChangelog, updateChangelog } from '@/lib/api/changelog'; export const runtime = 'edge'; /* - Update project changelog - PUT /api/v1/projects/[slug]/changelogs/[id] + Update workspace changelog + PUT /api/v1/workspaces/[slug]/changelogs/[id] { title: string; summary: string; @@ -35,8 +35,8 @@ export async function PUT(req: Request, context: { params: { slug: string; id: s } /* - Delete project changelog - DELETE /api/v1/projects/[slug]/changelogs/[id] + Delete workspace changelog + DELETE /api/v1/workspaces/[slug]/changelogs/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { const { data, error } = await deleteChangelog(context.params.id, context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts similarity index 74% rename from apps/web/app/api/v1/projects/[slug]/changelogs/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts index bd84f37..8689301 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createChangelog, getAllProjectChangelogs } from '@/lib/api/changelogs'; +import { createChangelog, getAllWorkspaceChangelogs } from '@/lib/api/changelog'; import { ChangelogProps } from '@/lib/types'; export const runtime = 'edge'; /* Create Changelog - POST /api/v1/projects/[slug]/changelogs + POST /api/v1/workspaces/[slug]/changelogs { title: string; summary: string; @@ -21,10 +21,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) title, summary, content, - image, + thumbnail, publish_date: publishDate, published, - } = (await req.json()) as ChangelogProps['Insert']; + notify_subscribers: notifySubscribers, + } = (await req.json()) as ChangelogProps['Insert'] & { notify_subscribers: boolean }; // Validate Request Body if (published) { @@ -42,13 +43,14 @@ export async function POST(req: Request, context: { params: { slug: string } }) title: title || '', summary: summary || '', content: content || '', - image: image || '', + thumbnail: thumbnail || null, publish_date: publishDate || null, published: published || false, - project_id: 'dummy-id', + workspace_id: 'dummy-id', slug: 'dummy-slug', author_id: 'dummy-author', }, + notifySubscribers, 'route' ); @@ -62,11 +64,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(context.params.slug, 'route', true); + const { data: changelogs, error } = await getAllWorkspaceChangelogs(context.params.slug, 'route', true); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts new file mode 100644 index 0000000..de0a23a --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; + +/* + Get the subscribers count for a workspace changelog + GET /api/v1/workspaces/:slug/changelogs/subscribers/count +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return new Response(error.message, { status: error.status }); + } + + return NextResponse.json({ count: subscribers.length }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts similarity index 84% rename from apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts index 914c1f1..ab2621b 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ -import { getChangelogSubscribers } from '@/lib/api/changelogs'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; /* Download changelog subscribers - GET /api/v1/projects/:slug/changelogs/subscribers/download + GET /api/v1/workspaces/:slug/changelogs/subscribers/download */ export async function GET(req: Request, context: { params: { slug: string } }) { const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts index c8e2f0b..36bc132 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { deleteCommentForFeedbackById } from '@/lib/api/comments'; +import { deleteCommentForFeedbackById } from '@/lib/api/comment'; /* Delete comment for feedback by id - DELETE /api/v1/projects/[slug]/feedback/[id]/comments/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id]/comments/[id] */ export async function DELETE( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts similarity index 81% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts index 3c2506b..0c00268 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { upvoteCommentForFeedbackById } from '@/lib/api/comments'; +import { upvoteCommentForFeedbackById } from '@/lib/api/comment'; /* Upvote comment for feedback by id - POST /api/v1/projects/[slug]/feedback/[id]/comments/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/comments/[id]/upvote */ export async function POST( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts similarity index 85% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts index e9b38f9..5ac00b2 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts @@ -1,16 +1,17 @@ import { NextResponse } from 'next/server'; -import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comments'; -import { FeedbackCommentProps } from '@/lib/types'; +import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comment'; +import { CommentProps } from '@/lib/types'; /* Create feedback comment - POST /api/v1/projects/[slug]/feedback/[id]/comments + POST /api/v1/workspaces/[slug]/feedback/[id]/comments { content: string + reply_to_id?: string } */ export async function POST(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { content, reply_to_id: replyToId } = (await req.json()) as FeedbackCommentProps['Insert']; + const { content, reply_to_id: replyToId } = (await req.json()) as CommentProps['Insert']; if (!content) { return NextResponse.json({ error: 'Content cannot be empty' }, { status: 400 }); @@ -38,7 +39,7 @@ export async function POST(req: Request, context: { params: { slug: string; feed /* Get feedback comments - GET /api/v1/projects/[slug]/feedback/[id]/comments + GET /api/v1/workspaces/[slug]/feedback/[id]/comments */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { const { data: comments, error } = await getCommentsForFeedbackById( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts similarity index 69% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts index d75433d..04c859d 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { deleteFeedbackByID, getFeedbackByID, updateFeedbackByID } from '@/lib/api/feedback'; +import { deleteFeedbackById, getFeedbackById, updateFeedbackByID } from '@/lib/api/feedback'; import { FeedbackWithUserInputProps } from '@/lib/types'; /* - Get Project Feedback by ID - GET /api/v1/projects/[slug]/feedback/[id] + Get Workspace Feedback by ID + GET /api/v1/workspaces/[slug]/feedback/[id] */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -24,27 +24,35 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Update Feedback by ID - PATCH /api/v1/projects/[slug]/feedback/[id] + PATCH /api/v1/workspaces/[slug]/feedback/[id] { title: string; - description: string; + content: string; status: string; tags: string[]; + board_id: string; } */ export async function PATCH(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { title, description, status, tags } = (await req.json()) as FeedbackWithUserInputProps; + const { + title, + content, + status, + tags, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; const { data: feedback, error } = await updateFeedbackByID( context.params.feedbackId, context.params.slug, { title: title || '', - description: description || '', - status, - project_id: 'dummy-id', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', user_id: 'dummy-id', tags: tags || undefined, + workspace_id: 'dummy-id', }, 'route' ); @@ -60,10 +68,10 @@ export async function PATCH(req: Request, context: { params: { slug: string; fee /* Delete Feedback by ID - DELETE /api/v1/projects/[slug]/feedback/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await deleteFeedbackByID( + const { data: feedback, error } = await deleteFeedbackById( context.params.feedbackId, context.params.slug, 'route' diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts similarity index 73% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts index 146768d..d3244af 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts @@ -1,13 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getFeedbackByID, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; -import { getCurrentUser } from '@/lib/api/user'; +import { getFeedbackById, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; /* Get feedback upvotes - GET /api/v1/projects/[slug]/feedback/[id]/upvote + GET /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -42,21 +41,14 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Upvote a feedback - POST /api/v1/projects/[slug]/feedback/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function POST(req: NextRequest, context: { params: { slug: string; feedbackId: string } }) { - const hasUpvoted = req.nextUrl.searchParams.get('has_upvoted'); - - // Get current user - const { data: isLoggedIn } = await getCurrentUser('route'); - // Upvote feedback const { data: feedback, error } = await upvoteFeedbackByID( context.params.feedbackId, context.params.slug, - 'route', - hasUpvoted ? hasUpvoted === 'true' : undefined, - !isLoggedIn + 'route' ); // If any errors thrown, return error diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts similarity index 78% rename from apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts index 1eebff8..166bf20 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts @@ -1,12 +1,12 @@ -import { getAllProjectFeedback } from '@/lib/api/feedback'; +import { getAllWorkspaceFeedback } from '@/lib/api/feedback'; import { formatRootUrl } from '@/lib/utils'; /* - Export all feedback for a project - GET /api/v1/projects/:slug/feedback/export + Export all feedback for a workspace + GET /api/v1/workspaces/:slug/feedback/export */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route'); + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -19,7 +19,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return `${feedback.id},${formatRootUrl( context.params.slug, `/feedback/${feedback.id}` - )},"${feedback.title.replace(/"/g, '""')}","${feedback.description.replace(/"/g, '""')}",${ + )},"${feedback.title.replace(/"/g, '""')}","${feedback.content.replace(/"/g, '""')}",${ feedback.status },${feedback.upvotes},${feedback.comment_count},${feedback.user_id},"${feedback.user.full_name.replace( /"/g, diff --git a/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts new file mode 100644 index 0000000..9f8bd23 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createFeedback, getAllBoardFeedback, getAllWorkspaceFeedback } from '@/lib/api/feedback'; +import { FeedbackWithUserInputProps } from '@/lib/types'; + +export const runtime = 'edge'; + +/* + Create Feedback + POST /api/v1/workspaces/[slug]/feedback + { + title: string; + content: string; + status: string; + tags: [id, id, id], + board_id?: string; + } +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { + title, + content, + status, + tags, + user, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; + + // Validate Request Body + if (!title) { + return NextResponse.json({ error: 'title is required.' }, { status: 400 }); + } + + const { data: feedback, error } = await createFeedback( + boardId, + context.params.slug, + { + title: title || '', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', + workspace_id: context.params.slug, + user_id: 'dummy-id', + tags: tags || [], + user: user !== null ? user : undefined, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} + +/* + Get Workspace Feedback + GET /api/v1/workspaces/[slug]/feedback + Query Parameters: + - b (board_id): string +*/ +export async function GET(req: NextRequest, context: { params: { slug: string } }) { + // Get query parameters + const boardId = req.nextUrl.searchParams.get('b'); + + // If boardId is provided, get feedback by boardId + if (boardId) { + const { data: feedback, error } = await getAllBoardFeedback(boardId, context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); + } + + // Get all feedback + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts similarity index 91% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts index 0713c06..0d3608a 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts @@ -3,7 +3,7 @@ import { deleteFeedbackTagByName } from '@/lib/api/feedback'; /* Delete tag by name - DELETE /api/v1/projects/:slug/feedback/tags/:name + DELETE /api/v1/workspaces/:slug/feedback/tags/:name */ export async function DELETE(req: Request, context: { params: { slug: string; name: string } }) { const { data: tag, error } = await deleteFeedbackTagByName( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts similarity index 89% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts index e6bc865..41af1a8 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts @@ -4,7 +4,7 @@ import { FeedbackTagProps } from '@/lib/types'; /* Create new tag - POST /api/v1/projects/:slug/feedback/tags + POST /api/v1/workspaces/:slug/feedback/tags { name: string, color: string @@ -33,10 +33,10 @@ export async function POST(req: Request, context: { params: { slug: string } }) /* Get all feedback tags - GET /api/v1/projects/:slug/feedback/tags + GET /api/v1/workspaces/:slug/feedback/tags */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route'); + const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts similarity index 61% rename from apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts index 1e66f71..b40a54f 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { acceptProjectInvite, deleteProjectInvite } from '@/lib/api/invites'; +import { acceptWorkspaceInvite, deleteWorkspaceInvite } from '@/lib/api/invite'; /* - Accept project invite - POST /api/v1/projects/[slug]/invites/[inviteId] + Accept workspace invite + POST /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function POST(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await acceptProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await acceptWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { @@ -18,11 +18,11 @@ export async function POST(req: Request, context: { params: { slug: string; invi } /* - Delete project invite - DELETE /api/v1/projects/[slug]/invites/[inviteId] + Delete workspace invite + DELETE /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function DELETE(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await deleteProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await deleteWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/invites/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/route.ts index 74256dc..65def20 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createProjectInvite, getProjectInvites } from '@/lib/api/invites'; +import { createWorkspaceInvite, getWorkspaceInvites } from '@/lib/api/invite'; /* - Get all project invites - GET /api/v1/projects/[slug]/invites + Get all workspace invites + GET /api/v1/workspaces/[slug]/invites */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: invites, error } = await getProjectInvites(context.params.slug, 'route'); + const { data: invites, error } = await getWorkspaceInvites(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -18,8 +18,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Invite a new member to a project - POST /api/v1/projects/[slug]/members + Invite a new member to a workspace + POST /api/v1/workspaces/[slug]/members { email: string } @@ -27,14 +27,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { export async function POST(req: Request, context: { params: { slug: string } }) { const { email } = await req.json(); - // Check if email is valid - const emailRegex = new RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); - if (!emailRegex.test(email)) { - return NextResponse.json({ error: 'Invalid email' }, { status: 400 }); - } - // Create invite - const { data: invite, error } = await createProjectInvite(context.params.slug, 'route', email); + const { data: invite, error } = await createWorkspaceInvite(context.params.slug, 'route', email); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/members/route.ts b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts similarity index 60% rename from apps/web/app/api/v1/projects/[slug]/members/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/members/route.ts index cfa5b2c..e73da78 100644 --- a/apps/web/app/api/v1/projects/[slug]/members/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getProjectMembers } from '@/lib/api/projects'; +import { getWorkspaceMembers } from '@/lib/api/workspace'; /* - Get all members of a project - GET /api/v1/projects/[slug]/members + Get all members of a workspace + GET /api/v1/workspaces/[slug]/members */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(context.params.slug, 'route'); + const { data: members, error } = await getWorkspaceMembers(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/route.ts new file mode 100644 index 0000000..309dac2 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { deleteWorkspaceBySlug, getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; +import { WorkspaceProps } from '@/lib/types'; + +/* + Get workspace by slug + GET /api/v1/workspaces/[slug] +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route'); + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return workspace + return NextResponse.json(workspace, { status: 200 }); +} + +/* + Update workspace by slug + PATCH /api/v1/workspaces/[slug] + { + name: string, + slug: string, + icon: string, + icon_radius: string, + og_image: string + icon_redirect_url: string + custom_domain_redirect: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + } = (await req.json()) as WorkspaceProps['Update']; + + const { data: updatedWorkspace, error } = await updateWorkspaceBySlug( + context.params.slug, + { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace + return NextResponse.json(updatedWorkspace, { status: 200 }); +} + +/* + Delete workspace by slug + DELETE /api/v1/workspaces/[slug] +*/ +export async function DELETE(req: Request, context: { params: { slug: string } }) { + const { data, error } = await deleteWorkspaceBySlug(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return success + return NextResponse.json({ data }, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/route.ts b/apps/web/app/api/v1/workspaces/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/route.ts rename to apps/web/app/api/v1/workspaces/route.ts index f65280b..d10836d 100644 --- a/apps/web/app/api/v1/projects/route.ts +++ b/apps/web/app/api/v1/workspaces/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { createProject } from '@/lib/api/projects'; -import { getUserProjects } from '@/lib/api/user'; +import { getUserWorkspaces } from '@/lib/api/user'; +import { createWorkspace } from '@/lib/api/workspace'; /* - Create Project - POST /api/v1/projects + Create Workspace + POST /api/v1/workspaces { - "name": "Project Name", - "slug": "project-slug", + "name": "Workspace Name", + "slug": "workspace-slug", } */ export async function POST(req: Request) { @@ -19,30 +19,30 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'name and slug are required.' }, { status: 400 }); } - // Create Project - const { data: project, error } = await createProject({ name, slug }, 'route'); + // Create Workspace + const { data: workspace, error } = await createWorkspace({ name, slug }, 'route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - return NextResponse.json(project, { status: 201 }); + return NextResponse.json(workspace, { status: 201 }); } /* - Get User Projects - GET /api/v1/projects + Get User Workspaces + GET /api/v1/workspaces */ export async function GET(req: Request) { - // Get User Projects - const { data: projects, error } = await getUserProjects('route'); + // Get User Workspaces + const { data: workspaces, error } = await getUserWorkspaces('route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return projects - return NextResponse.json(projects, { status: 200 }); + // Return workspaces + return NextResponse.json(workspaces, { status: 200 }); } diff --git a/apps/web/app/dash/(auth)/login/page.tsx b/apps/web/app/dash/(auth)/login/page.tsx index 951c959..129dc9e 100644 --- a/apps/web/app/dash/(auth)/login/page.tsx +++ b/apps/web/app/dash/(auth)/login/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign in to Feedbase', @@ -31,7 +38,7 @@ export default async function SignIn() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/(auth)/signup/page.tsx b/apps/web/app/dash/(auth)/signup/page.tsx index 912ed81..ffa038d 100644 --- a/apps/web/app/dash/(auth)/signup/page.tsx +++ b/apps/web/app/dash/(auth)/signup/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign up to Feedbase', @@ -31,7 +38,7 @@ export default async function SignUp() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/[slug]/analytics/loading.tsx b/apps/web/app/dash/[slug]/analytics/loading.tsx deleted file mode 100644 index 50dd5dd..0000000 --- a/apps/web/app/dash/[slug]/analytics/loading.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - - - - - - - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/analytics/page.tsx b/apps/web/app/dash/[slug]/analytics/page.tsx index 631e1be..35cebde 100644 --- a/apps/web/app/dash/[slug]/analytics/page.tsx +++ b/apps/web/app/dash/[slug]/analytics/page.tsx @@ -1,21 +1,55 @@ import React from 'react'; -import { getProjectAnalytics } from '@/lib/api/projects'; -import AnalyticsCards from '@/components/dashboard/analytics/chart-cards'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@feedbase/ui/components/select'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; +import { BarChart } from '@/components/analytics/bar-chart'; +import { LineChart } from '@/components/analytics/line-chart'; export default async function AnalyticsPage({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectAnalytics(params.slug, 'server'); + const { data, error } = await getWorkspaceAnalytics(params.slug, 'server'); if (!data) { return Error: {error?.message}; } return ( - - + + {/* Header */} + + Analytics + + + {/* Timeframe */} + + + + + + + Last 3 months + + + Last 30 days + + + Last 7 days + + + + + + + {/* Chart */} + + + + + ); } diff --git a/apps/web/app/dash/[slug]/changelog/loading.tsx b/apps/web/app/dash/[slug]/changelog/loading.tsx deleted file mode 100644 index 7e7f0a9..0000000 --- a/apps/web/app/dash/[slug]/changelog/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ; -} diff --git a/apps/web/app/dash/[slug]/changelog/page.tsx b/apps/web/app/dash/[slug]/changelog/page.tsx index e56db5e..d715ba5 100644 --- a/apps/web/app/dash/[slug]/changelog/page.tsx +++ b/apps/web/app/dash/[slug]/changelog/page.tsx @@ -1,67 +1,36 @@ +import { Button } from '@feedbase/ui/components/button'; +import { Separator } from '@feedbase/ui/components/separator'; import { Plus } from 'lucide-react'; -import { Button } from 'ui/components/ui/button'; -import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { Separator } from 'ui/components/ui/separator'; -import { getAllProjectChangelogs } from '@/lib/api/changelogs'; -import { ApiSheet } from '@/components/dashboard/changelogs/api-sheet'; -import ChangelogList from '@/components/dashboard/changelogs/changelog-list'; -import { AddChangelogModal } from '@/components/dashboard/modals/add-edit-changelog-modal'; - -export default async function Changelog({ params }: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(params.slug, 'server'); - - if (error) { - return {error.message}; - } +import { ApiSheet } from '@/components/changelog/api-sheet'; +import ChangelogList from '@/components/changelog/changelog-list'; +import { AddChangelogModal } from '@/components/modals/add-edit-changelog-modal'; +export default function Changelog({ params }: { params: { slug: string } }) { return ( - - {/* Content */} - - {/* Header Row */} - {changelogs.length > 0 && ( - - {/* Api Docs Button */} - + + {/* Header */} + + Changelogs - {/* Seperator Line */} - + + {/* Api Docs Button */} + - {/* Create new Button */} - - - New Changelog - - } - /> - - )} + {/* Seperator Line */} + - {/* Changelog List */} - {/* If there is no changelog, show empty text in the center */} - {changelogs.length === 0 && ( - - - No changelogs yet - - Once you create a changelog, it will show up here. - - - - Create first changelog} - /> - - - )} - - {/* If there is changelog, show changelog list */} - {changelogs.length > 0 && } + {/* Create new Button */} + + + + New Changelog + + + + + {/* Changelogs */} + ); } diff --git a/apps/web/app/dash/[slug]/feedback/loading.tsx b/apps/web/app/dash/[slug]/feedback/loading.tsx deleted file mode 100644 index ea9545c..0000000 --- a/apps/web/app/dash/[slug]/feedback/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - <> - - - > - ); -} diff --git a/apps/web/app/dash/[slug]/feedback/page.tsx b/apps/web/app/dash/[slug]/feedback/page.tsx index 700c37d..ea01880 100644 --- a/apps/web/app/dash/[slug]/feedback/page.tsx +++ b/apps/web/app/dash/[slug]/feedback/page.tsx @@ -1,46 +1,14 @@ -import { Card, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getAllFeedbackTags, getAllProjectFeedback } from '@/lib/api/feedback'; -import FeedbackTable from '@/components/dashboard/feedback/feedback-table'; -import FeedbackHeader from '@/components/dashboard/feedback/header-buttons'; - -export default async function Feedback({ - params, - searchParams, -}: { - params: { slug: string }; - searchParams: { tag: string }; -}) { - const { data: feedback, error } = await getAllProjectFeedback(params.slug, 'server'); - if (error) { - return {error.message}; - } - - const { data: tags, error: tagsError } = await getAllFeedbackTags(params.slug, 'server'); - if (tagsError) { - return {tagsError.message}; - } +import FeedbackHeader from '@/components/feedback/dashboard/feedback-header'; +import FeedbackList from '@/components/feedback/dashboard/feedback-list'; +export default async function Feedback() { return ( - - {/* Header Row */} - - - {/* Feedback Posts */} - {/* If there is no feedback, show empty text in the center */} - {feedback.length === 0 && ( - - - No feedback yet - - Once somone submits feedback, it will show up here. Make sure to share it so others can vote on - it! - - - - )} + + {/* Header */} + - {/* If there is feedback, show feedback list */} - {feedback.length > 0 && } + {/* Feedback List */} + ); } diff --git a/apps/web/app/dash/[slug]/layout.tsx b/apps/web/app/dash/[slug]/layout.tsx index aa90d86..eebd0d6 100644 --- a/apps/web/app/dash/[slug]/layout.tsx +++ b/apps/web/app/dash/[slug]/layout.tsx @@ -1,11 +1,11 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { getCurrentUser, getUserProjects } from '@/lib/api/user'; +import { getCurrentUser, getUserWorkspaces } from '@/lib/api/user'; import { DASH_DOMAIN } from '@/lib/constants'; -import InboxPopover from '@/components/layout/inbox-popover'; +import { SidebarTabsProps } from '@/lib/types'; +import DashboardHeader from '@/components/layout/header'; import NavbarMobile from '@/components/layout/nav-bar-mobile'; import Sidebar from '@/components/layout/sidebar'; -import TitleProvider from '@/components/layout/title-provider'; import { AnalyticsIcon, CalendarIcon, @@ -13,41 +13,45 @@ import { SettingsIcon, TagLabelIcon, } from '@/components/shared/icons/icons-animated'; -import { Icons } from '@/components/shared/icons/icons-static'; -import UserDropdown from '@/components/shared/user-dropdown'; -const tabs = [ - { - name: 'Changelog', - icon: TagLabelIcon, - slug: 'changelog', - }, - { - name: 'Feedback', - icon: FeedbackIcon, - slug: 'feedback', - }, - { - name: 'Roadmap (Soon)', - icon: CalendarIcon, - slug: 'roadmap', - }, - { - name: 'Analytics', - icon: AnalyticsIcon, - slug: 'analytics', - }, - { - name: 'Settings', - icon: SettingsIcon, - slug: 'settings', - }, -]; +const tabs: SidebarTabsProps = { + Modules: [ + { + name: 'Changelog', + icon: TagLabelIcon, + slug: 'changelog', + }, + { + name: 'Feedback', + icon: FeedbackIcon, + slug: 'feedback', + }, + { + name: 'Roadmap', + icon: CalendarIcon, + slug: 'roadmap', + }, + ], + Insights: [ + { + name: 'Analytics', + icon: AnalyticsIcon, + slug: 'analytics', + }, + ], + Workspace: [ + { + name: 'Settings', + icon: SettingsIcon, + slug: 'settings/general', + }, + ], +}; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { // Headers const headerList = headers(); - const projectSlug = headerList.get('x-project'); + const workspaceSlug = headerList.get('x-workspace'); const pathname = headerList.get('x-pathname'); // Fetch user @@ -57,67 +61,48 @@ export default async function DashboardLayout({ children }: { children: React.Re return redirect(`${DASH_DOMAIN}/login`); } - // Fetch the user's projects - const { data: projects } = await getUserProjects('server'); + // Fetch the user's workspaces + const { data: workspaces } = await getUserWorkspaces('server'); - // Check if the user has any projects - if (!projects || projects.length === 0) { + // Check if the user has any workspaces + if (!workspaces || workspaces.length === 0) { return redirect(`${DASH_DOMAIN}`); } - // Get the project with the current slug - const currentProject = projects.find((project) => project.slug === projectSlug); + // Get the workspace with the current slug + const currentWorkspace = workspaces.find((workspace) => workspace.slug === workspaceSlug); - // If currentProject is undefined, redirect to the first project - if (!currentProject) { - return redirect(`/${projects[0].slug}`); + // If currentWorkspace is undefined, redirect to the first workspace + if (!currentWorkspace) { + return redirect(`/${workspaces[0].slug}`); } // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.includes(tab.slug)); + const activeTab = Object.values(tabs) + .flatMap((tabArray) => tabArray) + .find((tab) => pathname?.includes(tab.slug)); return ( - - - {/* Header with logo and hub button */} - {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} - {/* https://github.com/radix-ui/primitives/discussions/1100 */} - - {/* Logo */} - - - - - - - - - {/* Sidebar */} - + + {/* Header with logo and hub button */} + {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} + {/* https://github.com/radix-ui/primitives/discussions/1100 */} + - {/* Main content */} - - - {children} - - + {/* Sidebar */} + - {/* Navbar (mobile) */} - + {/* Main content */} + + {children} + + {/* Navbar (mobile) */} + ); } diff --git a/apps/web/app/dash/[slug]/page.tsx b/apps/web/app/dash/[slug]/page.tsx index 1e344c3..4e3668f 100644 --- a/apps/web/app/dash/[slug]/page.tsx +++ b/apps/web/app/dash/[slug]/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -export default async function ProjectPage({ params }: { params: { slug: string } }) { +export default async function WorkspacePage({ params }: { params: { slug: string } }) { // Redirect to changelog redirect(`/${params.slug}/changelog`); } diff --git a/apps/web/app/dash/[slug]/roadmap/page.tsx b/apps/web/app/dash/[slug]/roadmap/page.tsx new file mode 100644 index 0000000..dc1a177 --- /dev/null +++ b/apps/web/app/dash/[slug]/roadmap/page.tsx @@ -0,0 +1,14 @@ +import RoadmapBoard from '@/components/roadmap/dashboard/roadmap-board'; +import RoadmapHeader from '@/components/roadmap/dashboard/roadmap-header'; + +export default async function Roadmap() { + return ( + + {/* Header */} + + + {/* Board */} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/api/page.tsx b/apps/web/app/dash/[slug]/settings/api/page.tsx new file mode 100644 index 0000000..a5c81c1 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/api/page.tsx @@ -0,0 +1,9 @@ +import SettingsCard from '@/components/settings/settings-card'; + +export default function ApiSettings() { + return ( + + s + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/billing/page.tsx b/apps/web/app/dash/[slug]/settings/billing/page.tsx new file mode 100644 index 0000000..c798162 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/billing/page.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { Label } from '@feedbase/ui/components/label'; +import { LucideExternalLink } from 'lucide-react'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function BillingSettings() { + return ( + + + + + Current plan: Starter + + + View plans + + + + + Change plan + Get in touch + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/changelog/page.tsx b/apps/web/app/dash/[slug]/settings/changelog/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/changelog/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/domain/page.tsx b/apps/web/app/dash/[slug]/settings/domain/page.tsx new file mode 100644 index 0000000..3ea7363 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/domain/page.tsx @@ -0,0 +1,599 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feedbase/ui/components/tabs'; +import { cn } from '@feedbase/ui/lib/utils'; +import { fontMono } from '@feedbase/ui/styles/fonts'; +import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { ChevronUpDownIcon } from '@heroicons/react/24/solid'; +import { Check, CheckIcon, ClipboardList, RefreshCcw, Trash2Icon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, fetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +interface domainData { + name: string; + apexName: string; + verified: boolean; + verification: { + type: string; + domain: string; + value: string; + reason: string; + }[]; +} + +export default function DomainSettings({ params }: { params: { slug: string } }) { + const [domain, setDomain] = useState(''); + const { workspace, loading, isValidating, mutate, error } = useWorkspace(); + const [domainStatus, setDomainStatus] = useState<'unset' | 'verifying' | 'verified'>('unset'); + const [hasCopied, setHasCopied] = useState([]); + const [redirectRule, setRedirectRule] = useState<'direct_redirect' | 'root_redirect' | 'no_redirect'>(); + + const { + data: domainData, + isValidating: domainIsValidating, + mutate: domainMutate, + } = useSWR(`/api/v1/workspaces/${params.slug}/domain`, fetcher, { + errorRetryInterval: 30000, + refreshInterval: 5000, + }); + + const { trigger: submitDomainVerification, isMutating: isSubmittingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('verifying'); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: removeDomain, isMutating: isRemovingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('unset'); + setDomain(''); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + toast.success('Redirect rule updated.'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + useEffect(() => { + if (workspace?.custom_domain) { + // Set domain if it is not set + if (!domain) { + setDomain(workspace.custom_domain); + } + + // Set domain status + if (workspace.custom_domain_verified || domainData?.verified) { + setDomainStatus('verified'); + } else { + setDomainStatus('verifying'); + } + } else { + setDomainStatus('unset'); + } + + setRedirectRule(workspace?.custom_domain_redirect); + }, [workspace, domainData]); + + function handleCopyToClipboard(value: string) { + navigator.clipboard.writeText(value); + setHasCopied((prevHasCopied) => [...prevHasCopied, value]); + + setTimeout(() => { + setHasCopied((prevHasCopied) => prevHasCopied.filter((item) => item !== value)); + }, 3000); + } + + // Loading State + if (loading) { + return ( + + + + ); + } + + // Error State + if (error) { + return ( + + + + + + ); + } + + return ( + + + Domain + + { + setDomain(event.target.value); + }} + /> + {domainStatus === 'unset' ? ( + { + submitDomainVerification({ name: domain }); + }} + disabled={isSubmittingDomain || !domain}> + {isSubmittingDomain ? : null} + Connect + + ) : ( + { + removeDomain({ method: 'DELETE' }); + }}> + {isRemovingDomain ? ( + + ) : ( + + )} + + )} + + + Want to host on a sub-path? Check out our{' '} + + documentation + + . + + + + + Redirect Feedbase Subdomain + + + + {redirectRule === 'direct_redirect' && 'Redirect direct path'} + {redirectRule === 'root_redirect' && 'Redirect to root'} + {redirectRule === 'no_redirect' && "Don't Redirect"} + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'direct_redirect' }); + setRedirectRule('direct_redirect'); + }}> + + Redirect direct path + + Redirects to equivalent path on your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'root_redirect' }); + setRedirectRule('root_redirect'); + }}> + + Redirect to root + + Redirects to the root of your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'no_redirect' }); + setRedirectRule('no_redirect'); + }}> + + Don't Redirect + + Does not redirect any requests to your custom domain. + + + + + + + + + + Wether to redirect the default {process.env.NEXT_PUBLIC_ROOT_DOMAIN} subdomain to your custom + domain. + + + + + {domainStatus === 'verifying' ? ( + <> + + + Verification Required + + + {/* Initial Loading state */} + {!domainData && ( + + + Fetching domain data... + + )} + + {domainData ? ( + + {/* Tabs, if domain is not assigned to a vercel workspace yet */} + {domainData.verification === undefined ? ( + + + + + A Record (Recommended) + + + CNAME Record + + + + {/* Refresh Status */} + { + domainMutate(); + }} + disabled={domainIsValidating} + className='text-secondary-foreground hover:text-foreground'> + {domainIsValidating ? ( + + ) : ( + + )} + + + + + + To verify your domain, add the following A Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + A + + + + Name + { + handleCopyToClipboard('@'); + }} + type='button'> + + @ + + + {hasCopied.includes('@') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + 76.76.21.21 + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + + To verify your domain, add the following CNAME Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + CNAME + + + + Name + { + handleCopyToClipboard('www'); + }} + type='button'> + + www + + + {hasCopied.includes('www') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + cname.vercel-dns.com + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + ) : null} + + {/* Verification Instructions for already vercel assigned domains */} + {domainData.verification !== undefined ? ( + + + To prove ownership of{' '} + + {domainData.apexName} + + , please add the following TXT Record to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + TXT + + + + Name + { + handleCopyToClipboard(domainData.verification[0].domain); + }} + type='button'> + + {domainData.verification[0].domain} + + + {hasCopied.includes(domainData.verification[0].domain) ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard(domainData.verification[0].value); + }} + type='button'> + + {domainData.verification[0].value} + + + {hasCopied.includes(domainData.verification[0].value) ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Warning: If{' '} + + {domainData.apexName} + {' '} + is already in use, the TXT Record will transfer away from the existing domain ownership + and break the site. Consider using a subdomain instead. + + + ) : null} + + ) : null} + > + ) : null} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/feedback/page.tsx b/apps/web/app/dash/[slug]/settings/feedback/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/feedback/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/general/loading.tsx b/apps/web/app/dash/[slug]/settings/general/loading.tsx deleted file mode 100644 index 5a31508..0000000 --- a/apps/web/app/dash/[slug]/settings/general/loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function GeneralLoading() { - return ( - // 3 cards with loading state - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/general/page.tsx b/apps/web/app/dash/[slug]/settings/general/page.tsx index 32c6754..cfbee47 100644 --- a/apps/web/app/dash/[slug]/settings/general/page.tsx +++ b/apps/web/app/dash/[slug]/settings/general/page.tsx @@ -1,28 +1,459 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import GeneralConfigCards from '@/components/dashboard/settings/general-cards'; +'use client'; -export default async function GeneralSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); +import { useEffect, useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { cn } from '@feedbase/ui/lib/utils'; +import { Check, ChevronsUpDownIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import useWorkspaceTheme from '@/lib/swr/use-workspace-theme'; +import { WorkspaceProps, WorkspaceThemeProps } from '@/lib/types'; +import { actionFetcher, areObjectsEqual } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import FileDrop from '@/components/shared/file-drop'; +import InputGroup from '@/components/shared/input-group'; - if (error) { - return {error.message}; - } +export default function GeneralSettings({ params }: { params: { slug: string } }) { + const { + workspace: workspaceData, + loading: workspaceLoading, + error: workspaceError, + mutate: workspaceMutate, + } = useWorkspace(); + const { + workspaceTheme: workspaceThemeData, + loading: workspaceThemeLoading, + error: workspaceThemeError, + mutate: workspaceThemeMutate, + } = useWorkspaceTheme(); + + const [workspace, setWorkspace] = useState(); + const [workspaceTheme, setWorkspaceTheme] = useState(); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + // Check if slug has changed + if (workspace?.slug !== workspaceData?.slug) { + // Redirect to new slug + window.location.href = `/${workspace?.slug}/settings/general`; + } + + workspaceMutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); - // Fetch project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - params.slug, - 'server' + const { trigger: updateWorkspaceTheme } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/theme`, + actionFetcher, + { + onError: (error) => { + toast.error(error.message); + }, + } ); - if (projectConfigError) { - return {projectConfigError.message}; + useEffect(() => { + setWorkspace(workspaceData); + setWorkspaceTheme(workspaceThemeData); + }, [workspaceData, workspaceThemeData]); + + if (workspaceError || workspaceThemeError) { + return ( + + ); } - return ( - - {/* General Card */} - - - ); + if (workspaceLoading || workspaceThemeLoading) { + return ( + <> + + + + + + + + + + + + > + ); + } + + if (workspace && workspaceTheme) { + return ( + <> + { + await updateWorkspace({ method: 'PATCH', ...workspace }); + }} + onCancel={() => { + setWorkspace(workspaceData); + }}> + + Name + { + setWorkspace({ + ...workspace, + name: e.target.value, + }); + }} + /> + This is the name of your workspace. + + + + Slug + + { + setWorkspace({ + ...workspace, + slug: e.target.value, + }); + }} + suffix={`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`} + placeholder='feedbase' + /> + + This is the subdomain of your workspace. + + + {/* Workspace Logo */} + + Logo + + {/* File Upload */} + + + + + {workspace.name[0]} + + + + + + Upload + + { + // Set the icon to the uploaded file + if (event.target.files?.[0]) { + // Check if the file is an image, image/png, image/jpeg, etc. + if (!['image/png', 'image/jpeg', 'image/jpg'].includes(event.target.files[0].type)) { + toast.error('Please upload a valid image.'); + return; + } + + // Read the file as a data URL + const reader = new FileReader(); + reader.onload = (e) => { + setWorkspace({ + ...workspace, + icon: e.target?.result as string, + }); + }; + reader.readAsDataURL(event.target.files[0]); + } + }} + /> + {workspace.icon ? ( + setWorkspace({ ...workspace, icon: '' })}> + Remove + + ) : null} + + Recommended size is 256x256. + + + + + + Radius + + + + {workspace.icon_radius === 'rounded-md' + ? 'Rounded' + : workspace.icon_radius === 'rounded-full' + ? 'Circle' + : 'Square'} + + + + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-md', + }); + }}> + Rounded + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-full', + }); + }}> + Circle + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-none', + }); + }}> + Square + + + + + This is the radius of your workspace. + + + + Redirect URL + { + setWorkspace({ + ...workspace, + icon_redirect_url: e.target.value, + }); + }} + placeholder='Leave blank to use default' + /> + + This is the redirect URL of your workspace. + + + + {/* OG Image */} + + OG Image} + image={workspace.opengraph_image} + setImage={(e: string | null) => { + setWorkspace({ + ...workspace, + opengraph_image: e, + }); + }} + className='h-40 w-80' + /> + + + The OG Image used when sharing your workspace. + + + + + + {/* Thanks to https://github.com/openstatushq for the inspiration of the theme previews */} + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'light' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'light' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + + Light + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'dark' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'dark' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + Dark + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'custom' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'custom' }); + }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + System + + + + + + + Delete this Workspace + + + + > + ); + } } diff --git a/apps/web/app/dash/[slug]/settings/hub/loading.tsx b/apps/web/app/dash/[slug]/settings/hub/loading.tsx index 8053295..f8a838e 100644 --- a/apps/web/app/dash/[slug]/settings/hub/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function HubLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/hub/page.tsx b/apps/web/app/dash/[slug]/settings/hub/page.tsx index 09506ef..0109277 100644 --- a/apps/web/app/dash/[slug]/settings/hub/page.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/page.tsx @@ -1,16 +1,18 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import HubConfigCards from '@/components/dashboard/settings/hub-cards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; + +// import HubConfigCards from '@/components/dashboard/settings/hub-cards'; export default async function HubSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); + // Fetch workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.slug, 'server'); if (error) { return {error.message}; } - // Fetch project config - const { data: projectConfig, error: configError } = await getProjectConfigBySlug(params.slug, 'server'); + // Fetch workspace config + const { data: workspaceConfig, error: configError } = await getWorkspaceModuleConfig(params.slug, 'server'); if (configError) { return {configError.message}; @@ -19,7 +21,7 @@ export default async function HubSettings({ params }: { params: { slug: string } return ( {/* Hub Card */} - + {/* */} ); } diff --git a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx index f1cbc8e..b50f052 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function IntegrationsLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/integrations/page.tsx b/apps/web/app/dash/[slug]/settings/integrations/page.tsx index 1bd1711..2b94c5f 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/page.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/page.tsx @@ -1,12 +1,81 @@ -import { getProjectConfigBySlug } from '@/lib/api/projects'; -import IntegrationCards from '@/components/dashboard/settings/integration-cards'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import FeedbackModal from '@/components/modals/send-feedback-modal'; +import SettingsCard from '@/components/settings/settings-card'; -export default async function IntegrationsSettings({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectConfigBySlug(params.slug, 'server'); +function IntegrationCard({ + title, + description, + image, + alt, + onClick, +}: { + title: string; + description: string; + image: string; + alt: string; + onClick?: () => void; +}) { + return ( + + {/* Avatar */} + + + {alt} + - if (error) { - return Error; - } + {/* Name and Description */} + + {title} - return ; + {description} + + + ); +} + +export default function IntegrationSettings({ params }: { params: { slug: string } }) { + return ( + + + + + + + + + + + More integrations coming soon! If you have a specific integration you would like to see,{' '} + + + let us know + + + ! + + + ); } diff --git a/apps/web/app/dash/[slug]/settings/layout.tsx b/apps/web/app/dash/[slug]/settings/layout.tsx index 85b549f..7b7f8f9 100644 --- a/apps/web/app/dash/[slug]/settings/layout.tsx +++ b/apps/web/app/dash/[slug]/settings/layout.tsx @@ -1,24 +1,84 @@ -import { headers } from 'next/headers'; -import CategoryTabs from '@/components/dashboard/settings/category-tabs'; +import { + Blocks, + Earth, + GalleryVerticalEnd, + KanbanSquare, + RefreshCcw, + SquareAsterisk, + SwatchBook, + Terminal, + UsersRoundIcon, + Wallet2, + Webhook, +} from 'lucide-react'; +import { SidebarTabsProps } from '@/lib/types'; +import Sidebar from '@/components/layout/sidebar'; -const tabs = [ - { - name: 'General', - slug: 'general', - }, - { - name: 'Hub', - slug: 'hub', - }, - { - name: 'Team', - slug: 'team', - }, - { - name: 'Integrations', - slug: 'integrations', - }, -]; +const tabs: SidebarTabsProps = { + Workspace: [ + { + name: 'Branding', + customIcon: , + slug: 'settings/general', + }, + { + name: 'Team Members', + customIcon: , + slug: 'settings/team', + }, + { + name: 'Billing', + customIcon: , + slug: 'settings/billing', + }, + ], + Public: [ + { + name: 'Domain', + customIcon: , + slug: 'settings/domain', + }, + { + name: 'SSO', + customIcon: , + slug: 'settings/sso', + }, + ], + Modules: [ + { + name: 'Feedback', + customIcon: , + slug: 'settings/feedback', + }, + { + name: 'Roadmap', + customIcon: , + slug: 'settings/roadmap', + }, + { + name: 'Changelog', + customIcon: , + slug: 'settings/changelog', + }, + ], + Additional: [ + { + name: 'API', + customIcon: , + slug: 'settings/api', + }, + { + name: 'Webhooks', + customIcon: , + slug: 'settings/webhooks', + }, + { + name: 'Integrations', + customIcon: , + slug: 'settings/integrations', + }, + ], +}; export default function SettingsLayout({ children, @@ -27,20 +87,21 @@ export default function SettingsLayout({ children: React.ReactNode; params: { slug: string }; }) { - // Headers - const headerList = headers(); - const pathname = headerList.get('x-pathname'); - - // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.split('/')[3] === tab.slug); - return ( - {/* Navigation tabs */} - + {/* Sidebar */} + {/* Content */} - {children} + + + {children} + + ); } diff --git a/apps/web/app/dash/[slug]/settings/roadmap/page.tsx b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx new file mode 100644 index 0000000..a37a7c4 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx @@ -0,0 +1,17 @@ +import { Label } from '@feedbase/ui/components/label'; +import { Switch } from '@feedbase/ui/components/switch'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Disable Roadmap + + + This will disable the roadmap for your workspace. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/sso/page.tsx b/apps/web/app/dash/[slug]/settings/sso/page.tsx new file mode 100644 index 0000000..ef4b2c9 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/sso/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@feedbase/ui/components/alert-dialog'; +import { Button } from '@feedbase/ui/components/button'; +import { Checkbox } from '@feedbase/ui/components/checkbox'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from '@feedbase/ui/components/responsive-dialog'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import CopyCheckIcon from '@/components/shared/copy-check-icon'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +export default function SSOSettings({ params }: { params: { slug: string } }) { + const [ssoConfig, setSsoConfig] = useState<{ + enabled: boolean; + url: string | null | undefined; + secret: string | null | undefined; + }>({ + enabled: false, + url: null, + secret: '', + }); + const [hasCopied, setHasCopied] = useState(); + const [open, setOpen] = useState(false); + const { workspace, loading, error, mutate } = useWorkspace(); + + const { trigger: updateWorkspaceSSO, isMutating: isUpdatingWorkspaceSSO } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso`, + actionFetcher, + { + onSuccess: () => { + mutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: generateJwtSecret, isMutating: isGenerating } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso/secret`, + actionFetcher, + { + onSuccess: (data: { secret: string }) => { + setSsoConfig({ ...ssoConfig, secret: data.secret }); + setOpen(true); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + useEffect(() => { + if (workspace) { + setSsoConfig({ + enabled: workspace.sso_auth_enabled, + url: workspace.sso_auth_url, + secret: workspace.sso_auth_secret_id ? 'dummy-secret' : undefined, + }); + } + }, [workspace]); + + // Loading state + if (loading && !error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + ); + } + + // Error state + if (error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + + + ); + } + + return ( + { + setSsoConfig({ ...ssoConfig, enabled: checked }); + toast.promise(updateWorkspaceSSO({ method: 'PATCH', enabled: checked }), { + loading: ssoConfig.enabled ? `Disabling Single Sign-On` : `Enabling Single Sign-On`, + success: ssoConfig.enabled ? `Single Sign-On disabled` : `Single Sign-On enabled`, + error: () => { + setSsoConfig({ ...ssoConfig, enabled: !checked }); + return `Failed to ${ssoConfig.enabled ? 'disable' : 'enable'} Single Sign-On`; + }, + }); + }} + description={ + <> + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + } + showSave={workspace?.sso_auth_url !== ssoConfig.url} + onCancel={() => { + setSsoConfig({ ...ssoConfig, url: workspace?.sso_auth_url }); + }} + onSave={async () => { + await updateWorkspaceSSO({ + method: 'PATCH', + enabled: ssoConfig.enabled, + url: ssoConfig.url, + secret: ssoConfig.secret, + }); + }}> + + Login Url + { + setSsoConfig({ ...ssoConfig, url: e.target.value }); + }} + /> + + The url where users will be redirected to login. + + + + + JWT Secret + + + + + + { + setHasCopied(false); + if (!ssoConfig.secret) { + e.preventDefault(); + await generateJwtSecret({}); + setOpen(true); + } + }} + disabled={isGenerating || isUpdatingWorkspaceSSO}> + {isGenerating ? : null} + {ssoConfig.secret ? 'Regenerate' : 'Generate'} + + + + + Are you absolutely sure? + + This action cannot be undone. This will generate a new secret for your Single Sign-On + configuration and invalidate the previous one. + + + + Cancel + { + await generateJwtSecret({}); + }}> + Continue + + + + + + + + Token Created + + This token can not be shown again. Please copy it and store it in a safe place. + + + + + Token + { + setHasCopied(true); + }} + /> + } + /> + + + + + { + setHasCopied(!hasCopied); + }} + id='copied' + /> + + I have copied this token + + + + { + toast.success('Token copied to clipboard'); + }}> + Done + + + + + + + + The secret used to sign the JWT token on your server. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/team/loading.tsx b/apps/web/app/dash/[slug]/settings/team/loading.tsx deleted file mode 100644 index 1753c58..0000000 --- a/apps/web/app/dash/[slug]/settings/team/loading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function TeamLoading() { - return ( - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/team/page.tsx b/apps/web/app/dash/[slug]/settings/team/page.tsx index 1343f64..eb627f2 100644 --- a/apps/web/app/dash/[slug]/settings/team/page.tsx +++ b/apps/web/app/dash/[slug]/settings/team/page.tsx @@ -1,32 +1,396 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getProjectInvites } from '@/lib/api/invites'; -import { getProjectMembers } from '@/lib/api/projects'; -import { TeamTable } from '@/components/dashboard/settings/team-table'; +'use client'; -export default async function TeamSettings({ params }: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(params.slug, 'server'); +import { useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuDestructiveItem, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { ChevronsUpDown, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useTeamInvites from '@/lib/swr/use-team-invites'; +import useTeamMembers from '@/lib/swr/use-team-members'; +import { ExtendedInviteProps, TeamMemberProps } from '@/lib/types'; +import { actionFetcher, formatTimeAgo } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; - if (error) { - return {error.message}; +export default function TeamSettings({ params }: { params: { slug: string } }) { + const { + teamMembers, + error: memberError, + mutate: mutateMember, + isValidating: isValidatingMember, + } = useTeamMembers(); + const { + teamInvites, + error: inviteError, + mutate: mutateInvite, + isValidating: inviteIsValidating, + } = useTeamInvites(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'all' | 'admins' | 'members' | 'invites'>('all'); + const [inviteEmail, setInviteEmail] = useState(''); + + // Filtered + const filteredMembers = teamMembers?.filter( + (member) => + member.full_name.toLowerCase().includes(search.toLowerCase()) || + member.email.toLowerCase().includes(search.toLowerCase()) + ); + const filteredInvites = teamInvites?.filter((invite) => + invite.email.toLowerCase().includes(search.toLowerCase()) + ); + + // Send invite + const { trigger: sendInvite, isMutating: isSendingInvite } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/invites`, + actionFetcher, + { + onSuccess: () => { + toast.success('Invite sent successfully.'); + // mutateMember(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + // Revoking invite + function revokeInvite(inviteId: string) { + const req = fetch(`/api/v1/workspaces/${params.slug}/invites/${inviteId}`, { + method: 'DELETE', + }); + + req.then(() => { + mutateInvite(); + }); + } + + // Error state + if (memberError || inviteError) { + return memberError ? ( + + ) : ( + + ); } - const { data: invites, error: invitesError } = await getProjectInvites(params.slug, 'server'); + // Loading state + if (!filteredMembers && isValidatingMember && !filteredInvites && inviteIsValidating) { + return ( + <> + + + Email + + + + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + + {filter === 'all' && 'members'} + {filter === 'admins' && 'admins'} + {filter === 'members' && 'members'} + {filter === 'invites' && 'pending invites'} + + - if (invitesError) { - return {invitesError.message}; + + + Loading team members... + + + > + ); } return ( - - - - Team - Add or remove users that have access to this project. - - - - - - + <> + + + Email + { + e.preventDefault(); + sendInvite({ email: inviteEmail }); + }}> + { + setInviteEmail(e.target.value); + }} + /> + + {isSendingInvite ? : null} + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + {/* Action Row */} + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + {/* Members */} + + + {filter === 'all' && (filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0)} + {/* TODO: Implement different roles, currently all are handled the same. */} + {filter === 'admins' && (filteredMembers?.length ?? 0)} + {filter === 'members' && (filteredMembers?.length ?? 0)} + {filter === 'invites' && (filteredInvites?.length ?? 0)}{' '} + {filter === 'all' && + ((filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'admins' && ((filteredMembers?.length ?? 0) > 1 ? 'admins' : 'admin')} + {filter === 'members' && ((filteredMembers?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'invites' && + ((filteredInvites?.length ?? 0) > 1 ? 'pending invites' : 'pending invite')} + + + + {/* Team Members */} + {(filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.map((member: TeamMemberProps) => ( + + + + + + {member.full_name[0]} + + + + {member.full_name} + {member.email} + + + + + {/* Joined days ago */} + + Joined {formatTimeAgo(new Date(member.joined_at))} ago + + + + + + + + + Make Admin + Suspend + + + + + ))} + + {/* Pending Invites */} + {(filter === 'all' || filter === 'invites') && + filteredInvites?.map((invite: ExtendedInviteProps) => ( + + + + + {invite.email[0]} + + + + {invite.email} + + Invited by {invite.creator.full_name} + + + + + + + Invited {formatTimeAgo(new Date(invite.created_at))} ago + + + + + + + + + + Resend + { + revokeInvite(invite.id); + }}> + Revoke + + + + + + ))} + + {/* Empty States */} + {((filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.length === 0 && + filteredInvites?.length === 0) || + (filter === 'invites' && filteredInvites?.length === 0) ? ( + + + {filter === 'all' || filter === 'admins' || filter === 'members' + ? 'No members found.' + : 'No pending invites found.'} + + + ) : null} + + + + + > ); } diff --git a/apps/web/app/dash/invite/[inviteId]/page.tsx b/apps/web/app/dash/invite/[inviteId]/page.tsx index 6b817f9..c8fa036 100644 --- a/apps/web/app/dash/invite/[inviteId]/page.tsx +++ b/apps/web/app/dash/invite/[inviteId]/page.tsx @@ -1,10 +1,10 @@ import { notFound } from 'next/navigation'; -import { getProjectInvite } from '@/lib/api/invites'; +import { getWorkspaceInvite } from '@/lib/api/invite'; import { getCurrentUser } from '@/lib/api/user'; -import ProjectInviteForm from '@/components/layout/accept-invite-form'; +import WorkspaceInviteForm from '@/components/workspace/accept-invite-form'; -export default async function ProjectInvite({ params }: { params: { inviteId: string } }) { - const { data: invite, error: inviteError } = await getProjectInvite(params.inviteId, 'server'); +export default async function WorkspaceInvite({ params }: { params: { inviteId: string } }) { + const { data: invite, error: inviteError } = await getWorkspaceInvite(params.inviteId, 'server'); if (inviteError) { return notFound(); @@ -15,7 +15,7 @@ export default async function ProjectInvite({ params }: { params: { inviteId: st return ( - + ); } diff --git a/apps/web/app/dash/page.tsx b/apps/web/app/dash/page.tsx index b9444a0..12760b8 100644 --- a/apps/web/app/dash/page.tsx +++ b/apps/web/app/dash/page.tsx @@ -1,9 +1,10 @@ import { redirect } from 'next/navigation'; -import { getUserProjects } from '@/lib/api/user'; -import Onboarding from '@/components/layout/onboarding'; +import { getUserWorkspaces } from '@/lib/api/user'; +import Onboarding from '@/components/workspace/onboarding'; +import WorkspaceOverview from '@/components/workspace/workspace-overview'; -export default async function Projects() { - const { data: projects, error } = await getUserProjects('server'); +export default async function Workspaces() { + const { data: workspaces, error } = await getUserWorkspaces('server'); if (error) { // Redirect to login if the user is not authenticated @@ -14,15 +15,10 @@ export default async function Projects() { return {error.message}; } - // Redirect to the first project - if (projects.length > 0) { - return redirect(`/${projects[0].slug}`); - } - - // TODO: Improve this and make this redirect to an onboarding page if the user has no projects + // TODO: Improve this and make this redirect to an onboarding page if the user has no workspaces return ( - + {workspaces && workspaces.length > 0 ? : } ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index cfce9bc..13871c1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,77 +1,127 @@ @tailwind base; @tailwind components; @tailwind utilities; - + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* + https://github.com/tailwindlabs/tailwindcss/discussions/2394 + https://github.com/tailwindlabs/tailwindcss/pull/5732 +*/ +@layer utilities { + /* Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .custom-scrollbar::-webkit-scrollbar { + width: 14px; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); + background-clip: padding-box; + border-radius: 50px; + background-color: hsl(var(--border) / 1); + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--foreground) / 0.15); + } +} + @layer base { :root { /* App root background */ - --root-background: 230 35% 3%; /* #000000 */ + --root-background: 0 0% 100%; + --background: 210 20% 98%; + --foreground: 224 71% 4%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 224 71% 4%; + --primary-foreground: 0 0% 98%; + --secondary: 240 5% 95%; + --secondary-foreground: 215 14% 34%; + --muted: 240 5% 96%; + --muted-foreground: 218 11% 65%; + --accent: 240 5% 94%; + --accent-foreground: 224 71% 4%; + --destructive: 0 79% 63%; + --destructive-foreground: 0 0% 100%; + --border: 220 13% 91%; + --input: 220 13% 91%; + --highlight: 231 100% 78%; + --ring: 216 12% 84%; + + /* Charts */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + --radius: 0.55rem; + } + + .dark { + /* App root background */ + --root-background: 235 7% 5%; /* Utility background (Buttons, inputs, etc) */ - --background: 230 11% 12%; /* #0A0C10 | #060606*/ - --foreground: 210 40% 98%; /* #D6D6D6 */ - + --background: 230 7% 16%; + --foreground: 220 9% 94%; + /* Muted background (Skeletons, Avatar, tabs, etc) */ - --muted: 230 11% 8%; /* #20242c */ - --muted-foreground: 215 6% 62%; - - --popover: 230 35% 3%; /* #0A0C10 */ - --popover-foreground: 210 40% 98%; - - --card: 230 35% 3%; /* #0A0C10 */ - --card-foreground: 210 40% 98%; - - --border: 222 9% 22%; /* #20242c */ - --input: 222 9% 22%; /* #20242c */ - - --primary: 210 40% 98%; - --primary-foreground: 230 35% 3%; - + --muted: 219 6% 12%; + --muted-foreground: 219 6% 65%; + + --popover: 235 7% 5%; + --popover-foreground: 220 9% 94%; + + --card: 235 7% 5%; + --card-foreground: 220 9% 94%; + + --border: 223 7% 19%; + --input: 223 6% 22%; + + --primary: 220 9% 94%; + --primary-foreground: 240 4% 10%; + /* Hover & active backgrounds (Nav buttons, etc) */ - --secondary: 227 11% 12%; /* #20242c */ - --secondary-foreground: 210 40% 98%; - + --secondary: 230 7% 16%; + --secondary-foreground: 218 7% 80%; + /* Button background hovers */ - --accent: 227 11% 15%; /* #20242c */ - --accent-foreground: 210 40% 98%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - + --accent: 230 7% 18%; + --accent-foreground: 220 9% 94%; + + --destructive: 0 79% 63%; + --destructive-foreground: 220 9% 94%; + /* Selection & accent color */ --highlight: 231 100% 78%; - - --ring: 230 9% 13%; /* #20242c */ - --radius: 0.5rem; - } - - .light { - /* App root background */ - --root-background: 0 0% 100%; /* #000000 */ - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --highlight: 231 100% 78%; - --ring: 240 5.9% 10%; + /* Charts */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + --ring: 230 9% 13%; } } - + @layer base { * { @apply border-border; @@ -79,4 +129,4 @@ body { @apply bg-root text-foreground selection:bg-highlight/20 selection:text-highlight; } -} \ No newline at end of file +} diff --git a/apps/web/app/home/(pages)/deploy/page.tsx b/apps/web/app/home/(pages)/deploy/page.tsx index b5f2ada..c98b5ee 100644 --- a/apps/web/app/home/(pages)/deploy/page.tsx +++ b/apps/web/app/home/(pages)/deploy/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Button } from '@feedbase/ui/components/button'; import HomeFooter from '@/components/home/footer'; export default function Deploy() { @@ -9,7 +9,7 @@ export default function Deploy() { Deploy Feedbase to Vercel - + You can deploy your own hosted instance of Feedbase to Vercel, for free, in just a few clicks. @@ -27,7 +27,7 @@ export default function Deploy() { Create a Supabase project - + Supabase is an open source Firebase alternative. It provides a database, auth, and storage. @@ -51,7 +51,7 @@ export default function Deploy() { Fork the Supabase database - + Once you have your Supabase project, you can fork the Feedbase database schemas to your own project. @@ -79,7 +79,7 @@ export default function Deploy() { Prepare your environment variables - + After setting up your Supabase project, check the example configuration to see where you can find the necessary environment variables. @@ -107,7 +107,7 @@ export default function Deploy() { Deploy to Vercel - + Once you have your environment variables, you can deploy to Vercel. diff --git a/apps/web/app/home/(pages)/page.tsx b/apps/web/app/home/(pages)/page.tsx index a58c6ad..15e2c4d 100644 --- a/apps/web/app/home/(pages)/page.tsx +++ b/apps/web/app/home/(pages)/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Background } from '@feedbase/ui/components/background/background'; +import { Button } from '@feedbase/ui/components/button'; import { ArrowRight, ChevronRight } from 'lucide-react'; -import { Background } from 'ui/components/ui/background/background'; import { formatRootUrl } from '@/lib/utils'; import ChangelogSection from '@/components/home/changelog-section'; import DashboardSection from '@/components/home/dashboard-section'; @@ -27,7 +27,7 @@ export default function Landing() { in One Central Place - + Feedbase simplifies feedback collection, feature prioritization, and product update sharing, allowing you to focus on building. @@ -39,7 +39,7 @@ export default function Landing() { Star on GitHub @@ -48,7 +48,7 @@ export default function Landing() { + className='text-foreground/60 hover:text-foreground/90 group relative mt-4 flex w-fit flex-row items-center justify-center p-1 text-center text-sm '> See it in action @@ -73,7 +73,7 @@ export default function Landing() { Create your feedback community today. - + Capture feedback, post updates, and engage with your users in one central place. diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8932134..274aa79 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,11 +1,12 @@ import './globals.css'; import { Metadata, Viewport } from 'next'; import Script from 'next/script'; -import { cn } from '@ui/lib/utils'; +import { cn } from '@feedbase/ui/lib/utils'; import { SpeedInsights } from '@vercel/speed-insights/next'; -import { GeistSans } from 'geist/font'; +import { GeistSans } from 'geist/font/sans'; import { Toaster } from 'sonner'; import { formatRootUrl } from '@/lib/utils'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; export const metadata: Metadata = { title: 'Feedbase', @@ -49,9 +50,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) )} - - {children} - + + + {children} + +
{changelog.author.full_name}
{new Date(changelog.publish_date!).toLocaleDateString('en-US', { year: 'numeric', @@ -146,7 +156,7 @@ export default async function ChangelogPage({ params }: Props) { className='text-foreground/70 hover:text-foreground/95 transition-all duration-200 hover:scale-110' href={`https://twitter.com/intent/tweet?text=Make sure to check out ${changelog.title} by ${ changelog.author.full_name - }!&url=${formatRootUrl(params.project, `/changelog/${changelog.slug}`)}`} + }!&url=${formatRootUrl(params.workspace, `/changelog/${changelog.slug}`)}`} target='_blank' rel='noopener noreferrer'> @@ -158,7 +168,7 @@ export default async function ChangelogPage({ params }: Props) { LUM-32 dangerouslySetInnerHTML={{ __html: changelog.content! }} /> @@ -178,7 +188,7 @@ export default async function ChangelogPage({ params }: Props) { + className='text-foreground/60 hover:text-foreground w-full text-sm transition-colors'> ← {changelogs[changelogIndex - 1]?.title} @@ -189,7 +199,7 @@ export default async function ChangelogPage({ params }: Props) { + className='text-foreground/60 hover:text-foreground w-full text-sm transition-colors'> {changelogs[changelogIndex + 1]?.title} → diff --git a/apps/web/app/[project]/changelog/loading.tsx b/apps/web/app/[workspace]/changelog/loading.tsx similarity index 92% rename from apps/web/app/[project]/changelog/loading.tsx rename to apps/web/app/[workspace]/changelog/loading.tsx index e60375d..f71b560 100644 --- a/apps/web/app/[project]/changelog/loading.tsx +++ b/apps/web/app/[workspace]/changelog/loading.tsx @@ -1,5 +1,5 @@ -import { Separator } from '@ui/components/ui/separator'; -import { Skeleton } from '@ui/components/ui/skeleton'; +import { Separator } from '@feedbase/ui/components/separator'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function ChangelogLoading() { return ( @@ -9,7 +9,7 @@ export default function ChangelogLoading() { - + diff --git a/apps/web/app/[project]/changelog/page.tsx b/apps/web/app/[workspace]/changelog/page.tsx similarity index 52% rename from apps/web/app/[project]/changelog/page.tsx rename to apps/web/app/[workspace]/changelog/page.tsx index 0e56224..cbfdb5e 100644 --- a/apps/web/app/[project]/changelog/page.tsx +++ b/apps/web/app/[workspace]/changelog/page.tsx @@ -2,36 +2,42 @@ import { Metadata } from 'next'; import Image from 'next/image'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { fontMono } from '@ui/styles/fonts'; -import { Separator } from 'ui/components/ui/separator'; -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; -import AnalyticsWrapper from '@/components/hub/analytics-wrapper'; -import SubscribeToEmailUpdates from '@/components/hub/modals/subscribe-email-modal'; +import { Separator } from '@feedbase/ui/components/separator'; +import { fontMono } from '@feedbase/ui/styles/fonts'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import SubscribeToEmailUpdates from '@/components/modals/subscribe-email-modal'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; type Props = { - params: { project: string }; + params: { workspace: string }; }; // Metadata export async function generateMetadata({ params }: Props): Promise { - // Get project - const { data: project, error } = await getProjectBySlug(params.project, 'server', true, false); + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); - // If project is undefined redirects to 404 - if (error?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { notFound(); } return { - title: `Changelog - ${project.name}`, - description: `All the latest updates, improvements, and fixes to ${project.name}.`, + title: `Changelog - ${workspace.name}`, + description: `All the latest updates, improvements, and fixes to ${workspace.name}.`, }; } export default async function Changelogs({ params }: Props) { // Get changelogs - const { data: changelogs, error } = await getPublicProjectChangelogs(params.project, 'server', true, false); + const { data: changelogs, error } = await getPublicWorkspaceChangelogs( + params.workspace, + 'server', + true, + false + ); // If error.status redirects to 404 if (error?.status === 404 || !changelogs) { @@ -40,82 +46,82 @@ export default async function Changelogs({ params }: Props) { // Sort changelogs by publish_date (newest first) changelogs.sort((a, b) => { - return new Date(b.publish_date!).getTime() - new Date(a.publish_date!).getTime(); + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); }); - // Get project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - params.project, + // Get workspace config + const { data: workspaceConfig, error: workspaceConfigError } = await getWorkspaceModuleConfig( + params.workspace, 'server', true, false ); // If error.status redirects to 404 - if (projectConfigError?.status === 404 || !projectConfig) { + if (workspaceConfigError?.status === 404 || !workspaceConfig) { notFound(); } - // Get project - const { data: project, error: projectError } = await getProjectBySlug( - params.project, + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug( + params.workspace, 'server', true, false ); - // If project is undefined redirects to 404 - if (projectError?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (workspaceError?.status === 404 || !workspace) { notFound(); } return ( - - - - Changelog - - All the latest updates, improvements, and fixes to {project.name}. + + + {/* Title, Description */} + + Changelog + + All the latest updates, improvements, and fixes to {workspace.name}.{' '} + - {/* Buttons */} - - {/* Email */} - - - Subscribe to Updates - - - - · - - {/* Twitter */} - {projectConfig.changelog_twitter_handle !== null && - projectConfig.changelog_twitter_handle !== '' && ( - - - Follow us on Twitter - - - · - - )} - - {/* RRS Update Feed */} - + {/* Email */} + + - Subscribe to Atom Feed - - + Subscribe to Updates + + + + · + + {/* Twitter */} + {workspaceConfig?.changelog_twitter_handle ? ( + + + Follow us on Twitter + + + · + + ) : null} + + {/* RRS Update Feed */} + + Subscribe to Atom Feed + @@ -134,9 +140,9 @@ export default async function Changelogs({ params }: Props) { {/* Date */} - - - {new Date(changelog.publish_date!).toLocaleDateString('en-US', { + + + {new Date(changelog.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', @@ -156,7 +162,7 @@ export default async function Changelogs({ params }: Props) { {/* Image */} {/* Summary */} - {projectConfig.changelog_preview_style === 'summary' && ( - + {workspaceConfig.changelog_preview_style === 'summary' && ( + {changelog.summary} )} - {projectConfig.changelog_preview_style === 'content' && ( + {workspaceConfig.changelog_preview_style === 'content' && ( )} @@ -198,8 +204,8 @@ export default async function Changelogs({ params }: Props) { {/* Empty State */} {changelogs.length === 0 && ( - No changelogs yet - + No changelogs yet + The latest updates, improvements, and fixes will be posted here. Stay tuned! diff --git a/apps/web/app/[project]/changelog/unsubscribe/page.tsx b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx similarity index 54% rename from apps/web/app/[project]/changelog/unsubscribe/page.tsx rename to apps/web/app/[workspace]/changelog/unsubscribe/page.tsx index a6af8c2..e6d305c 100644 --- a/apps/web/app/[project]/changelog/unsubscribe/page.tsx +++ b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx @@ -1,12 +1,12 @@ import { notFound, redirect } from 'next/navigation'; -import { getProjectBySlug } from '@/lib/api/projects'; -import UnsubscribeChangelogCard from '@/components/layout/unsubscribe-card'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import UnsubscribeChangelogCard from '@/components/changelog/unsubscribe-card'; export default async function ChangelogUnsubscribe({ params, searchParams, }: { - params: { project: string }; + params: { workspace: string }; searchParams: { subId: string }; }) { if (!searchParams.subId) { @@ -20,17 +20,17 @@ export default async function ChangelogUnsubscribe({ redirect('/'); } - // Get project - const { data: project, error } = await getProjectBySlug(params.project, 'server', true, false); + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); - // If project is undefined redirects to 404 - if (error?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { notFound(); } return ( - + ); } diff --git a/apps/web/app/[project]/feedback/[id]/page.tsx b/apps/web/app/[workspace]/feedback/[id]/page.tsx similarity index 70% rename from apps/web/app/[project]/feedback/[id]/page.tsx rename to apps/web/app/[workspace]/feedback/[id]/page.tsx index 54b8bd6..0a3d013 100644 --- a/apps/web/app/[project]/feedback/[id]/page.tsx +++ b/apps/web/app/[workspace]/feedback/[id]/page.tsx @@ -1,27 +1,31 @@ import { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { Avatar, AvatarFallback, AvatarImage } from '@ui/components/ui/avatar'; -import { Separator } from '@ui/components/ui/separator'; -import { cn } from '@ui/lib/utils'; -import { BadgeCheck, CheckCircle2, CircleDashed, CircleDot, CircleDotDashed, XCircle } from 'lucide-react'; -import { getCommentsForFeedbackById } from '@/lib/api/comments'; -import { getPublicProjectFeedback } from '@/lib/api/public'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Separator } from '@feedbase/ui/components/separator'; +import { cn } from '@feedbase/ui/lib/utils'; +import { BadgeCheck } from 'lucide-react'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; import { getCurrentUser } from '@/lib/api/user'; -import { PROSE_CN } from '@/lib/constants'; -import AnalyticsWrapper from '@/components/hub/analytics-wrapper'; -import CommentsList from '@/components/hub/feedback/comments/comments-list'; +import { PROSE_CN, STATUS_OPTIONS } from '@/lib/constants'; +import CommentsList from '@/components/feedback/hub/comments-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; type Props = { - params: { project: string; id: string }; + params: { workspace: string; id: string }; }; // Metadata export async function generateMetadata({ params }: Props): Promise { // Get feedback - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); - // If project is undefined redirects to 404 + // If workspace is undefined redirects to 404 if (error?.status === 404 || !feedbackList) { notFound(); } @@ -36,36 +40,17 @@ export async function generateMetadata({ params }: Props): Promise { return { title: feedback.title, - description: feedback.description, + description: feedback.content, }; } -// Status options -const statusOptions = [ - { - label: 'Backlog', - icon: CircleDashed, - }, - { - label: 'Planned', - icon: CircleDotDashed, - }, - { - label: 'In Progress', - icon: CircleDot, - }, - { - label: 'Completed', - icon: CheckCircle2, - }, - { - label: 'Rejected', - icon: XCircle, - }, -]; - export default async function FeedbackDetails({ params }: Props) { - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); if (error || !feedbackList) { return {error.message}; @@ -79,31 +64,19 @@ export default async function FeedbackDetails({ params }: Props) { notFound(); } - // Get comments - const { data: comments, error: commentsError } = await getCommentsForFeedbackById( - params.id, - params.project, - 'server', - false - ); - - if (commentsError) { - return {commentsError.message}; - } - // Get current user const { data: user } = await getCurrentUser('server'); return ( - + {/* // Row Splitting up date and Content */} {/* Back Button */} - - + + ← Back to Posts @@ -117,8 +90,8 @@ export default async function FeedbackDetails({ params }: Props) { {feedback.title} @@ -128,38 +101,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - - {currentStatus.label} - + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -171,7 +142,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -181,7 +152,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -193,9 +164,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -206,17 +177,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -226,19 +195,14 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} {/* Comments */} - + @@ -248,36 +212,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - {currentStatus.label} + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -289,7 +253,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -299,7 +263,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -311,9 +275,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -324,17 +288,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -345,7 +307,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} diff --git a/apps/web/app/[project]/page.tsx b/apps/web/app/[workspace]/feedback/page.tsx similarity index 73% rename from apps/web/app/[project]/page.tsx rename to apps/web/app/[workspace]/feedback/page.tsx index c6f6f88..2c59ed8 100644 --- a/apps/web/app/[project]/page.tsx +++ b/apps/web/app/[workspace]/feedback/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; -export default async function Hub() { +export default async function Feedback() { // Redirect to changelog // This page doesn't really get called as this is catched in layout.tsx but still needed to cause 404 - redirect(`/feedback`); + redirect(`/`); } diff --git a/apps/web/app/[workspace]/layout.tsx b/apps/web/app/[workspace]/layout.tsx new file mode 100644 index 0000000..da4072e --- /dev/null +++ b/apps/web/app/[workspace]/layout.tsx @@ -0,0 +1,160 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceTheme } from '@/lib/api/theme'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Header from '@/components/layout/nav-bar'; +import CustomThemeWrapper from '@/components/layout/theme-wrapper'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; + +type Props = { + children: React.ReactNode; + params: { workspace: string }; + searchParams: Record; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: workspace.name, + description: `Discover the latest updates, roadmaps, submit feedback, and explore more about ${workspace.name}.`, + icons: workspace.icon, + openGraph: { + images: [ + { + url: workspace.opengraph_image || '', + width: 1200, + height: 600, + alt: workspace.name, + }, + ], + }, + }; +} + +const tabs: { name: string; link: string; items?: { name: string; link: string }[] }[] = [ + { + name: 'Boards', + link: '/', + items: [], + }, + { + name: 'Roadmap', + link: '/roadmap', + }, + { + name: 'Changelog', + link: '/changelog', + }, +]; + +export default async function HubLayout({ children, params, searchParams }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + const hostname = headerList.get('host'); + + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + if (error?.status === 404 || !workspace) { + notFound(); + } + + // Get workspace boards + const { data: boards, error: boardsError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardsError || !boards) { + notFound(); + } + + // Set workspace boards to tabs + tabs[0].items = boards.map((board) => ({ + name: board.name, + link: `/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`, + })); + + // Get workspace config + const { data: config } = await getWorkspaceModuleConfig(params.workspace, 'server', true, false); + + // Get workspace theme + const { data: workspaceTheme } = await getWorkspaceTheme(params.workspace, 'server', true, false); + + if (!config || !workspaceTheme) { + notFound(); + } + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Check if custom domain is set and redirect to it + if (workspace.custom_domain && workspace.custom_domain_verified && hostname !== workspace.custom_domain) { + // Validate redirect rules + switch (workspace.custom_domain_redirect) { + case 'root_redirect': + return redirect(`https://${workspace.custom_domain}`); + case 'direct_redirect': + return redirect(`https://${workspace.custom_domain}${pathname}`); + case 'no_redirect': + break; + } + } + + // Get current tab + const currentTab = tabs.find((tab) => { + if (tab.link === pathname) { + return true; + } + if (tab.items) { + const subItem = tab.items.find((item) => item.link === pathname); + if (subItem) { + // Return the subitem directly + return subItem; + } + } + return false; + }); + + // Extract subItem if found, otherwise use the main tab + const foundItem = currentTab?.items?.find((item) => item.link === pathname) || currentTab; + + // Check if any modules are disabled and remove them from the tabs + if (!config.changelog_enabled) { + tabs.splice(1, 1); + } + + return ( + // + + {/* Header */} + + + {/* Main content */} + + {children} + + + ); +} diff --git a/apps/web/app/[workspace]/page.tsx b/apps/web/app/[workspace]/page.tsx new file mode 100644 index 0000000..ff32044 --- /dev/null +++ b/apps/web/app/[workspace]/page.tsx @@ -0,0 +1,98 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import FeedbackBoardList from '@/components/feedback/hub/board-list'; +import FeedbackHeader from '@/components/feedback/hub/button-header'; +import FeedbackList from '@/components/feedback/hub/feedback-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Feedback - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Fetch feedback boards + const { data: boards, error: boardError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardError) { + return {boardError.message}; + } + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + // Search for the initial board mathing the pathname by /board/board-name format + const initialBoard = boards.find( + (board) => pathname?.includes(`/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`) + ); + + return ( + + {/* Title, Description */} + + + Feedback + + Have a suggestion or found a bug? Let us know! + + + + + {/* Seperator */} + + + + {/* Filter Header, Feedback List */} + + {/* Filter Header */} + + {/* Feedback List */} + + + {/* Boards */} + + + + ); +} diff --git a/apps/web/app/[workspace]/roadmap/page.tsx b/apps/web/app/[workspace]/roadmap/page.tsx new file mode 100644 index 0000000..3648c14 --- /dev/null +++ b/apps/web/app/[workspace]/roadmap/page.tsx @@ -0,0 +1,69 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Roadmap from '@/components/roadmap/hub/roadmap'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Roadmap - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + return ( + + {/* Title, Description */} + + + Roadmap + + View what we're working on and what's coming next. + + + + + {/* Seperator */} + + + {/* Roadmap */} + + + ); +} diff --git a/apps/web/app/api/trigger/route.ts b/apps/web/app/api/trigger/route.ts new file mode 100644 index 0000000..0b08117 --- /dev/null +++ b/apps/web/app/api/trigger/route.ts @@ -0,0 +1,9 @@ +import { client } from '@feedbase/triggers'; +import { createAppRoute } from '@trigger.dev/nextjs'; +import '@feedbase/triggers/jobs'; + +//this route is used to send and receive data with Trigger.dev +export const { POST, dynamic } = createAppRoute(client); + +//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration +export const maxDuration = 60; diff --git a/apps/web/app/api/v1/[slug]/atom/route.ts b/apps/web/app/api/v1/[slug]/atom/route.ts index 1218c5b..198eaeb 100644 --- a/apps/web/app/api/v1/[slug]/atom/route.ts +++ b/apps/web/app/api/v1/[slug]/atom/route.ts @@ -1,20 +1,20 @@ import { NextResponse } from 'next/server'; -import { getProjectBySlug } from '@/lib/api/projects'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; /* - Generate atom feed for project changelog + Generate atom feed for workspace changelog */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project data - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route', true, false); + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - const { data: changelogs, error: changelogError } = await getPublicProjectChangelogs( + const { data: changelogs, error: changelogError } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, @@ -30,12 +30,12 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return new Response( ` - ${project.name} Changelog - ${project.name}'s Changelog + ${workspace.name} Changelog + ${workspace.name}'s Changelog ${changelogs[0].publish_date} - ${project.id}${changelogs + ${workspace.id}${changelogs .map((post) => { return ` diff --git a/apps/web/app/api/v1/[slug]/changelogs/route.ts b/apps/web/app/api/v1/[slug]/changelogs/route.ts index 8650092..1f46ed6 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getPublicProjectChangelogs( + const { data: changelogs, error } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, diff --git a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts index f225b75..0f96984 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import { subscribeToProjectChangelogs, unsubscribeFromProjectChangelogs } from '@/lib/api/public'; +import { subscribeToWorkspaceChangelogs, unsubscribeFromWorkspaceChangelogs } from '@/lib/api/public'; /* - Subscribe to project changelogs + Subscribe to workspace changelogs POST /api/v1/:slug/changelogs/subscribers { email: string, @@ -16,8 +16,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'email is required.' }, { status: 400 }); } - // Subscribe to project changelogs - const { data: subscriber, error } = await subscribeToProjectChangelogs(context.params.slug, email); + // Subscribe to workspace changelogs + const { data: subscriber, error } = await subscribeToWorkspaceChangelogs(context.params.slug, email); // If any errors thrown, return error if (error) { @@ -29,7 +29,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Unsubscribe from project changelogs + Unsubscribe from workspace changelogs DELETE /api/v1/:slug/changelogs/subscribers { subId: string, @@ -43,8 +43,8 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: 'subId is required.' }, { status: 400 }); } - // Unsubscribe from project changelogs - const { error } = await unsubscribeFromProjectChangelogs(context.params.slug, subId); + // Unsubscribe from workspace changelogs + const { error } = await unsubscribeFromWorkspaceChangelogs(context.params.slug, subId); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/[slug]/feedback/route.ts b/apps/web/app/api/v1/[slug]/feedback/route.ts new file mode 100644 index 0000000..51e8479 --- /dev/null +++ b/apps/web/app/api/v1/[slug]/feedback/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; + +/* + Get public feedback + GET /api/v1/[slug]/feedback + */ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: feedback, error } = await getPublicWorkspaceFeedback( + context.params.slug, + 'route', + true, + false + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/[slug]/sso/route.ts b/apps/web/app/api/v1/[slug]/sso/route.ts index 889291e..92ead6c 100644 --- a/apps/web/app/api/v1/[slug]/sso/route.ts +++ b/apps/web/app/api/v1/[slug]/sso/route.ts @@ -2,7 +2,7 @@ import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@supabase/supabase-js'; import { JwtPayload, verify } from 'jsonwebtoken'; -import { getProjectBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; export async function GET(req: NextRequest, context: { params: { slug: string } }) { const redirectTo = req.nextUrl.searchParams.get('redirect_to'); @@ -18,33 +18,33 @@ export async function GET(req: NextRequest, context: { params: { slug: string } const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY); - // Get project by slug - const { data: project, error: projectError } = await getProjectBySlug( + // Get workspace by slug + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug( context.params.slug, 'route', true, false ); - if (projectError) { - return NextResponse.json(projectError, { status: 500 }); + if (workspaceError) { + return NextResponse.json(workspaceError, { status: 500 }); } - // Get projects jwt secret - const { data: projectConfig, error: projectConfigError } = await supabase - .from('project_configs') + // Get workspaces jwt secret + const { data: workspaceConfig, error: workspaceConfigError } = await supabase + .from('workspace_configs') .select('integration_sso_secret') - .eq('project_id', project.id) + .eq('workspace_id', workspace.id) .single(); - if (projectConfigError) { - return NextResponse.json(projectConfigError.message, { status: 500 }); + if (workspaceConfigError) { + return NextResponse.json(workspaceConfigError.message, { status: 500 }); } // Verify jwt let payload: JwtPayload; try { - const decoded = verify(jwtPayload, projectConfig?.integration_sso_secret as string); + const decoded = verify(jwtPayload, workspaceConfig?.integration_sso_secret as string); payload = decoded as JwtPayload; } catch (error) { if (error instanceof Error) { @@ -59,7 +59,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid payload' }, { status: 500 }); } - const email = (payload.email as string).replace('@', `+${project.id}@`); + const email = (payload.email as string).replace('@', `+${workspace.id}@`); // Create user based on jwt payload const { error } = await supabase.auth.admin.createUser({ diff --git a/apps/web/app/api/v1/[slug]/views/route.ts b/apps/web/app/api/v1/[slug]/views/route.ts index 9b038e7..ec0681e 100644 --- a/apps/web/app/api/v1/[slug]/views/route.ts +++ b/apps/web/app/api/v1/[slug]/views/route.ts @@ -3,7 +3,7 @@ import { recordClick } from '@/lib/tinybird'; /* Record page view - POST /api/v1/[project]/views + POST /api/v1/[workspace]/views { "feedbackId": "string", "changelogId": "string", @@ -19,7 +19,7 @@ export async function POST(req: NextRequest, context: { params: { slug: string } const data = await recordClick({ req, - projectId: context.params.slug, + workspaceId: context.params.slug, feedbackId, changelogId, }); diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts b/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts deleted file mode 100644 index 72a5472..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; - -/* - Update SSO configuration - PATCH /api/v1/projects/:slug/config/integrations/sso - { - status: boolean, - url: string, - secret: string, - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, url, secret } = await req.json(); - - if (status && (!url || !secret)) { - return NextResponse.json( - { error: 'url and secret are required when enabling SSO integration.' }, - { status: 400 } - ); - } - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - integration_sso_status: status, - integration_sso_url: status ? url : null, - integration_sso_secret: status ? secret : null, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/config/route.ts b/apps/web/app/api/v1/projects/[slug]/config/route.ts deleted file mode 100644 index 938fdfb..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; -import { ProjectConfigProps } from '@/lib/types'; - -/* - Get Project Config by slug - GET /api/v1/projects/[slug]/config -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: projectConfig, error } = await getProjectConfigBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project config - return NextResponse.json(projectConfig, { status: 200 }); -} - -/* - Update Project Config by slug - PATCH /api/v1/projects/[slug]/config - { - changelog_preview_style: string; - changelog_twitter_handle: string; - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - } = (await req.json()) as ProjectConfigProps['Update']; - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts b/apps/web/app/api/v1/projects/[slug]/feedback/route.ts deleted file mode 100644 index 7c24e3d..0000000 --- a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createFeedback, getAllProjectFeedback } from '@/lib/api/feedback'; -import { FeedbackWithUserInputProps } from '@/lib/types'; - -export const runtime = 'edge'; - -/* - Create Feedback - POST /api/v1/projects/[slug]/feedback - { - title: string; - description: string; - status: string; - tags: [id, id, id] - } -*/ -export async function POST(req: Request, context: { params: { slug: string } }) { - const { title, description, status, tags, user } = (await req.json()) as FeedbackWithUserInputProps; - - // Validate Request Body - if (!title) { - return NextResponse.json({ error: 'title is required when creating feedback.' }, { status: 400 }); - } - - const { data: feedback, error } = await createFeedback( - context.params.slug, - { - title: title || '', - description: description || '', - status: status || '', - project_id: 'dummy-id', - user_id: 'dummy-id', - tags: tags || [], - user: user !== null ? user : undefined, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} - -/* - Get Project Feedback - GET /api/v1/projects/[slug]/feedback -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route', false); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/route.ts b/apps/web/app/api/v1/projects/[slug]/route.ts deleted file mode 100644 index ddfa07b..0000000 --- a/apps/web/app/api/v1/projects/[slug]/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextResponse } from 'next/server'; -import { deleteProjectBySlug, getProjectBySlug, updateProjectBySlug } from '@/lib/api/projects'; -import { ProjectProps } from '@/lib/types'; - -/* - Get project by slug - GET /api/v1/projects/[slug] -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route'); - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project - return NextResponse.json({ project }, { status: 200 }); -} - -/* - Update project by slug - PATCH /api/v1/projects/[slug] - { - name: string, - slug: string, - icon: string, - icon_radius: string, - og_image: string - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - name, - slug, - icon, - icon_radius: iconRadius, - og_image: OGImage, - } = (await req.json()) as ProjectProps['Update']; - - const { data: updatedProject, error } = await updateProjectBySlug( - context.params.slug, - { name, slug, icon, icon_radius: iconRadius, og_image: OGImage }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project - return NextResponse.json(updatedProject, { status: 200 }); -} - -/* - Delete project by slug - DELETE /api/v1/projects/[slug] -*/ -export async function DELETE(req: Request, context: { params: { slug: string } }) { - const { data, error } = await deleteProjectBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return success - return NextResponse.json({ data }, { status: 200 }); -} diff --git a/apps/web/app/api/v1/route.ts b/apps/web/app/api/v1/route.ts index 56224ea..eb89aaf 100644 --- a/apps/web/app/api/v1/route.ts +++ b/apps/web/app/api/v1/route.ts @@ -23,16 +23,16 @@ export function GET(): NextResponse { }, ], paths: { - '/{projectSlug}/atom': { + '/{workspaceSlug}/atom': { get: { - description: 'Generate atom feed for project changelog', - operationId: 'getProjectChangelogsAtom', + description: 'Generate atom feed for workspace changelog', + operationId: 'getWorkspaceChangelogsAtom', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -51,7 +51,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -61,7 +61,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -73,16 +73,16 @@ export function GET(): NextResponse { }, }, }, - '/{projectSlug}/changelogs': { + '/{workspaceSlug}/changelogs': { get: { - description: 'Get public project changelogs', - operationId: 'getPublicProjectChangelogs', + description: 'Get public workspace changelogs', + operationId: 'getPublicWorkspaceChangelogs', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -104,7 +104,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -114,7 +114,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -126,15 +126,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}': { + '/workspaces/{workspaceSlug}': { get: { - description: 'Get a project by slug', - operationId: 'getProjectBySlug', + description: 'Get a workspace by slug', + operationId: 'getWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -147,13 +147,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -163,7 +163,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -175,13 +175,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update a project by slug', - operationId: 'updateProjectBySlug', + description: 'Update a workspace by slug', + operationId: 'updateWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -189,12 +189,12 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project object that needs to be updated', + description: 'Workspace object that needs to be updated', required: true, content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectUpdate', + $ref: '#/components/schemas/WorkspaceUpdate', }, }, }, @@ -205,13 +205,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -221,7 +221,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -233,13 +233,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete a project by slug', - operationId: 'deleteProjectBySlug', + description: 'Delete a workspace by slug', + operationId: 'deleteWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -252,13 +252,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -268,7 +268,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -280,15 +280,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs': { + '/workspaces/{workspaceSlug}/changelogs': { get: { - description: 'Get all project changelogs', - operationId: 'getProjectChangelogs', + description: 'Get all workspace changelogs', + operationId: 'getWorkspaceChangelogs', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -310,7 +310,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -320,7 +320,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -332,13 +332,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project changelog', - operationId: 'createProjectChangelog', + description: 'Create workspace changelog', + operationId: 'createWorkspaceChangelog', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -368,7 +368,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -378,7 +378,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -390,15 +390,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs/{changelogId}': { + '/workspaces/{workspaceSlug}/changelogs/{changelogId}': { put: { - description: 'Update project changelog by id', - operationId: 'updateProjectChangelogById', + description: 'Update workspace changelog by id', + operationId: 'updateWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -437,21 +437,21 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, delete: { - description: 'Delete project changelog by id', - operationId: 'deleteProjectChangelogById', + description: 'Delete workspace changelog by id', + operationId: 'deleteWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -479,23 +479,23 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, }, - '/projects/{projectSlug}/config': { + '/workspaces/{workspaceSlug}/config': { get: { - description: 'Get project config', - operationId: 'getProjectConfig', + description: 'Get workspace config', + operationId: 'getWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -508,13 +508,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -524,7 +524,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -536,13 +536,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project config', - operationId: 'updateProjectConfig', + description: 'Update workspace config', + operationId: 'updateWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -550,13 +550,13 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project config object that needs to be updated', + description: 'Workspace config object that needs to be updated', required: true, content: { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfigUpdate', + $ref: '#/components/schemas/WorkspaceConfigUpdate', }, }, }, @@ -568,13 +568,13 @@ export function GET(): NextResponse { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -584,7 +584,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -596,15 +596,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback': { + '/workspaces/{workspaceSlug}/feedback': { get: { - description: 'Get all project feedback', - operationId: 'getProjectFeedback', + description: 'Get all workspace feedback', + operationId: 'getWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -626,7 +626,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -636,7 +636,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -648,13 +648,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project feedback', - operationId: 'createProjectFeedback', + description: 'Create workspace feedback', + operationId: 'createWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -684,7 +684,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -694,7 +694,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -706,15 +706,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}': { get: { - description: 'Get project feedback by id', - operationId: 'getProjectFeedbackById', + description: 'Get workspace feedback by id', + operationId: 'getWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -742,7 +742,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -764,13 +764,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project feedback by id', - operationId: 'updateProjectFeedbackById', + description: 'Update workspace feedback by id', + operationId: 'updateWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -809,7 +809,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -831,13 +831,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete project feedback by id', - operationId: 'deleteProjectFeedbackById', + description: 'Delete workspace feedback by id', + operationId: 'deleteWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -865,7 +865,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -887,15 +887,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments': { get: { description: 'Get feedback comments', operationId: 'getFeedbackComments', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -926,7 +926,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -936,7 +936,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -948,15 +948,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments/{commentId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments/{commentId}': { delete: { - description: 'Delete project feedback comment by id', - operationId: 'deleteProjectFeedbackCommentById', + description: 'Delete workspace feedback comment by id', + operationId: 'deleteWorkspaceFeedbackCommentById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -993,7 +993,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1003,7 +1003,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -1015,15 +1015,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/upvotes': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/upvotes': { get: { description: 'Get feedback upvoters', operationId: 'getFeedbackUpvoters', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1054,7 +1054,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1076,15 +1076,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags': { + '/workspaces/{workspaceSlug}/feedback/tags': { get: { description: 'Get feedback tags', operationId: 'getFeedbackTags', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1106,7 +1106,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1116,7 +1116,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1132,9 +1132,9 @@ export function GET(): NextResponse { operationId: 'createFeedbackTag', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1164,7 +1164,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1174,7 +1174,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1186,15 +1186,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags/{tagName}': { + '/workspaces/{workspaceSlug}/feedback/tags/{tagName}': { delete: { description: 'Delete feedback tag by name', operationId: 'deleteFeedbackTagByName', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1222,7 +1222,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or tag name supplied', + description: 'Invalid workspace slug or tag name supplied', content: { 'application/json': { schema: { @@ -1232,7 +1232,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or tag not found', + description: 'Workspace or tag not found', content: { 'application/json': { schema: { @@ -1244,15 +1244,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites': { + '/workspaces/{workspaceSlug}/invites': { get: { - description: 'List project team invites', - operationId: 'getProjectInvites', + description: 'List workspace team invites', + operationId: 'getWorkspaceInvites', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1267,14 +1267,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ExtendedProjectInvite', + $ref: '#/components/schemas/ExtendedWorkspaceInvite', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1284,7 +1284,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1296,13 +1296,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create a project invite', - operationId: 'createProjectInvite', + description: 'Create a workspace invite', + operationId: 'createWorkspaceInvite', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1310,7 +1310,7 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project invite object that needs to be created', + description: 'Workspace invite object that needs to be created', required: true, content: { 'application/json': { @@ -1331,13 +1331,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1347,7 +1347,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1359,15 +1359,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites/{inviteId}': { + '/workspaces/{workspaceSlug}/invites/{inviteId}': { delete: { - description: 'Delete project invite by id', - operationId: 'deleteProjectInviteById', + description: 'Delete workspace invite by id', + operationId: 'deleteWorkspaceInviteById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1389,13 +1389,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1405,7 +1405,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1417,15 +1417,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/members': { + '/workspaces/{workspaceSlug}/members': { get: { - description: 'Get project members', - operationId: 'getProjectMembers', + description: 'Get workspace members', + operationId: 'getWorkspaceMembers', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1441,14 +1441,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ProjectMember', + $ref: '#/components/schemas/WorkspaceMember', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1458,7 +1458,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1473,7 +1473,7 @@ export function GET(): NextResponse { }, components: { schemas: { - Project: { + Workspace: { type: 'object', properties: { id: { @@ -1501,7 +1501,7 @@ export function GET(): NextResponse { }, }, }, - ProjectUpdate: { + WorkspaceUpdate: { type: 'object', properties: { name: { @@ -1528,7 +1528,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1567,7 +1567,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1667,14 +1667,14 @@ export function GET(): NextResponse { }, }, }, - ProjectConfig: { + WorkspaceConfig: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1705,7 +1705,7 @@ export function GET(): NextResponse { }, }, }, - ProjectConfigUpdate: { + WorkspaceConfigUpdate: { type: 'object', properties: { changelog_preview_style: { @@ -1723,7 +1723,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1759,7 +1759,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1998,7 +1998,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2025,14 +2025,14 @@ export function GET(): NextResponse { }, }, }, - ProjectInvite: { + WorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2052,18 +2052,18 @@ export function GET(): NextResponse { }, }, }, - ExtendedProjectInvite: { + ExtendedWorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, - project: { + workspace: { type: 'object', properties: { name: { @@ -2101,7 +2101,7 @@ export function GET(): NextResponse { }, }, }, - ProjectMember: { + WorkspaceMember: { type: 'object', properties: { id: { diff --git a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts similarity index 71% rename from apps/web/app/api/v1/projects/[slug]/config/domain/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts index c5a2d2b..cc55d24 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts @@ -1,21 +1,21 @@ import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; /* - GET /api/v1/projects/:slug/config/domain + GET /api/v1/workspaces/:slug/domain */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } const [configResponse, domainResponse] = await Promise.all([ fetch( - `https://api.vercel.com/v6/domains/${data.custom_domain}/config${ + `https://api.vercel.com/v6/domains/${workspace.custom_domain}/config${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -27,9 +27,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } ), fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data.custom_domain}${ - process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' - }`, + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ + workspace.custom_domain + }${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'GET', headers: { @@ -58,7 +58,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { if (!domainData.verified && !configData.misconfigured) { const verifyResponse = await fetch( `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ - data.custom_domain + workspace.custom_domain }/verify${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'POST', @@ -102,9 +102,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { ); } - // If verification succeeded, update project config + // If verification succeeded, update workspace config if (domainData?.verified && !configData.misconfigured) { - const { error: updateError } = await updateProjectConfigBySlug( + const { error: updateError } = await updateWorkspaceBySlug( context.params.slug, { custom_domain_verified: true }, 'route' @@ -128,7 +128,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - POST /api/v1/projects/:slug/config/domain + POST /api/v1/workspaces/:slug/domain { "name": "example.com" } @@ -140,20 +140,17 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'Name is required' }, { status: 400 }); } - // Get project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - context.params.slug, - 'route' - ); + // Get workspace config + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (projectConfigError) { - return NextResponse.json({ error: projectConfigError.message }, { status: projectConfigError.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } - // If project already has a domain, return error - if (projectConfig.custom_domain) { - return NextResponse.json({ error: 'Project already has a custom domain' }, { status: 409 }); + // If workspace already has a domain, return error + if (workspace.custom_domain) { + return NextResponse.json({ error: 'Workspace already has a custom domain' }, { status: 409 }); } const response = await fetch( @@ -188,8 +185,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) ); } - // Update project config - const { error } = await updateProjectConfigBySlug( + // Update workspace config + const { error } = await updateWorkspaceBySlug( context.params.slug, { custom_domain: responseData.name, custom_domain_verified: false }, 'route' @@ -205,25 +202,25 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - DELETE /api/v1/projects/:slug/config/domain + DELETE /api/v1/workspaces/:slug/domain */ export async function DELETE(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } // If no domain, return error - if (!data?.custom_domain) { - return NextResponse.json({ error: 'Project does not have a custom domain' }, { status: 400 }); + if (!workspace?.custom_domain) { + return NextResponse.json({ error: 'Workspace does not have a custom domain' }, { status: 400 }); } // Delete domain from Vercel const response = await fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data?.custom_domain}${ + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${workspace?.custom_domain}${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -242,21 +239,21 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: responseData.error.message }, { status: 400 }); } - // Update project config - const { data: updatedProjectConfig, error: updatedProjectConfigError } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error: updatedWorkspaceConfigError } = await updateWorkspaceBySlug( context.params.slug, - { custom_domain: null, custom_domain_verified: null }, + { custom_domain: null, custom_domain_verified: false }, 'route' ); // If any errors thrown, return error - if (updatedProjectConfigError) { + if (updatedWorkspaceConfigError) { return NextResponse.json( - { error: updatedProjectConfigError.message }, - { status: updatedProjectConfigError.status } + { error: updatedWorkspaceConfigError.message }, + { status: updatedWorkspaceConfigError.status } ); } // Return response - return NextResponse.json(updatedProjectConfig, { status: 200 }); + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts index 2c483cf..003e70b 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts @@ -1,37 +1,37 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Discord integration - PATCH /api/v1/projects/:slug/config/integrations/discord + PATCH /api/v1/workspaces/:slug/config/integrations/discord { - status: boolean, + enabled: boolean, webhook: string, roleId: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook, roleId } = (await req.json()) as { - status: boolean; + const { enabled, webhook, roleId } = (await req.json()) as { + enabled: boolean; webhook: string; - roleId: string; + roleId: number; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Discord integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_discord_status: status, - integration_discord_webhook: status ? webhook : null, - integration_discord_role_id: status ? roleId : null, + discord_enabled: enabled, + discord_webhook: enabled ? webhook : null, + discord_role_id: enabled ? roleId : null, }, 'route' ); @@ -41,6 +41,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts index 25496b4..2c310e4 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts @@ -1,34 +1,34 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Slack integration - PATCH /api/v1/projects/:slug/config/integrations/slack + PATCH /api/v1/workspaces/:slug/config/integrations/slack { status: boolean, webhook: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook } = (await req.json()) as { - status: boolean; + const { enabled, webhook } = (await req.json()) as { + enabled: boolean; webhook: string; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Slack integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_slack_status: status, - integration_slack_webhook: status ? webhook : null, + slack_enabled: enabled, + slack_webhook: enabled ? webhook : null, }, 'route' ); @@ -38,6 +38,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts new file mode 100644 index 0000000..700e0f0 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; + +/* + Get workspace module config + GET /api/v1/workspaces/[slug]/modules +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace module config + const { data: moduleConfig, error } = await getWorkspaceModuleConfig(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(moduleConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts new file mode 100644 index 0000000..36ed41c --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { updateWorkspaceBySlug } from '@/lib/api/workspace'; + +/* + Update SSO configuration + PATCH /api/v1/workspaces/:slug/config/integrations/sso + { + enabled: boolean, + url: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { enabled, url } = await req.json(); + + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceBySlug( + context.params.slug, + { + sso_auth_enabled: enabled, + sso_auth_url: url, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts new file mode 100644 index 0000000..eeb54b4 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { createWorkspaceSSOSecret } from '@/lib/api/workspace'; + +/* + Generate random jwt secret + POST /api/v1/workspaces/:slug/config/integrations/sso/secret +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { data, error } = await createWorkspaceSSOSecret(context.params.slug, 'route'); + + // If there is an error, return it + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(data, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts new file mode 100644 index 0000000..0f5eb8b --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceTheme, updateWorkspaceTheme } from '@/lib/api/theme'; + +/* + Get workspace theme + GET /api/v1/workspaces/:slug/theme +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace theme + const { data: theme, error } = await getWorkspaceTheme(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(theme, { status: 200 }); +} + +/* + Update workspace theme + PATCH /api/v1/workspaces/:slug/theme + { + "accent": "string", + "background": "string", + "border": "string", + "foreground": "string", + "root": "string", + "secondary_background": "string", + "theme": "string" + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + } = await req.json(); + + // Update workspace theme + const { data: updatedTheme, error } = await updateWorkspaceTheme( + context.params.slug, + { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + }, + 'route' + ); + + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + return NextResponse.json(updatedTheme, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/analytics/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts index 03d0ba4..eb95e97 100644 --- a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getProjectAnalytics } from '@/lib/api/projects'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; /* Get Analytics - GET /api/v1/projects/:slug/analytics + GET /api/v1/workspaces/:slug/analytics */ export async function GET(req: NextRequest, context: { params: { slug: string } }) { // Check if tinybird variables are set @@ -20,7 +20,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid start or end date.' }, { status: 400 }); } - const { data: analyticsData, error } = await getProjectAnalytics(context.params.slug, 'route'); + const { data: analyticsData, error } = await getWorkspaceAnalytics(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts index 436bbf4..bcf2640 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { deleteProjectApiKey } from '@/lib/api/projects'; +import { deleteWorkspaceApiKey } from '@/lib/api/api-key'; /* - Delete api key for a project - DELETE /api/v1/projects/:slug/config/api/:token + Delete api key for a workspace + DELETE /api/v1/workspaces/:slug/config/api/:token */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { - const { error } = await deleteProjectApiKey(context.params.slug, context.params.id, 'route'); + const { error } = await deleteWorkspaceApiKey(context.params.slug, context.params.id, 'route'); if (error) { return NextResponse.json({ error }, { status: error.status }); diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts similarity index 65% rename from apps/web/app/api/v1/projects/[slug]/api-keys/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts index b40c674..28aab04 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts @@ -1,13 +1,14 @@ import { NextResponse } from 'next/server'; -import { createProjectApiKey, getProjectApiKeys } from '@/lib/api/projects'; +import { createWorkspaceApiKey, getWorkspaceApiKeys } from '@/lib/api/api-key'; +import { ApiKeyPermissions } from '@/lib/types'; /* - Get all API keys for a project - GET /api/v1/projects/:slug/config/api + Get all API keys for a workspace + GET /api/v1/workspaces/:slug/config/api */ export async function GET(req: Request, context: { params: { slug: string } }) { // Get all api keys - const { data: apiKeys, error } = await getProjectApiKeys(context.params.slug, 'route'); + const { data: apiKeys, error } = await getWorkspaceApiKeys(context.params.slug, 'route'); if (error) { return NextResponse.json(error, { status: error.status }); @@ -17,15 +18,15 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Create a new API key for a project - POST /api/v1/projects/:slug/config/api + Create a new API key for a workspace + POST /api/v1/workspaces/:slug/config/api { "name": "string", "permissions": "string" } */ export async function POST(req: Request, context: { params: { slug: string } }) { - const { name, permission } = (await req.json()) as { name: string; permission: string }; + const { name, permission } = (await req.json()) as { name: string; permission: ApiKeyPermissions }; // Validate input if (!name || !permission) { @@ -33,7 +34,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } // Create api key - const { data: apiKey, error } = await createProjectApiKey( + const { data: apiKey, error } = await createWorkspaceApiKey( context.params.slug, { name, permission }, 'route' diff --git a/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts new file mode 100644 index 0000000..3af8b5f --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceBoards } from '@/lib/api/boards'; + +/* + Get all workspace boards + GET /api/v1/workspaces/[slug]/boards +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: boards, error } = await getWorkspaceBoards(context.params.slug, 'route', true, false); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return boards + return NextResponse.json(boards, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts similarity index 88% rename from apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts index c454d15..842afd2 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server'; -import { deleteChangelog, updateChangelog } from '@/lib/api/changelogs'; +import { deleteChangelog, updateChangelog } from '@/lib/api/changelog'; export const runtime = 'edge'; /* - Update project changelog - PUT /api/v1/projects/[slug]/changelogs/[id] + Update workspace changelog + PUT /api/v1/workspaces/[slug]/changelogs/[id] { title: string; summary: string; @@ -35,8 +35,8 @@ export async function PUT(req: Request, context: { params: { slug: string; id: s } /* - Delete project changelog - DELETE /api/v1/projects/[slug]/changelogs/[id] + Delete workspace changelog + DELETE /api/v1/workspaces/[slug]/changelogs/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { const { data, error } = await deleteChangelog(context.params.id, context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts similarity index 74% rename from apps/web/app/api/v1/projects/[slug]/changelogs/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts index bd84f37..8689301 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createChangelog, getAllProjectChangelogs } from '@/lib/api/changelogs'; +import { createChangelog, getAllWorkspaceChangelogs } from '@/lib/api/changelog'; import { ChangelogProps } from '@/lib/types'; export const runtime = 'edge'; /* Create Changelog - POST /api/v1/projects/[slug]/changelogs + POST /api/v1/workspaces/[slug]/changelogs { title: string; summary: string; @@ -21,10 +21,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) title, summary, content, - image, + thumbnail, publish_date: publishDate, published, - } = (await req.json()) as ChangelogProps['Insert']; + notify_subscribers: notifySubscribers, + } = (await req.json()) as ChangelogProps['Insert'] & { notify_subscribers: boolean }; // Validate Request Body if (published) { @@ -42,13 +43,14 @@ export async function POST(req: Request, context: { params: { slug: string } }) title: title || '', summary: summary || '', content: content || '', - image: image || '', + thumbnail: thumbnail || null, publish_date: publishDate || null, published: published || false, - project_id: 'dummy-id', + workspace_id: 'dummy-id', slug: 'dummy-slug', author_id: 'dummy-author', }, + notifySubscribers, 'route' ); @@ -62,11 +64,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(context.params.slug, 'route', true); + const { data: changelogs, error } = await getAllWorkspaceChangelogs(context.params.slug, 'route', true); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts new file mode 100644 index 0000000..de0a23a --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; + +/* + Get the subscribers count for a workspace changelog + GET /api/v1/workspaces/:slug/changelogs/subscribers/count +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return new Response(error.message, { status: error.status }); + } + + return NextResponse.json({ count: subscribers.length }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts similarity index 84% rename from apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts index 914c1f1..ab2621b 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ -import { getChangelogSubscribers } from '@/lib/api/changelogs'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; /* Download changelog subscribers - GET /api/v1/projects/:slug/changelogs/subscribers/download + GET /api/v1/workspaces/:slug/changelogs/subscribers/download */ export async function GET(req: Request, context: { params: { slug: string } }) { const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts index c8e2f0b..36bc132 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { deleteCommentForFeedbackById } from '@/lib/api/comments'; +import { deleteCommentForFeedbackById } from '@/lib/api/comment'; /* Delete comment for feedback by id - DELETE /api/v1/projects/[slug]/feedback/[id]/comments/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id]/comments/[id] */ export async function DELETE( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts similarity index 81% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts index 3c2506b..0c00268 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { upvoteCommentForFeedbackById } from '@/lib/api/comments'; +import { upvoteCommentForFeedbackById } from '@/lib/api/comment'; /* Upvote comment for feedback by id - POST /api/v1/projects/[slug]/feedback/[id]/comments/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/comments/[id]/upvote */ export async function POST( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts similarity index 85% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts index e9b38f9..5ac00b2 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts @@ -1,16 +1,17 @@ import { NextResponse } from 'next/server'; -import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comments'; -import { FeedbackCommentProps } from '@/lib/types'; +import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comment'; +import { CommentProps } from '@/lib/types'; /* Create feedback comment - POST /api/v1/projects/[slug]/feedback/[id]/comments + POST /api/v1/workspaces/[slug]/feedback/[id]/comments { content: string + reply_to_id?: string } */ export async function POST(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { content, reply_to_id: replyToId } = (await req.json()) as FeedbackCommentProps['Insert']; + const { content, reply_to_id: replyToId } = (await req.json()) as CommentProps['Insert']; if (!content) { return NextResponse.json({ error: 'Content cannot be empty' }, { status: 400 }); @@ -38,7 +39,7 @@ export async function POST(req: Request, context: { params: { slug: string; feed /* Get feedback comments - GET /api/v1/projects/[slug]/feedback/[id]/comments + GET /api/v1/workspaces/[slug]/feedback/[id]/comments */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { const { data: comments, error } = await getCommentsForFeedbackById( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts similarity index 69% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts index d75433d..04c859d 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { deleteFeedbackByID, getFeedbackByID, updateFeedbackByID } from '@/lib/api/feedback'; +import { deleteFeedbackById, getFeedbackById, updateFeedbackByID } from '@/lib/api/feedback'; import { FeedbackWithUserInputProps } from '@/lib/types'; /* - Get Project Feedback by ID - GET /api/v1/projects/[slug]/feedback/[id] + Get Workspace Feedback by ID + GET /api/v1/workspaces/[slug]/feedback/[id] */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -24,27 +24,35 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Update Feedback by ID - PATCH /api/v1/projects/[slug]/feedback/[id] + PATCH /api/v1/workspaces/[slug]/feedback/[id] { title: string; - description: string; + content: string; status: string; tags: string[]; + board_id: string; } */ export async function PATCH(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { title, description, status, tags } = (await req.json()) as FeedbackWithUserInputProps; + const { + title, + content, + status, + tags, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; const { data: feedback, error } = await updateFeedbackByID( context.params.feedbackId, context.params.slug, { title: title || '', - description: description || '', - status, - project_id: 'dummy-id', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', user_id: 'dummy-id', tags: tags || undefined, + workspace_id: 'dummy-id', }, 'route' ); @@ -60,10 +68,10 @@ export async function PATCH(req: Request, context: { params: { slug: string; fee /* Delete Feedback by ID - DELETE /api/v1/projects/[slug]/feedback/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await deleteFeedbackByID( + const { data: feedback, error } = await deleteFeedbackById( context.params.feedbackId, context.params.slug, 'route' diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts similarity index 73% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts index 146768d..d3244af 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts @@ -1,13 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getFeedbackByID, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; -import { getCurrentUser } from '@/lib/api/user'; +import { getFeedbackById, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; /* Get feedback upvotes - GET /api/v1/projects/[slug]/feedback/[id]/upvote + GET /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -42,21 +41,14 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Upvote a feedback - POST /api/v1/projects/[slug]/feedback/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function POST(req: NextRequest, context: { params: { slug: string; feedbackId: string } }) { - const hasUpvoted = req.nextUrl.searchParams.get('has_upvoted'); - - // Get current user - const { data: isLoggedIn } = await getCurrentUser('route'); - // Upvote feedback const { data: feedback, error } = await upvoteFeedbackByID( context.params.feedbackId, context.params.slug, - 'route', - hasUpvoted ? hasUpvoted === 'true' : undefined, - !isLoggedIn + 'route' ); // If any errors thrown, return error diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts similarity index 78% rename from apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts index 1eebff8..166bf20 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts @@ -1,12 +1,12 @@ -import { getAllProjectFeedback } from '@/lib/api/feedback'; +import { getAllWorkspaceFeedback } from '@/lib/api/feedback'; import { formatRootUrl } from '@/lib/utils'; /* - Export all feedback for a project - GET /api/v1/projects/:slug/feedback/export + Export all feedback for a workspace + GET /api/v1/workspaces/:slug/feedback/export */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route'); + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -19,7 +19,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return `${feedback.id},${formatRootUrl( context.params.slug, `/feedback/${feedback.id}` - )},"${feedback.title.replace(/"/g, '""')}","${feedback.description.replace(/"/g, '""')}",${ + )},"${feedback.title.replace(/"/g, '""')}","${feedback.content.replace(/"/g, '""')}",${ feedback.status },${feedback.upvotes},${feedback.comment_count},${feedback.user_id},"${feedback.user.full_name.replace( /"/g, diff --git a/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts new file mode 100644 index 0000000..9f8bd23 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createFeedback, getAllBoardFeedback, getAllWorkspaceFeedback } from '@/lib/api/feedback'; +import { FeedbackWithUserInputProps } from '@/lib/types'; + +export const runtime = 'edge'; + +/* + Create Feedback + POST /api/v1/workspaces/[slug]/feedback + { + title: string; + content: string; + status: string; + tags: [id, id, id], + board_id?: string; + } +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { + title, + content, + status, + tags, + user, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; + + // Validate Request Body + if (!title) { + return NextResponse.json({ error: 'title is required.' }, { status: 400 }); + } + + const { data: feedback, error } = await createFeedback( + boardId, + context.params.slug, + { + title: title || '', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', + workspace_id: context.params.slug, + user_id: 'dummy-id', + tags: tags || [], + user: user !== null ? user : undefined, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} + +/* + Get Workspace Feedback + GET /api/v1/workspaces/[slug]/feedback + Query Parameters: + - b (board_id): string +*/ +export async function GET(req: NextRequest, context: { params: { slug: string } }) { + // Get query parameters + const boardId = req.nextUrl.searchParams.get('b'); + + // If boardId is provided, get feedback by boardId + if (boardId) { + const { data: feedback, error } = await getAllBoardFeedback(boardId, context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); + } + + // Get all feedback + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts similarity index 91% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts index 0713c06..0d3608a 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts @@ -3,7 +3,7 @@ import { deleteFeedbackTagByName } from '@/lib/api/feedback'; /* Delete tag by name - DELETE /api/v1/projects/:slug/feedback/tags/:name + DELETE /api/v1/workspaces/:slug/feedback/tags/:name */ export async function DELETE(req: Request, context: { params: { slug: string; name: string } }) { const { data: tag, error } = await deleteFeedbackTagByName( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts similarity index 89% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts index e6bc865..41af1a8 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts @@ -4,7 +4,7 @@ import { FeedbackTagProps } from '@/lib/types'; /* Create new tag - POST /api/v1/projects/:slug/feedback/tags + POST /api/v1/workspaces/:slug/feedback/tags { name: string, color: string @@ -33,10 +33,10 @@ export async function POST(req: Request, context: { params: { slug: string } }) /* Get all feedback tags - GET /api/v1/projects/:slug/feedback/tags + GET /api/v1/workspaces/:slug/feedback/tags */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route'); + const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts similarity index 61% rename from apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts index 1e66f71..b40a54f 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { acceptProjectInvite, deleteProjectInvite } from '@/lib/api/invites'; +import { acceptWorkspaceInvite, deleteWorkspaceInvite } from '@/lib/api/invite'; /* - Accept project invite - POST /api/v1/projects/[slug]/invites/[inviteId] + Accept workspace invite + POST /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function POST(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await acceptProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await acceptWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { @@ -18,11 +18,11 @@ export async function POST(req: Request, context: { params: { slug: string; invi } /* - Delete project invite - DELETE /api/v1/projects/[slug]/invites/[inviteId] + Delete workspace invite + DELETE /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function DELETE(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await deleteProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await deleteWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/invites/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/route.ts index 74256dc..65def20 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createProjectInvite, getProjectInvites } from '@/lib/api/invites'; +import { createWorkspaceInvite, getWorkspaceInvites } from '@/lib/api/invite'; /* - Get all project invites - GET /api/v1/projects/[slug]/invites + Get all workspace invites + GET /api/v1/workspaces/[slug]/invites */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: invites, error } = await getProjectInvites(context.params.slug, 'route'); + const { data: invites, error } = await getWorkspaceInvites(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -18,8 +18,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Invite a new member to a project - POST /api/v1/projects/[slug]/members + Invite a new member to a workspace + POST /api/v1/workspaces/[slug]/members { email: string } @@ -27,14 +27,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { export async function POST(req: Request, context: { params: { slug: string } }) { const { email } = await req.json(); - // Check if email is valid - const emailRegex = new RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); - if (!emailRegex.test(email)) { - return NextResponse.json({ error: 'Invalid email' }, { status: 400 }); - } - // Create invite - const { data: invite, error } = await createProjectInvite(context.params.slug, 'route', email); + const { data: invite, error } = await createWorkspaceInvite(context.params.slug, 'route', email); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/members/route.ts b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts similarity index 60% rename from apps/web/app/api/v1/projects/[slug]/members/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/members/route.ts index cfa5b2c..e73da78 100644 --- a/apps/web/app/api/v1/projects/[slug]/members/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getProjectMembers } from '@/lib/api/projects'; +import { getWorkspaceMembers } from '@/lib/api/workspace'; /* - Get all members of a project - GET /api/v1/projects/[slug]/members + Get all members of a workspace + GET /api/v1/workspaces/[slug]/members */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(context.params.slug, 'route'); + const { data: members, error } = await getWorkspaceMembers(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/route.ts new file mode 100644 index 0000000..309dac2 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { deleteWorkspaceBySlug, getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; +import { WorkspaceProps } from '@/lib/types'; + +/* + Get workspace by slug + GET /api/v1/workspaces/[slug] +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route'); + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return workspace + return NextResponse.json(workspace, { status: 200 }); +} + +/* + Update workspace by slug + PATCH /api/v1/workspaces/[slug] + { + name: string, + slug: string, + icon: string, + icon_radius: string, + og_image: string + icon_redirect_url: string + custom_domain_redirect: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + } = (await req.json()) as WorkspaceProps['Update']; + + const { data: updatedWorkspace, error } = await updateWorkspaceBySlug( + context.params.slug, + { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace + return NextResponse.json(updatedWorkspace, { status: 200 }); +} + +/* + Delete workspace by slug + DELETE /api/v1/workspaces/[slug] +*/ +export async function DELETE(req: Request, context: { params: { slug: string } }) { + const { data, error } = await deleteWorkspaceBySlug(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return success + return NextResponse.json({ data }, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/route.ts b/apps/web/app/api/v1/workspaces/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/route.ts rename to apps/web/app/api/v1/workspaces/route.ts index f65280b..d10836d 100644 --- a/apps/web/app/api/v1/projects/route.ts +++ b/apps/web/app/api/v1/workspaces/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { createProject } from '@/lib/api/projects'; -import { getUserProjects } from '@/lib/api/user'; +import { getUserWorkspaces } from '@/lib/api/user'; +import { createWorkspace } from '@/lib/api/workspace'; /* - Create Project - POST /api/v1/projects + Create Workspace + POST /api/v1/workspaces { - "name": "Project Name", - "slug": "project-slug", + "name": "Workspace Name", + "slug": "workspace-slug", } */ export async function POST(req: Request) { @@ -19,30 +19,30 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'name and slug are required.' }, { status: 400 }); } - // Create Project - const { data: project, error } = await createProject({ name, slug }, 'route'); + // Create Workspace + const { data: workspace, error } = await createWorkspace({ name, slug }, 'route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - return NextResponse.json(project, { status: 201 }); + return NextResponse.json(workspace, { status: 201 }); } /* - Get User Projects - GET /api/v1/projects + Get User Workspaces + GET /api/v1/workspaces */ export async function GET(req: Request) { - // Get User Projects - const { data: projects, error } = await getUserProjects('route'); + // Get User Workspaces + const { data: workspaces, error } = await getUserWorkspaces('route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return projects - return NextResponse.json(projects, { status: 200 }); + // Return workspaces + return NextResponse.json(workspaces, { status: 200 }); } diff --git a/apps/web/app/dash/(auth)/login/page.tsx b/apps/web/app/dash/(auth)/login/page.tsx index 951c959..129dc9e 100644 --- a/apps/web/app/dash/(auth)/login/page.tsx +++ b/apps/web/app/dash/(auth)/login/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign in to Feedbase', @@ -31,7 +38,7 @@ export default async function SignIn() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/(auth)/signup/page.tsx b/apps/web/app/dash/(auth)/signup/page.tsx index 912ed81..ffa038d 100644 --- a/apps/web/app/dash/(auth)/signup/page.tsx +++ b/apps/web/app/dash/(auth)/signup/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign up to Feedbase', @@ -31,7 +38,7 @@ export default async function SignUp() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/[slug]/analytics/loading.tsx b/apps/web/app/dash/[slug]/analytics/loading.tsx deleted file mode 100644 index 50dd5dd..0000000 --- a/apps/web/app/dash/[slug]/analytics/loading.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - - - - - - - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/analytics/page.tsx b/apps/web/app/dash/[slug]/analytics/page.tsx index 631e1be..35cebde 100644 --- a/apps/web/app/dash/[slug]/analytics/page.tsx +++ b/apps/web/app/dash/[slug]/analytics/page.tsx @@ -1,21 +1,55 @@ import React from 'react'; -import { getProjectAnalytics } from '@/lib/api/projects'; -import AnalyticsCards from '@/components/dashboard/analytics/chart-cards'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@feedbase/ui/components/select'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; +import { BarChart } from '@/components/analytics/bar-chart'; +import { LineChart } from '@/components/analytics/line-chart'; export default async function AnalyticsPage({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectAnalytics(params.slug, 'server'); + const { data, error } = await getWorkspaceAnalytics(params.slug, 'server'); if (!data) { return Error: {error?.message}; } return ( - - + + {/* Header */} + + Analytics + + + {/* Timeframe */} + + + + + + + Last 3 months + + + Last 30 days + + + Last 7 days + + + + + + + {/* Chart */} + + + + + ); } diff --git a/apps/web/app/dash/[slug]/changelog/loading.tsx b/apps/web/app/dash/[slug]/changelog/loading.tsx deleted file mode 100644 index 7e7f0a9..0000000 --- a/apps/web/app/dash/[slug]/changelog/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ; -} diff --git a/apps/web/app/dash/[slug]/changelog/page.tsx b/apps/web/app/dash/[slug]/changelog/page.tsx index e56db5e..d715ba5 100644 --- a/apps/web/app/dash/[slug]/changelog/page.tsx +++ b/apps/web/app/dash/[slug]/changelog/page.tsx @@ -1,67 +1,36 @@ +import { Button } from '@feedbase/ui/components/button'; +import { Separator } from '@feedbase/ui/components/separator'; import { Plus } from 'lucide-react'; -import { Button } from 'ui/components/ui/button'; -import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { Separator } from 'ui/components/ui/separator'; -import { getAllProjectChangelogs } from '@/lib/api/changelogs'; -import { ApiSheet } from '@/components/dashboard/changelogs/api-sheet'; -import ChangelogList from '@/components/dashboard/changelogs/changelog-list'; -import { AddChangelogModal } from '@/components/dashboard/modals/add-edit-changelog-modal'; - -export default async function Changelog({ params }: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(params.slug, 'server'); - - if (error) { - return {error.message}; - } +import { ApiSheet } from '@/components/changelog/api-sheet'; +import ChangelogList from '@/components/changelog/changelog-list'; +import { AddChangelogModal } from '@/components/modals/add-edit-changelog-modal'; +export default function Changelog({ params }: { params: { slug: string } }) { return ( - - {/* Content */} - - {/* Header Row */} - {changelogs.length > 0 && ( - - {/* Api Docs Button */} - + + {/* Header */} + + Changelogs - {/* Seperator Line */} - + + {/* Api Docs Button */} + - {/* Create new Button */} - - - New Changelog - - } - /> - - )} + {/* Seperator Line */} + - {/* Changelog List */} - {/* If there is no changelog, show empty text in the center */} - {changelogs.length === 0 && ( - - - No changelogs yet - - Once you create a changelog, it will show up here. - - - - Create first changelog} - /> - - - )} - - {/* If there is changelog, show changelog list */} - {changelogs.length > 0 && } + {/* Create new Button */} + + + + New Changelog + + + + + {/* Changelogs */} + ); } diff --git a/apps/web/app/dash/[slug]/feedback/loading.tsx b/apps/web/app/dash/[slug]/feedback/loading.tsx deleted file mode 100644 index ea9545c..0000000 --- a/apps/web/app/dash/[slug]/feedback/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - <> - - - > - ); -} diff --git a/apps/web/app/dash/[slug]/feedback/page.tsx b/apps/web/app/dash/[slug]/feedback/page.tsx index 700c37d..ea01880 100644 --- a/apps/web/app/dash/[slug]/feedback/page.tsx +++ b/apps/web/app/dash/[slug]/feedback/page.tsx @@ -1,46 +1,14 @@ -import { Card, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getAllFeedbackTags, getAllProjectFeedback } from '@/lib/api/feedback'; -import FeedbackTable from '@/components/dashboard/feedback/feedback-table'; -import FeedbackHeader from '@/components/dashboard/feedback/header-buttons'; - -export default async function Feedback({ - params, - searchParams, -}: { - params: { slug: string }; - searchParams: { tag: string }; -}) { - const { data: feedback, error } = await getAllProjectFeedback(params.slug, 'server'); - if (error) { - return {error.message}; - } - - const { data: tags, error: tagsError } = await getAllFeedbackTags(params.slug, 'server'); - if (tagsError) { - return {tagsError.message}; - } +import FeedbackHeader from '@/components/feedback/dashboard/feedback-header'; +import FeedbackList from '@/components/feedback/dashboard/feedback-list'; +export default async function Feedback() { return ( - - {/* Header Row */} - - - {/* Feedback Posts */} - {/* If there is no feedback, show empty text in the center */} - {feedback.length === 0 && ( - - - No feedback yet - - Once somone submits feedback, it will show up here. Make sure to share it so others can vote on - it! - - - - )} + + {/* Header */} + - {/* If there is feedback, show feedback list */} - {feedback.length > 0 && } + {/* Feedback List */} + ); } diff --git a/apps/web/app/dash/[slug]/layout.tsx b/apps/web/app/dash/[slug]/layout.tsx index aa90d86..eebd0d6 100644 --- a/apps/web/app/dash/[slug]/layout.tsx +++ b/apps/web/app/dash/[slug]/layout.tsx @@ -1,11 +1,11 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { getCurrentUser, getUserProjects } from '@/lib/api/user'; +import { getCurrentUser, getUserWorkspaces } from '@/lib/api/user'; import { DASH_DOMAIN } from '@/lib/constants'; -import InboxPopover from '@/components/layout/inbox-popover'; +import { SidebarTabsProps } from '@/lib/types'; +import DashboardHeader from '@/components/layout/header'; import NavbarMobile from '@/components/layout/nav-bar-mobile'; import Sidebar from '@/components/layout/sidebar'; -import TitleProvider from '@/components/layout/title-provider'; import { AnalyticsIcon, CalendarIcon, @@ -13,41 +13,45 @@ import { SettingsIcon, TagLabelIcon, } from '@/components/shared/icons/icons-animated'; -import { Icons } from '@/components/shared/icons/icons-static'; -import UserDropdown from '@/components/shared/user-dropdown'; -const tabs = [ - { - name: 'Changelog', - icon: TagLabelIcon, - slug: 'changelog', - }, - { - name: 'Feedback', - icon: FeedbackIcon, - slug: 'feedback', - }, - { - name: 'Roadmap (Soon)', - icon: CalendarIcon, - slug: 'roadmap', - }, - { - name: 'Analytics', - icon: AnalyticsIcon, - slug: 'analytics', - }, - { - name: 'Settings', - icon: SettingsIcon, - slug: 'settings', - }, -]; +const tabs: SidebarTabsProps = { + Modules: [ + { + name: 'Changelog', + icon: TagLabelIcon, + slug: 'changelog', + }, + { + name: 'Feedback', + icon: FeedbackIcon, + slug: 'feedback', + }, + { + name: 'Roadmap', + icon: CalendarIcon, + slug: 'roadmap', + }, + ], + Insights: [ + { + name: 'Analytics', + icon: AnalyticsIcon, + slug: 'analytics', + }, + ], + Workspace: [ + { + name: 'Settings', + icon: SettingsIcon, + slug: 'settings/general', + }, + ], +}; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { // Headers const headerList = headers(); - const projectSlug = headerList.get('x-project'); + const workspaceSlug = headerList.get('x-workspace'); const pathname = headerList.get('x-pathname'); // Fetch user @@ -57,67 +61,48 @@ export default async function DashboardLayout({ children }: { children: React.Re return redirect(`${DASH_DOMAIN}/login`); } - // Fetch the user's projects - const { data: projects } = await getUserProjects('server'); + // Fetch the user's workspaces + const { data: workspaces } = await getUserWorkspaces('server'); - // Check if the user has any projects - if (!projects || projects.length === 0) { + // Check if the user has any workspaces + if (!workspaces || workspaces.length === 0) { return redirect(`${DASH_DOMAIN}`); } - // Get the project with the current slug - const currentProject = projects.find((project) => project.slug === projectSlug); + // Get the workspace with the current slug + const currentWorkspace = workspaces.find((workspace) => workspace.slug === workspaceSlug); - // If currentProject is undefined, redirect to the first project - if (!currentProject) { - return redirect(`/${projects[0].slug}`); + // If currentWorkspace is undefined, redirect to the first workspace + if (!currentWorkspace) { + return redirect(`/${workspaces[0].slug}`); } // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.includes(tab.slug)); + const activeTab = Object.values(tabs) + .flatMap((tabArray) => tabArray) + .find((tab) => pathname?.includes(tab.slug)); return ( - - - {/* Header with logo and hub button */} - {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} - {/* https://github.com/radix-ui/primitives/discussions/1100 */} - - {/* Logo */} - - - - - - - - - {/* Sidebar */} - + + {/* Header with logo and hub button */} + {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} + {/* https://github.com/radix-ui/primitives/discussions/1100 */} + - {/* Main content */} - - - {children} - - + {/* Sidebar */} + - {/* Navbar (mobile) */} - + {/* Main content */} + + {children} + + {/* Navbar (mobile) */} + ); } diff --git a/apps/web/app/dash/[slug]/page.tsx b/apps/web/app/dash/[slug]/page.tsx index 1e344c3..4e3668f 100644 --- a/apps/web/app/dash/[slug]/page.tsx +++ b/apps/web/app/dash/[slug]/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -export default async function ProjectPage({ params }: { params: { slug: string } }) { +export default async function WorkspacePage({ params }: { params: { slug: string } }) { // Redirect to changelog redirect(`/${params.slug}/changelog`); } diff --git a/apps/web/app/dash/[slug]/roadmap/page.tsx b/apps/web/app/dash/[slug]/roadmap/page.tsx new file mode 100644 index 0000000..dc1a177 --- /dev/null +++ b/apps/web/app/dash/[slug]/roadmap/page.tsx @@ -0,0 +1,14 @@ +import RoadmapBoard from '@/components/roadmap/dashboard/roadmap-board'; +import RoadmapHeader from '@/components/roadmap/dashboard/roadmap-header'; + +export default async function Roadmap() { + return ( + + {/* Header */} + + + {/* Board */} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/api/page.tsx b/apps/web/app/dash/[slug]/settings/api/page.tsx new file mode 100644 index 0000000..a5c81c1 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/api/page.tsx @@ -0,0 +1,9 @@ +import SettingsCard from '@/components/settings/settings-card'; + +export default function ApiSettings() { + return ( + + s + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/billing/page.tsx b/apps/web/app/dash/[slug]/settings/billing/page.tsx new file mode 100644 index 0000000..c798162 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/billing/page.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { Label } from '@feedbase/ui/components/label'; +import { LucideExternalLink } from 'lucide-react'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function BillingSettings() { + return ( + + + + + Current plan: Starter + + + View plans + + + + + Change plan + Get in touch + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/changelog/page.tsx b/apps/web/app/dash/[slug]/settings/changelog/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/changelog/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/domain/page.tsx b/apps/web/app/dash/[slug]/settings/domain/page.tsx new file mode 100644 index 0000000..3ea7363 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/domain/page.tsx @@ -0,0 +1,599 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feedbase/ui/components/tabs'; +import { cn } from '@feedbase/ui/lib/utils'; +import { fontMono } from '@feedbase/ui/styles/fonts'; +import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { ChevronUpDownIcon } from '@heroicons/react/24/solid'; +import { Check, CheckIcon, ClipboardList, RefreshCcw, Trash2Icon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, fetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +interface domainData { + name: string; + apexName: string; + verified: boolean; + verification: { + type: string; + domain: string; + value: string; + reason: string; + }[]; +} + +export default function DomainSettings({ params }: { params: { slug: string } }) { + const [domain, setDomain] = useState(''); + const { workspace, loading, isValidating, mutate, error } = useWorkspace(); + const [domainStatus, setDomainStatus] = useState<'unset' | 'verifying' | 'verified'>('unset'); + const [hasCopied, setHasCopied] = useState([]); + const [redirectRule, setRedirectRule] = useState<'direct_redirect' | 'root_redirect' | 'no_redirect'>(); + + const { + data: domainData, + isValidating: domainIsValidating, + mutate: domainMutate, + } = useSWR(`/api/v1/workspaces/${params.slug}/domain`, fetcher, { + errorRetryInterval: 30000, + refreshInterval: 5000, + }); + + const { trigger: submitDomainVerification, isMutating: isSubmittingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('verifying'); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: removeDomain, isMutating: isRemovingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('unset'); + setDomain(''); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + toast.success('Redirect rule updated.'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + useEffect(() => { + if (workspace?.custom_domain) { + // Set domain if it is not set + if (!domain) { + setDomain(workspace.custom_domain); + } + + // Set domain status + if (workspace.custom_domain_verified || domainData?.verified) { + setDomainStatus('verified'); + } else { + setDomainStatus('verifying'); + } + } else { + setDomainStatus('unset'); + } + + setRedirectRule(workspace?.custom_domain_redirect); + }, [workspace, domainData]); + + function handleCopyToClipboard(value: string) { + navigator.clipboard.writeText(value); + setHasCopied((prevHasCopied) => [...prevHasCopied, value]); + + setTimeout(() => { + setHasCopied((prevHasCopied) => prevHasCopied.filter((item) => item !== value)); + }, 3000); + } + + // Loading State + if (loading) { + return ( + + + + ); + } + + // Error State + if (error) { + return ( + + + + + + ); + } + + return ( + + + Domain + + { + setDomain(event.target.value); + }} + /> + {domainStatus === 'unset' ? ( + { + submitDomainVerification({ name: domain }); + }} + disabled={isSubmittingDomain || !domain}> + {isSubmittingDomain ? : null} + Connect + + ) : ( + { + removeDomain({ method: 'DELETE' }); + }}> + {isRemovingDomain ? ( + + ) : ( + + )} + + )} + + + Want to host on a sub-path? Check out our{' '} + + documentation + + . + + + + + Redirect Feedbase Subdomain + + + + {redirectRule === 'direct_redirect' && 'Redirect direct path'} + {redirectRule === 'root_redirect' && 'Redirect to root'} + {redirectRule === 'no_redirect' && "Don't Redirect"} + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'direct_redirect' }); + setRedirectRule('direct_redirect'); + }}> + + Redirect direct path + + Redirects to equivalent path on your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'root_redirect' }); + setRedirectRule('root_redirect'); + }}> + + Redirect to root + + Redirects to the root of your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'no_redirect' }); + setRedirectRule('no_redirect'); + }}> + + Don't Redirect + + Does not redirect any requests to your custom domain. + + + + + + + + + + Wether to redirect the default {process.env.NEXT_PUBLIC_ROOT_DOMAIN} subdomain to your custom + domain. + + + + + {domainStatus === 'verifying' ? ( + <> + + + Verification Required + + + {/* Initial Loading state */} + {!domainData && ( + + + Fetching domain data... + + )} + + {domainData ? ( + + {/* Tabs, if domain is not assigned to a vercel workspace yet */} + {domainData.verification === undefined ? ( + + + + + A Record (Recommended) + + + CNAME Record + + + + {/* Refresh Status */} + { + domainMutate(); + }} + disabled={domainIsValidating} + className='text-secondary-foreground hover:text-foreground'> + {domainIsValidating ? ( + + ) : ( + + )} + + + + + + To verify your domain, add the following A Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + A + + + + Name + { + handleCopyToClipboard('@'); + }} + type='button'> + + @ + + + {hasCopied.includes('@') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + 76.76.21.21 + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + + To verify your domain, add the following CNAME Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + CNAME + + + + Name + { + handleCopyToClipboard('www'); + }} + type='button'> + + www + + + {hasCopied.includes('www') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + cname.vercel-dns.com + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + ) : null} + + {/* Verification Instructions for already vercel assigned domains */} + {domainData.verification !== undefined ? ( + + + To prove ownership of{' '} + + {domainData.apexName} + + , please add the following TXT Record to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + TXT + + + + Name + { + handleCopyToClipboard(domainData.verification[0].domain); + }} + type='button'> + + {domainData.verification[0].domain} + + + {hasCopied.includes(domainData.verification[0].domain) ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard(domainData.verification[0].value); + }} + type='button'> + + {domainData.verification[0].value} + + + {hasCopied.includes(domainData.verification[0].value) ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Warning: If{' '} + + {domainData.apexName} + {' '} + is already in use, the TXT Record will transfer away from the existing domain ownership + and break the site. Consider using a subdomain instead. + + + ) : null} + + ) : null} + > + ) : null} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/feedback/page.tsx b/apps/web/app/dash/[slug]/settings/feedback/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/feedback/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/general/loading.tsx b/apps/web/app/dash/[slug]/settings/general/loading.tsx deleted file mode 100644 index 5a31508..0000000 --- a/apps/web/app/dash/[slug]/settings/general/loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function GeneralLoading() { - return ( - // 3 cards with loading state - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/general/page.tsx b/apps/web/app/dash/[slug]/settings/general/page.tsx index 32c6754..cfbee47 100644 --- a/apps/web/app/dash/[slug]/settings/general/page.tsx +++ b/apps/web/app/dash/[slug]/settings/general/page.tsx @@ -1,28 +1,459 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import GeneralConfigCards from '@/components/dashboard/settings/general-cards'; +'use client'; -export default async function GeneralSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); +import { useEffect, useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { cn } from '@feedbase/ui/lib/utils'; +import { Check, ChevronsUpDownIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import useWorkspaceTheme from '@/lib/swr/use-workspace-theme'; +import { WorkspaceProps, WorkspaceThemeProps } from '@/lib/types'; +import { actionFetcher, areObjectsEqual } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import FileDrop from '@/components/shared/file-drop'; +import InputGroup from '@/components/shared/input-group'; - if (error) { - return {error.message}; - } +export default function GeneralSettings({ params }: { params: { slug: string } }) { + const { + workspace: workspaceData, + loading: workspaceLoading, + error: workspaceError, + mutate: workspaceMutate, + } = useWorkspace(); + const { + workspaceTheme: workspaceThemeData, + loading: workspaceThemeLoading, + error: workspaceThemeError, + mutate: workspaceThemeMutate, + } = useWorkspaceTheme(); + + const [workspace, setWorkspace] = useState(); + const [workspaceTheme, setWorkspaceTheme] = useState(); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + // Check if slug has changed + if (workspace?.slug !== workspaceData?.slug) { + // Redirect to new slug + window.location.href = `/${workspace?.slug}/settings/general`; + } + + workspaceMutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); - // Fetch project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - params.slug, - 'server' + const { trigger: updateWorkspaceTheme } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/theme`, + actionFetcher, + { + onError: (error) => { + toast.error(error.message); + }, + } ); - if (projectConfigError) { - return {projectConfigError.message}; + useEffect(() => { + setWorkspace(workspaceData); + setWorkspaceTheme(workspaceThemeData); + }, [workspaceData, workspaceThemeData]); + + if (workspaceError || workspaceThemeError) { + return ( + + ); } - return ( - - {/* General Card */} - - - ); + if (workspaceLoading || workspaceThemeLoading) { + return ( + <> + + + + + + + + + + + + > + ); + } + + if (workspace && workspaceTheme) { + return ( + <> + { + await updateWorkspace({ method: 'PATCH', ...workspace }); + }} + onCancel={() => { + setWorkspace(workspaceData); + }}> + + Name + { + setWorkspace({ + ...workspace, + name: e.target.value, + }); + }} + /> + This is the name of your workspace. + + + + Slug + + { + setWorkspace({ + ...workspace, + slug: e.target.value, + }); + }} + suffix={`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`} + placeholder='feedbase' + /> + + This is the subdomain of your workspace. + + + {/* Workspace Logo */} + + Logo + + {/* File Upload */} + + + + + {workspace.name[0]} + + + + + + Upload + + { + // Set the icon to the uploaded file + if (event.target.files?.[0]) { + // Check if the file is an image, image/png, image/jpeg, etc. + if (!['image/png', 'image/jpeg', 'image/jpg'].includes(event.target.files[0].type)) { + toast.error('Please upload a valid image.'); + return; + } + + // Read the file as a data URL + const reader = new FileReader(); + reader.onload = (e) => { + setWorkspace({ + ...workspace, + icon: e.target?.result as string, + }); + }; + reader.readAsDataURL(event.target.files[0]); + } + }} + /> + {workspace.icon ? ( + setWorkspace({ ...workspace, icon: '' })}> + Remove + + ) : null} + + Recommended size is 256x256. + + + + + + Radius + + + + {workspace.icon_radius === 'rounded-md' + ? 'Rounded' + : workspace.icon_radius === 'rounded-full' + ? 'Circle' + : 'Square'} + + + + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-md', + }); + }}> + Rounded + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-full', + }); + }}> + Circle + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-none', + }); + }}> + Square + + + + + This is the radius of your workspace. + + + + Redirect URL + { + setWorkspace({ + ...workspace, + icon_redirect_url: e.target.value, + }); + }} + placeholder='Leave blank to use default' + /> + + This is the redirect URL of your workspace. + + + + {/* OG Image */} + + OG Image} + image={workspace.opengraph_image} + setImage={(e: string | null) => { + setWorkspace({ + ...workspace, + opengraph_image: e, + }); + }} + className='h-40 w-80' + /> + + + The OG Image used when sharing your workspace. + + + + + + {/* Thanks to https://github.com/openstatushq for the inspiration of the theme previews */} + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'light' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'light' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + + Light + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'dark' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'dark' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + Dark + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'custom' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'custom' }); + }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + System + + + + + + + Delete this Workspace + + + + > + ); + } } diff --git a/apps/web/app/dash/[slug]/settings/hub/loading.tsx b/apps/web/app/dash/[slug]/settings/hub/loading.tsx index 8053295..f8a838e 100644 --- a/apps/web/app/dash/[slug]/settings/hub/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function HubLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/hub/page.tsx b/apps/web/app/dash/[slug]/settings/hub/page.tsx index 09506ef..0109277 100644 --- a/apps/web/app/dash/[slug]/settings/hub/page.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/page.tsx @@ -1,16 +1,18 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import HubConfigCards from '@/components/dashboard/settings/hub-cards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; + +// import HubConfigCards from '@/components/dashboard/settings/hub-cards'; export default async function HubSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); + // Fetch workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.slug, 'server'); if (error) { return {error.message}; } - // Fetch project config - const { data: projectConfig, error: configError } = await getProjectConfigBySlug(params.slug, 'server'); + // Fetch workspace config + const { data: workspaceConfig, error: configError } = await getWorkspaceModuleConfig(params.slug, 'server'); if (configError) { return {configError.message}; @@ -19,7 +21,7 @@ export default async function HubSettings({ params }: { params: { slug: string } return ( {/* Hub Card */} - + {/* */} ); } diff --git a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx index f1cbc8e..b50f052 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function IntegrationsLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/integrations/page.tsx b/apps/web/app/dash/[slug]/settings/integrations/page.tsx index 1bd1711..2b94c5f 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/page.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/page.tsx @@ -1,12 +1,81 @@ -import { getProjectConfigBySlug } from '@/lib/api/projects'; -import IntegrationCards from '@/components/dashboard/settings/integration-cards'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import FeedbackModal from '@/components/modals/send-feedback-modal'; +import SettingsCard from '@/components/settings/settings-card'; -export default async function IntegrationsSettings({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectConfigBySlug(params.slug, 'server'); +function IntegrationCard({ + title, + description, + image, + alt, + onClick, +}: { + title: string; + description: string; + image: string; + alt: string; + onClick?: () => void; +}) { + return ( + + {/* Avatar */} + + + {alt} + - if (error) { - return Error; - } + {/* Name and Description */} + + {title} - return ; + {description} + + + ); +} + +export default function IntegrationSettings({ params }: { params: { slug: string } }) { + return ( + + + + + + + + + + + More integrations coming soon! If you have a specific integration you would like to see,{' '} + + + let us know + + + ! + + + ); } diff --git a/apps/web/app/dash/[slug]/settings/layout.tsx b/apps/web/app/dash/[slug]/settings/layout.tsx index 85b549f..7b7f8f9 100644 --- a/apps/web/app/dash/[slug]/settings/layout.tsx +++ b/apps/web/app/dash/[slug]/settings/layout.tsx @@ -1,24 +1,84 @@ -import { headers } from 'next/headers'; -import CategoryTabs from '@/components/dashboard/settings/category-tabs'; +import { + Blocks, + Earth, + GalleryVerticalEnd, + KanbanSquare, + RefreshCcw, + SquareAsterisk, + SwatchBook, + Terminal, + UsersRoundIcon, + Wallet2, + Webhook, +} from 'lucide-react'; +import { SidebarTabsProps } from '@/lib/types'; +import Sidebar from '@/components/layout/sidebar'; -const tabs = [ - { - name: 'General', - slug: 'general', - }, - { - name: 'Hub', - slug: 'hub', - }, - { - name: 'Team', - slug: 'team', - }, - { - name: 'Integrations', - slug: 'integrations', - }, -]; +const tabs: SidebarTabsProps = { + Workspace: [ + { + name: 'Branding', + customIcon: , + slug: 'settings/general', + }, + { + name: 'Team Members', + customIcon: , + slug: 'settings/team', + }, + { + name: 'Billing', + customIcon: , + slug: 'settings/billing', + }, + ], + Public: [ + { + name: 'Domain', + customIcon: , + slug: 'settings/domain', + }, + { + name: 'SSO', + customIcon: , + slug: 'settings/sso', + }, + ], + Modules: [ + { + name: 'Feedback', + customIcon: , + slug: 'settings/feedback', + }, + { + name: 'Roadmap', + customIcon: , + slug: 'settings/roadmap', + }, + { + name: 'Changelog', + customIcon: , + slug: 'settings/changelog', + }, + ], + Additional: [ + { + name: 'API', + customIcon: , + slug: 'settings/api', + }, + { + name: 'Webhooks', + customIcon: , + slug: 'settings/webhooks', + }, + { + name: 'Integrations', + customIcon: , + slug: 'settings/integrations', + }, + ], +}; export default function SettingsLayout({ children, @@ -27,20 +87,21 @@ export default function SettingsLayout({ children: React.ReactNode; params: { slug: string }; }) { - // Headers - const headerList = headers(); - const pathname = headerList.get('x-pathname'); - - // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.split('/')[3] === tab.slug); - return ( - {/* Navigation tabs */} - + {/* Sidebar */} + {/* Content */} - {children} + + + {children} + + ); } diff --git a/apps/web/app/dash/[slug]/settings/roadmap/page.tsx b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx new file mode 100644 index 0000000..a37a7c4 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx @@ -0,0 +1,17 @@ +import { Label } from '@feedbase/ui/components/label'; +import { Switch } from '@feedbase/ui/components/switch'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Disable Roadmap + + + This will disable the roadmap for your workspace. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/sso/page.tsx b/apps/web/app/dash/[slug]/settings/sso/page.tsx new file mode 100644 index 0000000..ef4b2c9 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/sso/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@feedbase/ui/components/alert-dialog'; +import { Button } from '@feedbase/ui/components/button'; +import { Checkbox } from '@feedbase/ui/components/checkbox'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from '@feedbase/ui/components/responsive-dialog'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import CopyCheckIcon from '@/components/shared/copy-check-icon'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +export default function SSOSettings({ params }: { params: { slug: string } }) { + const [ssoConfig, setSsoConfig] = useState<{ + enabled: boolean; + url: string | null | undefined; + secret: string | null | undefined; + }>({ + enabled: false, + url: null, + secret: '', + }); + const [hasCopied, setHasCopied] = useState(); + const [open, setOpen] = useState(false); + const { workspace, loading, error, mutate } = useWorkspace(); + + const { trigger: updateWorkspaceSSO, isMutating: isUpdatingWorkspaceSSO } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso`, + actionFetcher, + { + onSuccess: () => { + mutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: generateJwtSecret, isMutating: isGenerating } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso/secret`, + actionFetcher, + { + onSuccess: (data: { secret: string }) => { + setSsoConfig({ ...ssoConfig, secret: data.secret }); + setOpen(true); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + useEffect(() => { + if (workspace) { + setSsoConfig({ + enabled: workspace.sso_auth_enabled, + url: workspace.sso_auth_url, + secret: workspace.sso_auth_secret_id ? 'dummy-secret' : undefined, + }); + } + }, [workspace]); + + // Loading state + if (loading && !error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + ); + } + + // Error state + if (error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + + + ); + } + + return ( + { + setSsoConfig({ ...ssoConfig, enabled: checked }); + toast.promise(updateWorkspaceSSO({ method: 'PATCH', enabled: checked }), { + loading: ssoConfig.enabled ? `Disabling Single Sign-On` : `Enabling Single Sign-On`, + success: ssoConfig.enabled ? `Single Sign-On disabled` : `Single Sign-On enabled`, + error: () => { + setSsoConfig({ ...ssoConfig, enabled: !checked }); + return `Failed to ${ssoConfig.enabled ? 'disable' : 'enable'} Single Sign-On`; + }, + }); + }} + description={ + <> + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + } + showSave={workspace?.sso_auth_url !== ssoConfig.url} + onCancel={() => { + setSsoConfig({ ...ssoConfig, url: workspace?.sso_auth_url }); + }} + onSave={async () => { + await updateWorkspaceSSO({ + method: 'PATCH', + enabled: ssoConfig.enabled, + url: ssoConfig.url, + secret: ssoConfig.secret, + }); + }}> + + Login Url + { + setSsoConfig({ ...ssoConfig, url: e.target.value }); + }} + /> + + The url where users will be redirected to login. + + + + + JWT Secret + + + + + + { + setHasCopied(false); + if (!ssoConfig.secret) { + e.preventDefault(); + await generateJwtSecret({}); + setOpen(true); + } + }} + disabled={isGenerating || isUpdatingWorkspaceSSO}> + {isGenerating ? : null} + {ssoConfig.secret ? 'Regenerate' : 'Generate'} + + + + + Are you absolutely sure? + + This action cannot be undone. This will generate a new secret for your Single Sign-On + configuration and invalidate the previous one. + + + + Cancel + { + await generateJwtSecret({}); + }}> + Continue + + + + + + + + Token Created + + This token can not be shown again. Please copy it and store it in a safe place. + + + + + Token + { + setHasCopied(true); + }} + /> + } + /> + + + + + { + setHasCopied(!hasCopied); + }} + id='copied' + /> + + I have copied this token + + + + { + toast.success('Token copied to clipboard'); + }}> + Done + + + + + + + + The secret used to sign the JWT token on your server. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/team/loading.tsx b/apps/web/app/dash/[slug]/settings/team/loading.tsx deleted file mode 100644 index 1753c58..0000000 --- a/apps/web/app/dash/[slug]/settings/team/loading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function TeamLoading() { - return ( - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/team/page.tsx b/apps/web/app/dash/[slug]/settings/team/page.tsx index 1343f64..eb627f2 100644 --- a/apps/web/app/dash/[slug]/settings/team/page.tsx +++ b/apps/web/app/dash/[slug]/settings/team/page.tsx @@ -1,32 +1,396 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getProjectInvites } from '@/lib/api/invites'; -import { getProjectMembers } from '@/lib/api/projects'; -import { TeamTable } from '@/components/dashboard/settings/team-table'; +'use client'; -export default async function TeamSettings({ params }: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(params.slug, 'server'); +import { useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuDestructiveItem, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { ChevronsUpDown, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useTeamInvites from '@/lib/swr/use-team-invites'; +import useTeamMembers from '@/lib/swr/use-team-members'; +import { ExtendedInviteProps, TeamMemberProps } from '@/lib/types'; +import { actionFetcher, formatTimeAgo } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; - if (error) { - return {error.message}; +export default function TeamSettings({ params }: { params: { slug: string } }) { + const { + teamMembers, + error: memberError, + mutate: mutateMember, + isValidating: isValidatingMember, + } = useTeamMembers(); + const { + teamInvites, + error: inviteError, + mutate: mutateInvite, + isValidating: inviteIsValidating, + } = useTeamInvites(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'all' | 'admins' | 'members' | 'invites'>('all'); + const [inviteEmail, setInviteEmail] = useState(''); + + // Filtered + const filteredMembers = teamMembers?.filter( + (member) => + member.full_name.toLowerCase().includes(search.toLowerCase()) || + member.email.toLowerCase().includes(search.toLowerCase()) + ); + const filteredInvites = teamInvites?.filter((invite) => + invite.email.toLowerCase().includes(search.toLowerCase()) + ); + + // Send invite + const { trigger: sendInvite, isMutating: isSendingInvite } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/invites`, + actionFetcher, + { + onSuccess: () => { + toast.success('Invite sent successfully.'); + // mutateMember(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + // Revoking invite + function revokeInvite(inviteId: string) { + const req = fetch(`/api/v1/workspaces/${params.slug}/invites/${inviteId}`, { + method: 'DELETE', + }); + + req.then(() => { + mutateInvite(); + }); + } + + // Error state + if (memberError || inviteError) { + return memberError ? ( + + ) : ( + + ); } - const { data: invites, error: invitesError } = await getProjectInvites(params.slug, 'server'); + // Loading state + if (!filteredMembers && isValidatingMember && !filteredInvites && inviteIsValidating) { + return ( + <> + + + Email + + + + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + + {filter === 'all' && 'members'} + {filter === 'admins' && 'admins'} + {filter === 'members' && 'members'} + {filter === 'invites' && 'pending invites'} + + - if (invitesError) { - return {invitesError.message}; + + + Loading team members... + + + > + ); } return ( - - - - Team - Add or remove users that have access to this project. - - - - - - + <> + + + Email + { + e.preventDefault(); + sendInvite({ email: inviteEmail }); + }}> + { + setInviteEmail(e.target.value); + }} + /> + + {isSendingInvite ? : null} + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + {/* Action Row */} + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + {/* Members */} + + + {filter === 'all' && (filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0)} + {/* TODO: Implement different roles, currently all are handled the same. */} + {filter === 'admins' && (filteredMembers?.length ?? 0)} + {filter === 'members' && (filteredMembers?.length ?? 0)} + {filter === 'invites' && (filteredInvites?.length ?? 0)}{' '} + {filter === 'all' && + ((filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'admins' && ((filteredMembers?.length ?? 0) > 1 ? 'admins' : 'admin')} + {filter === 'members' && ((filteredMembers?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'invites' && + ((filteredInvites?.length ?? 0) > 1 ? 'pending invites' : 'pending invite')} + + + + {/* Team Members */} + {(filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.map((member: TeamMemberProps) => ( + + + + + + {member.full_name[0]} + + + + {member.full_name} + {member.email} + + + + + {/* Joined days ago */} + + Joined {formatTimeAgo(new Date(member.joined_at))} ago + + + + + + + + + Make Admin + Suspend + + + + + ))} + + {/* Pending Invites */} + {(filter === 'all' || filter === 'invites') && + filteredInvites?.map((invite: ExtendedInviteProps) => ( + + + + + {invite.email[0]} + + + + {invite.email} + + Invited by {invite.creator.full_name} + + + + + + + Invited {formatTimeAgo(new Date(invite.created_at))} ago + + + + + + + + + + Resend + { + revokeInvite(invite.id); + }}> + Revoke + + + + + + ))} + + {/* Empty States */} + {((filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.length === 0 && + filteredInvites?.length === 0) || + (filter === 'invites' && filteredInvites?.length === 0) ? ( + + + {filter === 'all' || filter === 'admins' || filter === 'members' + ? 'No members found.' + : 'No pending invites found.'} + + + ) : null} + + + + + > ); } diff --git a/apps/web/app/dash/invite/[inviteId]/page.tsx b/apps/web/app/dash/invite/[inviteId]/page.tsx index 6b817f9..c8fa036 100644 --- a/apps/web/app/dash/invite/[inviteId]/page.tsx +++ b/apps/web/app/dash/invite/[inviteId]/page.tsx @@ -1,10 +1,10 @@ import { notFound } from 'next/navigation'; -import { getProjectInvite } from '@/lib/api/invites'; +import { getWorkspaceInvite } from '@/lib/api/invite'; import { getCurrentUser } from '@/lib/api/user'; -import ProjectInviteForm from '@/components/layout/accept-invite-form'; +import WorkspaceInviteForm from '@/components/workspace/accept-invite-form'; -export default async function ProjectInvite({ params }: { params: { inviteId: string } }) { - const { data: invite, error: inviteError } = await getProjectInvite(params.inviteId, 'server'); +export default async function WorkspaceInvite({ params }: { params: { inviteId: string } }) { + const { data: invite, error: inviteError } = await getWorkspaceInvite(params.inviteId, 'server'); if (inviteError) { return notFound(); @@ -15,7 +15,7 @@ export default async function ProjectInvite({ params }: { params: { inviteId: st return ( - + ); } diff --git a/apps/web/app/dash/page.tsx b/apps/web/app/dash/page.tsx index b9444a0..12760b8 100644 --- a/apps/web/app/dash/page.tsx +++ b/apps/web/app/dash/page.tsx @@ -1,9 +1,10 @@ import { redirect } from 'next/navigation'; -import { getUserProjects } from '@/lib/api/user'; -import Onboarding from '@/components/layout/onboarding'; +import { getUserWorkspaces } from '@/lib/api/user'; +import Onboarding from '@/components/workspace/onboarding'; +import WorkspaceOverview from '@/components/workspace/workspace-overview'; -export default async function Projects() { - const { data: projects, error } = await getUserProjects('server'); +export default async function Workspaces() { + const { data: workspaces, error } = await getUserWorkspaces('server'); if (error) { // Redirect to login if the user is not authenticated @@ -14,15 +15,10 @@ export default async function Projects() { return {error.message}; } - // Redirect to the first project - if (projects.length > 0) { - return redirect(`/${projects[0].slug}`); - } - - // TODO: Improve this and make this redirect to an onboarding page if the user has no projects + // TODO: Improve this and make this redirect to an onboarding page if the user has no workspaces return ( - + {workspaces && workspaces.length > 0 ? : } ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index cfce9bc..13871c1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,77 +1,127 @@ @tailwind base; @tailwind components; @tailwind utilities; - + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* + https://github.com/tailwindlabs/tailwindcss/discussions/2394 + https://github.com/tailwindlabs/tailwindcss/pull/5732 +*/ +@layer utilities { + /* Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .custom-scrollbar::-webkit-scrollbar { + width: 14px; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); + background-clip: padding-box; + border-radius: 50px; + background-color: hsl(var(--border) / 1); + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--foreground) / 0.15); + } +} + @layer base { :root { /* App root background */ - --root-background: 230 35% 3%; /* #000000 */ + --root-background: 0 0% 100%; + --background: 210 20% 98%; + --foreground: 224 71% 4%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 224 71% 4%; + --primary-foreground: 0 0% 98%; + --secondary: 240 5% 95%; + --secondary-foreground: 215 14% 34%; + --muted: 240 5% 96%; + --muted-foreground: 218 11% 65%; + --accent: 240 5% 94%; + --accent-foreground: 224 71% 4%; + --destructive: 0 79% 63%; + --destructive-foreground: 0 0% 100%; + --border: 220 13% 91%; + --input: 220 13% 91%; + --highlight: 231 100% 78%; + --ring: 216 12% 84%; + + /* Charts */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + --radius: 0.55rem; + } + + .dark { + /* App root background */ + --root-background: 235 7% 5%; /* Utility background (Buttons, inputs, etc) */ - --background: 230 11% 12%; /* #0A0C10 | #060606*/ - --foreground: 210 40% 98%; /* #D6D6D6 */ - + --background: 230 7% 16%; + --foreground: 220 9% 94%; + /* Muted background (Skeletons, Avatar, tabs, etc) */ - --muted: 230 11% 8%; /* #20242c */ - --muted-foreground: 215 6% 62%; - - --popover: 230 35% 3%; /* #0A0C10 */ - --popover-foreground: 210 40% 98%; - - --card: 230 35% 3%; /* #0A0C10 */ - --card-foreground: 210 40% 98%; - - --border: 222 9% 22%; /* #20242c */ - --input: 222 9% 22%; /* #20242c */ - - --primary: 210 40% 98%; - --primary-foreground: 230 35% 3%; - + --muted: 219 6% 12%; + --muted-foreground: 219 6% 65%; + + --popover: 235 7% 5%; + --popover-foreground: 220 9% 94%; + + --card: 235 7% 5%; + --card-foreground: 220 9% 94%; + + --border: 223 7% 19%; + --input: 223 6% 22%; + + --primary: 220 9% 94%; + --primary-foreground: 240 4% 10%; + /* Hover & active backgrounds (Nav buttons, etc) */ - --secondary: 227 11% 12%; /* #20242c */ - --secondary-foreground: 210 40% 98%; - + --secondary: 230 7% 16%; + --secondary-foreground: 218 7% 80%; + /* Button background hovers */ - --accent: 227 11% 15%; /* #20242c */ - --accent-foreground: 210 40% 98%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - + --accent: 230 7% 18%; + --accent-foreground: 220 9% 94%; + + --destructive: 0 79% 63%; + --destructive-foreground: 220 9% 94%; + /* Selection & accent color */ --highlight: 231 100% 78%; - - --ring: 230 9% 13%; /* #20242c */ - --radius: 0.5rem; - } - - .light { - /* App root background */ - --root-background: 0 0% 100%; /* #000000 */ - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --highlight: 231 100% 78%; - --ring: 240 5.9% 10%; + /* Charts */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + --ring: 230 9% 13%; } } - + @layer base { * { @apply border-border; @@ -79,4 +129,4 @@ body { @apply bg-root text-foreground selection:bg-highlight/20 selection:text-highlight; } -} \ No newline at end of file +} diff --git a/apps/web/app/home/(pages)/deploy/page.tsx b/apps/web/app/home/(pages)/deploy/page.tsx index b5f2ada..c98b5ee 100644 --- a/apps/web/app/home/(pages)/deploy/page.tsx +++ b/apps/web/app/home/(pages)/deploy/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Button } from '@feedbase/ui/components/button'; import HomeFooter from '@/components/home/footer'; export default function Deploy() { @@ -9,7 +9,7 @@ export default function Deploy() { Deploy Feedbase to Vercel - + You can deploy your own hosted instance of Feedbase to Vercel, for free, in just a few clicks. @@ -27,7 +27,7 @@ export default function Deploy() { Create a Supabase project - + Supabase is an open source Firebase alternative. It provides a database, auth, and storage. @@ -51,7 +51,7 @@ export default function Deploy() { Fork the Supabase database - + Once you have your Supabase project, you can fork the Feedbase database schemas to your own project. @@ -79,7 +79,7 @@ export default function Deploy() { Prepare your environment variables - + After setting up your Supabase project, check the example configuration to see where you can find the necessary environment variables. @@ -107,7 +107,7 @@ export default function Deploy() { Deploy to Vercel - + Once you have your environment variables, you can deploy to Vercel. diff --git a/apps/web/app/home/(pages)/page.tsx b/apps/web/app/home/(pages)/page.tsx index a58c6ad..15e2c4d 100644 --- a/apps/web/app/home/(pages)/page.tsx +++ b/apps/web/app/home/(pages)/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Background } from '@feedbase/ui/components/background/background'; +import { Button } from '@feedbase/ui/components/button'; import { ArrowRight, ChevronRight } from 'lucide-react'; -import { Background } from 'ui/components/ui/background/background'; import { formatRootUrl } from '@/lib/utils'; import ChangelogSection from '@/components/home/changelog-section'; import DashboardSection from '@/components/home/dashboard-section'; @@ -27,7 +27,7 @@ export default function Landing() { in One Central Place - + Feedbase simplifies feedback collection, feature prioritization, and product update sharing, allowing you to focus on building. @@ -39,7 +39,7 @@ export default function Landing() { Star on GitHub @@ -48,7 +48,7 @@ export default function Landing() { + className='text-foreground/60 hover:text-foreground/90 group relative mt-4 flex w-fit flex-row items-center justify-center p-1 text-center text-sm '> See it in action @@ -73,7 +73,7 @@ export default function Landing() { Create your feedback community today. - + Capture feedback, post updates, and engage with your users in one central place. diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8932134..274aa79 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,11 +1,12 @@ import './globals.css'; import { Metadata, Viewport } from 'next'; import Script from 'next/script'; -import { cn } from '@ui/lib/utils'; +import { cn } from '@feedbase/ui/lib/utils'; import { SpeedInsights } from '@vercel/speed-insights/next'; -import { GeistSans } from 'geist/font'; +import { GeistSans } from 'geist/font/sans'; import { Toaster } from 'sonner'; import { formatRootUrl } from '@/lib/utils'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; export const metadata: Metadata = { title: 'Feedbase', @@ -49,9 +50,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) )} - - {children} - + + + {children} + +
- All the latest updates, improvements, and fixes to {project.name}. + + + {/* Title, Description */} + + Changelog + + All the latest updates, improvements, and fixes to {workspace.name}.{' '} + - {/* Buttons */} - - {/* Email */} - - - Subscribe to Updates - - - - · - - {/* Twitter */} - {projectConfig.changelog_twitter_handle !== null && - projectConfig.changelog_twitter_handle !== '' && ( - - - Follow us on Twitter - - - · - - )} - - {/* RRS Update Feed */} - + {/* Email */} + + - Subscribe to Atom Feed - - + Subscribe to Updates + + + + · + + {/* Twitter */} + {workspaceConfig?.changelog_twitter_handle ? ( + + + Follow us on Twitter + + + · + + ) : null} + + {/* RRS Update Feed */} + + Subscribe to Atom Feed +
+ All the latest updates, improvements, and fixes to {workspace.name}.{' '}
- - {new Date(changelog.publish_date!).toLocaleDateString('en-US', { + + + {new Date(changelog.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', @@ -156,7 +162,7 @@ export default async function Changelogs({ params }: Props) { {/* Image */} {/* Summary */} - {projectConfig.changelog_preview_style === 'summary' && ( - + {workspaceConfig.changelog_preview_style === 'summary' && ( + {changelog.summary} )} - {projectConfig.changelog_preview_style === 'content' && ( + {workspaceConfig.changelog_preview_style === 'content' && ( )} @@ -198,8 +204,8 @@ export default async function Changelogs({ params }: Props) { {/* Empty State */} {changelogs.length === 0 && ( - No changelogs yet - + No changelogs yet + The latest updates, improvements, and fixes will be posted here. Stay tuned! diff --git a/apps/web/app/[project]/changelog/unsubscribe/page.tsx b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx similarity index 54% rename from apps/web/app/[project]/changelog/unsubscribe/page.tsx rename to apps/web/app/[workspace]/changelog/unsubscribe/page.tsx index a6af8c2..e6d305c 100644 --- a/apps/web/app/[project]/changelog/unsubscribe/page.tsx +++ b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx @@ -1,12 +1,12 @@ import { notFound, redirect } from 'next/navigation'; -import { getProjectBySlug } from '@/lib/api/projects'; -import UnsubscribeChangelogCard from '@/components/layout/unsubscribe-card'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import UnsubscribeChangelogCard from '@/components/changelog/unsubscribe-card'; export default async function ChangelogUnsubscribe({ params, searchParams, }: { - params: { project: string }; + params: { workspace: string }; searchParams: { subId: string }; }) { if (!searchParams.subId) { @@ -20,17 +20,17 @@ export default async function ChangelogUnsubscribe({ redirect('/'); } - // Get project - const { data: project, error } = await getProjectBySlug(params.project, 'server', true, false); + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); - // If project is undefined redirects to 404 - if (error?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { notFound(); } return ( - + ); } diff --git a/apps/web/app/[project]/feedback/[id]/page.tsx b/apps/web/app/[workspace]/feedback/[id]/page.tsx similarity index 70% rename from apps/web/app/[project]/feedback/[id]/page.tsx rename to apps/web/app/[workspace]/feedback/[id]/page.tsx index 54b8bd6..0a3d013 100644 --- a/apps/web/app/[project]/feedback/[id]/page.tsx +++ b/apps/web/app/[workspace]/feedback/[id]/page.tsx @@ -1,27 +1,31 @@ import { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { Avatar, AvatarFallback, AvatarImage } from '@ui/components/ui/avatar'; -import { Separator } from '@ui/components/ui/separator'; -import { cn } from '@ui/lib/utils'; -import { BadgeCheck, CheckCircle2, CircleDashed, CircleDot, CircleDotDashed, XCircle } from 'lucide-react'; -import { getCommentsForFeedbackById } from '@/lib/api/comments'; -import { getPublicProjectFeedback } from '@/lib/api/public'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Separator } from '@feedbase/ui/components/separator'; +import { cn } from '@feedbase/ui/lib/utils'; +import { BadgeCheck } from 'lucide-react'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; import { getCurrentUser } from '@/lib/api/user'; -import { PROSE_CN } from '@/lib/constants'; -import AnalyticsWrapper from '@/components/hub/analytics-wrapper'; -import CommentsList from '@/components/hub/feedback/comments/comments-list'; +import { PROSE_CN, STATUS_OPTIONS } from '@/lib/constants'; +import CommentsList from '@/components/feedback/hub/comments-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; type Props = { - params: { project: string; id: string }; + params: { workspace: string; id: string }; }; // Metadata export async function generateMetadata({ params }: Props): Promise { // Get feedback - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); - // If project is undefined redirects to 404 + // If workspace is undefined redirects to 404 if (error?.status === 404 || !feedbackList) { notFound(); } @@ -36,36 +40,17 @@ export async function generateMetadata({ params }: Props): Promise { return { title: feedback.title, - description: feedback.description, + description: feedback.content, }; } -// Status options -const statusOptions = [ - { - label: 'Backlog', - icon: CircleDashed, - }, - { - label: 'Planned', - icon: CircleDotDashed, - }, - { - label: 'In Progress', - icon: CircleDot, - }, - { - label: 'Completed', - icon: CheckCircle2, - }, - { - label: 'Rejected', - icon: XCircle, - }, -]; - export default async function FeedbackDetails({ params }: Props) { - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); if (error || !feedbackList) { return {error.message}; @@ -79,31 +64,19 @@ export default async function FeedbackDetails({ params }: Props) { notFound(); } - // Get comments - const { data: comments, error: commentsError } = await getCommentsForFeedbackById( - params.id, - params.project, - 'server', - false - ); - - if (commentsError) { - return {commentsError.message}; - } - // Get current user const { data: user } = await getCurrentUser('server'); return ( - + {/* // Row Splitting up date and Content */} {/* Back Button */} - - + + ← Back to Posts @@ -117,8 +90,8 @@ export default async function FeedbackDetails({ params }: Props) { {feedback.title} @@ -128,38 +101,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - - {currentStatus.label} - + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -171,7 +142,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -181,7 +152,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -193,9 +164,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -206,17 +177,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -226,19 +195,14 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} {/* Comments */} - + @@ -248,36 +212,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - {currentStatus.label} + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -289,7 +253,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -299,7 +263,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -311,9 +275,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -324,17 +288,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -345,7 +307,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} diff --git a/apps/web/app/[project]/page.tsx b/apps/web/app/[workspace]/feedback/page.tsx similarity index 73% rename from apps/web/app/[project]/page.tsx rename to apps/web/app/[workspace]/feedback/page.tsx index c6f6f88..2c59ed8 100644 --- a/apps/web/app/[project]/page.tsx +++ b/apps/web/app/[workspace]/feedback/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; -export default async function Hub() { +export default async function Feedback() { // Redirect to changelog // This page doesn't really get called as this is catched in layout.tsx but still needed to cause 404 - redirect(`/feedback`); + redirect(`/`); } diff --git a/apps/web/app/[workspace]/layout.tsx b/apps/web/app/[workspace]/layout.tsx new file mode 100644 index 0000000..da4072e --- /dev/null +++ b/apps/web/app/[workspace]/layout.tsx @@ -0,0 +1,160 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceTheme } from '@/lib/api/theme'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Header from '@/components/layout/nav-bar'; +import CustomThemeWrapper from '@/components/layout/theme-wrapper'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; + +type Props = { + children: React.ReactNode; + params: { workspace: string }; + searchParams: Record; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: workspace.name, + description: `Discover the latest updates, roadmaps, submit feedback, and explore more about ${workspace.name}.`, + icons: workspace.icon, + openGraph: { + images: [ + { + url: workspace.opengraph_image || '', + width: 1200, + height: 600, + alt: workspace.name, + }, + ], + }, + }; +} + +const tabs: { name: string; link: string; items?: { name: string; link: string }[] }[] = [ + { + name: 'Boards', + link: '/', + items: [], + }, + { + name: 'Roadmap', + link: '/roadmap', + }, + { + name: 'Changelog', + link: '/changelog', + }, +]; + +export default async function HubLayout({ children, params, searchParams }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + const hostname = headerList.get('host'); + + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + if (error?.status === 404 || !workspace) { + notFound(); + } + + // Get workspace boards + const { data: boards, error: boardsError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardsError || !boards) { + notFound(); + } + + // Set workspace boards to tabs + tabs[0].items = boards.map((board) => ({ + name: board.name, + link: `/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`, + })); + + // Get workspace config + const { data: config } = await getWorkspaceModuleConfig(params.workspace, 'server', true, false); + + // Get workspace theme + const { data: workspaceTheme } = await getWorkspaceTheme(params.workspace, 'server', true, false); + + if (!config || !workspaceTheme) { + notFound(); + } + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Check if custom domain is set and redirect to it + if (workspace.custom_domain && workspace.custom_domain_verified && hostname !== workspace.custom_domain) { + // Validate redirect rules + switch (workspace.custom_domain_redirect) { + case 'root_redirect': + return redirect(`https://${workspace.custom_domain}`); + case 'direct_redirect': + return redirect(`https://${workspace.custom_domain}${pathname}`); + case 'no_redirect': + break; + } + } + + // Get current tab + const currentTab = tabs.find((tab) => { + if (tab.link === pathname) { + return true; + } + if (tab.items) { + const subItem = tab.items.find((item) => item.link === pathname); + if (subItem) { + // Return the subitem directly + return subItem; + } + } + return false; + }); + + // Extract subItem if found, otherwise use the main tab + const foundItem = currentTab?.items?.find((item) => item.link === pathname) || currentTab; + + // Check if any modules are disabled and remove them from the tabs + if (!config.changelog_enabled) { + tabs.splice(1, 1); + } + + return ( + // + + {/* Header */} + + + {/* Main content */} + + {children} + + + ); +} diff --git a/apps/web/app/[workspace]/page.tsx b/apps/web/app/[workspace]/page.tsx new file mode 100644 index 0000000..ff32044 --- /dev/null +++ b/apps/web/app/[workspace]/page.tsx @@ -0,0 +1,98 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import FeedbackBoardList from '@/components/feedback/hub/board-list'; +import FeedbackHeader from '@/components/feedback/hub/button-header'; +import FeedbackList from '@/components/feedback/hub/feedback-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Feedback - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Fetch feedback boards + const { data: boards, error: boardError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardError) { + return {boardError.message}; + } + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + // Search for the initial board mathing the pathname by /board/board-name format + const initialBoard = boards.find( + (board) => pathname?.includes(`/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`) + ); + + return ( + + {/* Title, Description */} + + + Feedback + + Have a suggestion or found a bug? Let us know! + + + + + {/* Seperator */} + + + + {/* Filter Header, Feedback List */} + + {/* Filter Header */} + + {/* Feedback List */} + + + {/* Boards */} + + + + ); +} diff --git a/apps/web/app/[workspace]/roadmap/page.tsx b/apps/web/app/[workspace]/roadmap/page.tsx new file mode 100644 index 0000000..3648c14 --- /dev/null +++ b/apps/web/app/[workspace]/roadmap/page.tsx @@ -0,0 +1,69 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Roadmap from '@/components/roadmap/hub/roadmap'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Roadmap - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + return ( + + {/* Title, Description */} + + + Roadmap + + View what we're working on and what's coming next. + + + + + {/* Seperator */} + + + {/* Roadmap */} + + + ); +} diff --git a/apps/web/app/api/trigger/route.ts b/apps/web/app/api/trigger/route.ts new file mode 100644 index 0000000..0b08117 --- /dev/null +++ b/apps/web/app/api/trigger/route.ts @@ -0,0 +1,9 @@ +import { client } from '@feedbase/triggers'; +import { createAppRoute } from '@trigger.dev/nextjs'; +import '@feedbase/triggers/jobs'; + +//this route is used to send and receive data with Trigger.dev +export const { POST, dynamic } = createAppRoute(client); + +//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration +export const maxDuration = 60; diff --git a/apps/web/app/api/v1/[slug]/atom/route.ts b/apps/web/app/api/v1/[slug]/atom/route.ts index 1218c5b..198eaeb 100644 --- a/apps/web/app/api/v1/[slug]/atom/route.ts +++ b/apps/web/app/api/v1/[slug]/atom/route.ts @@ -1,20 +1,20 @@ import { NextResponse } from 'next/server'; -import { getProjectBySlug } from '@/lib/api/projects'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; /* - Generate atom feed for project changelog + Generate atom feed for workspace changelog */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project data - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route', true, false); + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - const { data: changelogs, error: changelogError } = await getPublicProjectChangelogs( + const { data: changelogs, error: changelogError } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, @@ -30,12 +30,12 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return new Response( ` - ${project.name} Changelog - ${project.name}'s Changelog + ${workspace.name} Changelog + ${workspace.name}'s Changelog ${changelogs[0].publish_date} - ${project.id}${changelogs + ${workspace.id}${changelogs .map((post) => { return ` diff --git a/apps/web/app/api/v1/[slug]/changelogs/route.ts b/apps/web/app/api/v1/[slug]/changelogs/route.ts index 8650092..1f46ed6 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getPublicProjectChangelogs( + const { data: changelogs, error } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, diff --git a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts index f225b75..0f96984 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import { subscribeToProjectChangelogs, unsubscribeFromProjectChangelogs } from '@/lib/api/public'; +import { subscribeToWorkspaceChangelogs, unsubscribeFromWorkspaceChangelogs } from '@/lib/api/public'; /* - Subscribe to project changelogs + Subscribe to workspace changelogs POST /api/v1/:slug/changelogs/subscribers { email: string, @@ -16,8 +16,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'email is required.' }, { status: 400 }); } - // Subscribe to project changelogs - const { data: subscriber, error } = await subscribeToProjectChangelogs(context.params.slug, email); + // Subscribe to workspace changelogs + const { data: subscriber, error } = await subscribeToWorkspaceChangelogs(context.params.slug, email); // If any errors thrown, return error if (error) { @@ -29,7 +29,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Unsubscribe from project changelogs + Unsubscribe from workspace changelogs DELETE /api/v1/:slug/changelogs/subscribers { subId: string, @@ -43,8 +43,8 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: 'subId is required.' }, { status: 400 }); } - // Unsubscribe from project changelogs - const { error } = await unsubscribeFromProjectChangelogs(context.params.slug, subId); + // Unsubscribe from workspace changelogs + const { error } = await unsubscribeFromWorkspaceChangelogs(context.params.slug, subId); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/[slug]/feedback/route.ts b/apps/web/app/api/v1/[slug]/feedback/route.ts new file mode 100644 index 0000000..51e8479 --- /dev/null +++ b/apps/web/app/api/v1/[slug]/feedback/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; + +/* + Get public feedback + GET /api/v1/[slug]/feedback + */ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: feedback, error } = await getPublicWorkspaceFeedback( + context.params.slug, + 'route', + true, + false + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/[slug]/sso/route.ts b/apps/web/app/api/v1/[slug]/sso/route.ts index 889291e..92ead6c 100644 --- a/apps/web/app/api/v1/[slug]/sso/route.ts +++ b/apps/web/app/api/v1/[slug]/sso/route.ts @@ -2,7 +2,7 @@ import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@supabase/supabase-js'; import { JwtPayload, verify } from 'jsonwebtoken'; -import { getProjectBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; export async function GET(req: NextRequest, context: { params: { slug: string } }) { const redirectTo = req.nextUrl.searchParams.get('redirect_to'); @@ -18,33 +18,33 @@ export async function GET(req: NextRequest, context: { params: { slug: string } const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY); - // Get project by slug - const { data: project, error: projectError } = await getProjectBySlug( + // Get workspace by slug + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug( context.params.slug, 'route', true, false ); - if (projectError) { - return NextResponse.json(projectError, { status: 500 }); + if (workspaceError) { + return NextResponse.json(workspaceError, { status: 500 }); } - // Get projects jwt secret - const { data: projectConfig, error: projectConfigError } = await supabase - .from('project_configs') + // Get workspaces jwt secret + const { data: workspaceConfig, error: workspaceConfigError } = await supabase + .from('workspace_configs') .select('integration_sso_secret') - .eq('project_id', project.id) + .eq('workspace_id', workspace.id) .single(); - if (projectConfigError) { - return NextResponse.json(projectConfigError.message, { status: 500 }); + if (workspaceConfigError) { + return NextResponse.json(workspaceConfigError.message, { status: 500 }); } // Verify jwt let payload: JwtPayload; try { - const decoded = verify(jwtPayload, projectConfig?.integration_sso_secret as string); + const decoded = verify(jwtPayload, workspaceConfig?.integration_sso_secret as string); payload = decoded as JwtPayload; } catch (error) { if (error instanceof Error) { @@ -59,7 +59,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid payload' }, { status: 500 }); } - const email = (payload.email as string).replace('@', `+${project.id}@`); + const email = (payload.email as string).replace('@', `+${workspace.id}@`); // Create user based on jwt payload const { error } = await supabase.auth.admin.createUser({ diff --git a/apps/web/app/api/v1/[slug]/views/route.ts b/apps/web/app/api/v1/[slug]/views/route.ts index 9b038e7..ec0681e 100644 --- a/apps/web/app/api/v1/[slug]/views/route.ts +++ b/apps/web/app/api/v1/[slug]/views/route.ts @@ -3,7 +3,7 @@ import { recordClick } from '@/lib/tinybird'; /* Record page view - POST /api/v1/[project]/views + POST /api/v1/[workspace]/views { "feedbackId": "string", "changelogId": "string", @@ -19,7 +19,7 @@ export async function POST(req: NextRequest, context: { params: { slug: string } const data = await recordClick({ req, - projectId: context.params.slug, + workspaceId: context.params.slug, feedbackId, changelogId, }); diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts b/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts deleted file mode 100644 index 72a5472..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; - -/* - Update SSO configuration - PATCH /api/v1/projects/:slug/config/integrations/sso - { - status: boolean, - url: string, - secret: string, - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, url, secret } = await req.json(); - - if (status && (!url || !secret)) { - return NextResponse.json( - { error: 'url and secret are required when enabling SSO integration.' }, - { status: 400 } - ); - } - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - integration_sso_status: status, - integration_sso_url: status ? url : null, - integration_sso_secret: status ? secret : null, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/config/route.ts b/apps/web/app/api/v1/projects/[slug]/config/route.ts deleted file mode 100644 index 938fdfb..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; -import { ProjectConfigProps } from '@/lib/types'; - -/* - Get Project Config by slug - GET /api/v1/projects/[slug]/config -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: projectConfig, error } = await getProjectConfigBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project config - return NextResponse.json(projectConfig, { status: 200 }); -} - -/* - Update Project Config by slug - PATCH /api/v1/projects/[slug]/config - { - changelog_preview_style: string; - changelog_twitter_handle: string; - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - } = (await req.json()) as ProjectConfigProps['Update']; - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts b/apps/web/app/api/v1/projects/[slug]/feedback/route.ts deleted file mode 100644 index 7c24e3d..0000000 --- a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createFeedback, getAllProjectFeedback } from '@/lib/api/feedback'; -import { FeedbackWithUserInputProps } from '@/lib/types'; - -export const runtime = 'edge'; - -/* - Create Feedback - POST /api/v1/projects/[slug]/feedback - { - title: string; - description: string; - status: string; - tags: [id, id, id] - } -*/ -export async function POST(req: Request, context: { params: { slug: string } }) { - const { title, description, status, tags, user } = (await req.json()) as FeedbackWithUserInputProps; - - // Validate Request Body - if (!title) { - return NextResponse.json({ error: 'title is required when creating feedback.' }, { status: 400 }); - } - - const { data: feedback, error } = await createFeedback( - context.params.slug, - { - title: title || '', - description: description || '', - status: status || '', - project_id: 'dummy-id', - user_id: 'dummy-id', - tags: tags || [], - user: user !== null ? user : undefined, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} - -/* - Get Project Feedback - GET /api/v1/projects/[slug]/feedback -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route', false); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/route.ts b/apps/web/app/api/v1/projects/[slug]/route.ts deleted file mode 100644 index ddfa07b..0000000 --- a/apps/web/app/api/v1/projects/[slug]/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextResponse } from 'next/server'; -import { deleteProjectBySlug, getProjectBySlug, updateProjectBySlug } from '@/lib/api/projects'; -import { ProjectProps } from '@/lib/types'; - -/* - Get project by slug - GET /api/v1/projects/[slug] -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route'); - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project - return NextResponse.json({ project }, { status: 200 }); -} - -/* - Update project by slug - PATCH /api/v1/projects/[slug] - { - name: string, - slug: string, - icon: string, - icon_radius: string, - og_image: string - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - name, - slug, - icon, - icon_radius: iconRadius, - og_image: OGImage, - } = (await req.json()) as ProjectProps['Update']; - - const { data: updatedProject, error } = await updateProjectBySlug( - context.params.slug, - { name, slug, icon, icon_radius: iconRadius, og_image: OGImage }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project - return NextResponse.json(updatedProject, { status: 200 }); -} - -/* - Delete project by slug - DELETE /api/v1/projects/[slug] -*/ -export async function DELETE(req: Request, context: { params: { slug: string } }) { - const { data, error } = await deleteProjectBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return success - return NextResponse.json({ data }, { status: 200 }); -} diff --git a/apps/web/app/api/v1/route.ts b/apps/web/app/api/v1/route.ts index 56224ea..eb89aaf 100644 --- a/apps/web/app/api/v1/route.ts +++ b/apps/web/app/api/v1/route.ts @@ -23,16 +23,16 @@ export function GET(): NextResponse { }, ], paths: { - '/{projectSlug}/atom': { + '/{workspaceSlug}/atom': { get: { - description: 'Generate atom feed for project changelog', - operationId: 'getProjectChangelogsAtom', + description: 'Generate atom feed for workspace changelog', + operationId: 'getWorkspaceChangelogsAtom', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -51,7 +51,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -61,7 +61,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -73,16 +73,16 @@ export function GET(): NextResponse { }, }, }, - '/{projectSlug}/changelogs': { + '/{workspaceSlug}/changelogs': { get: { - description: 'Get public project changelogs', - operationId: 'getPublicProjectChangelogs', + description: 'Get public workspace changelogs', + operationId: 'getPublicWorkspaceChangelogs', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -104,7 +104,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -114,7 +114,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -126,15 +126,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}': { + '/workspaces/{workspaceSlug}': { get: { - description: 'Get a project by slug', - operationId: 'getProjectBySlug', + description: 'Get a workspace by slug', + operationId: 'getWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -147,13 +147,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -163,7 +163,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -175,13 +175,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update a project by slug', - operationId: 'updateProjectBySlug', + description: 'Update a workspace by slug', + operationId: 'updateWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -189,12 +189,12 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project object that needs to be updated', + description: 'Workspace object that needs to be updated', required: true, content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectUpdate', + $ref: '#/components/schemas/WorkspaceUpdate', }, }, }, @@ -205,13 +205,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -221,7 +221,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -233,13 +233,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete a project by slug', - operationId: 'deleteProjectBySlug', + description: 'Delete a workspace by slug', + operationId: 'deleteWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -252,13 +252,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -268,7 +268,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -280,15 +280,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs': { + '/workspaces/{workspaceSlug}/changelogs': { get: { - description: 'Get all project changelogs', - operationId: 'getProjectChangelogs', + description: 'Get all workspace changelogs', + operationId: 'getWorkspaceChangelogs', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -310,7 +310,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -320,7 +320,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -332,13 +332,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project changelog', - operationId: 'createProjectChangelog', + description: 'Create workspace changelog', + operationId: 'createWorkspaceChangelog', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -368,7 +368,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -378,7 +378,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -390,15 +390,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs/{changelogId}': { + '/workspaces/{workspaceSlug}/changelogs/{changelogId}': { put: { - description: 'Update project changelog by id', - operationId: 'updateProjectChangelogById', + description: 'Update workspace changelog by id', + operationId: 'updateWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -437,21 +437,21 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, delete: { - description: 'Delete project changelog by id', - operationId: 'deleteProjectChangelogById', + description: 'Delete workspace changelog by id', + operationId: 'deleteWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -479,23 +479,23 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, }, - '/projects/{projectSlug}/config': { + '/workspaces/{workspaceSlug}/config': { get: { - description: 'Get project config', - operationId: 'getProjectConfig', + description: 'Get workspace config', + operationId: 'getWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -508,13 +508,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -524,7 +524,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -536,13 +536,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project config', - operationId: 'updateProjectConfig', + description: 'Update workspace config', + operationId: 'updateWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -550,13 +550,13 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project config object that needs to be updated', + description: 'Workspace config object that needs to be updated', required: true, content: { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfigUpdate', + $ref: '#/components/schemas/WorkspaceConfigUpdate', }, }, }, @@ -568,13 +568,13 @@ export function GET(): NextResponse { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -584,7 +584,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -596,15 +596,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback': { + '/workspaces/{workspaceSlug}/feedback': { get: { - description: 'Get all project feedback', - operationId: 'getProjectFeedback', + description: 'Get all workspace feedback', + operationId: 'getWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -626,7 +626,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -636,7 +636,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -648,13 +648,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project feedback', - operationId: 'createProjectFeedback', + description: 'Create workspace feedback', + operationId: 'createWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -684,7 +684,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -694,7 +694,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -706,15 +706,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}': { get: { - description: 'Get project feedback by id', - operationId: 'getProjectFeedbackById', + description: 'Get workspace feedback by id', + operationId: 'getWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -742,7 +742,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -764,13 +764,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project feedback by id', - operationId: 'updateProjectFeedbackById', + description: 'Update workspace feedback by id', + operationId: 'updateWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -809,7 +809,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -831,13 +831,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete project feedback by id', - operationId: 'deleteProjectFeedbackById', + description: 'Delete workspace feedback by id', + operationId: 'deleteWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -865,7 +865,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -887,15 +887,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments': { get: { description: 'Get feedback comments', operationId: 'getFeedbackComments', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -926,7 +926,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -936,7 +936,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -948,15 +948,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments/{commentId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments/{commentId}': { delete: { - description: 'Delete project feedback comment by id', - operationId: 'deleteProjectFeedbackCommentById', + description: 'Delete workspace feedback comment by id', + operationId: 'deleteWorkspaceFeedbackCommentById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -993,7 +993,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1003,7 +1003,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -1015,15 +1015,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/upvotes': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/upvotes': { get: { description: 'Get feedback upvoters', operationId: 'getFeedbackUpvoters', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1054,7 +1054,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1076,15 +1076,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags': { + '/workspaces/{workspaceSlug}/feedback/tags': { get: { description: 'Get feedback tags', operationId: 'getFeedbackTags', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1106,7 +1106,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1116,7 +1116,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1132,9 +1132,9 @@ export function GET(): NextResponse { operationId: 'createFeedbackTag', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1164,7 +1164,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1174,7 +1174,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1186,15 +1186,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags/{tagName}': { + '/workspaces/{workspaceSlug}/feedback/tags/{tagName}': { delete: { description: 'Delete feedback tag by name', operationId: 'deleteFeedbackTagByName', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1222,7 +1222,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or tag name supplied', + description: 'Invalid workspace slug or tag name supplied', content: { 'application/json': { schema: { @@ -1232,7 +1232,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or tag not found', + description: 'Workspace or tag not found', content: { 'application/json': { schema: { @@ -1244,15 +1244,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites': { + '/workspaces/{workspaceSlug}/invites': { get: { - description: 'List project team invites', - operationId: 'getProjectInvites', + description: 'List workspace team invites', + operationId: 'getWorkspaceInvites', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1267,14 +1267,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ExtendedProjectInvite', + $ref: '#/components/schemas/ExtendedWorkspaceInvite', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1284,7 +1284,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1296,13 +1296,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create a project invite', - operationId: 'createProjectInvite', + description: 'Create a workspace invite', + operationId: 'createWorkspaceInvite', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1310,7 +1310,7 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project invite object that needs to be created', + description: 'Workspace invite object that needs to be created', required: true, content: { 'application/json': { @@ -1331,13 +1331,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1347,7 +1347,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1359,15 +1359,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites/{inviteId}': { + '/workspaces/{workspaceSlug}/invites/{inviteId}': { delete: { - description: 'Delete project invite by id', - operationId: 'deleteProjectInviteById', + description: 'Delete workspace invite by id', + operationId: 'deleteWorkspaceInviteById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1389,13 +1389,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1405,7 +1405,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1417,15 +1417,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/members': { + '/workspaces/{workspaceSlug}/members': { get: { - description: 'Get project members', - operationId: 'getProjectMembers', + description: 'Get workspace members', + operationId: 'getWorkspaceMembers', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1441,14 +1441,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ProjectMember', + $ref: '#/components/schemas/WorkspaceMember', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1458,7 +1458,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1473,7 +1473,7 @@ export function GET(): NextResponse { }, components: { schemas: { - Project: { + Workspace: { type: 'object', properties: { id: { @@ -1501,7 +1501,7 @@ export function GET(): NextResponse { }, }, }, - ProjectUpdate: { + WorkspaceUpdate: { type: 'object', properties: { name: { @@ -1528,7 +1528,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1567,7 +1567,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1667,14 +1667,14 @@ export function GET(): NextResponse { }, }, }, - ProjectConfig: { + WorkspaceConfig: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1705,7 +1705,7 @@ export function GET(): NextResponse { }, }, }, - ProjectConfigUpdate: { + WorkspaceConfigUpdate: { type: 'object', properties: { changelog_preview_style: { @@ -1723,7 +1723,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1759,7 +1759,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1998,7 +1998,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2025,14 +2025,14 @@ export function GET(): NextResponse { }, }, }, - ProjectInvite: { + WorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2052,18 +2052,18 @@ export function GET(): NextResponse { }, }, }, - ExtendedProjectInvite: { + ExtendedWorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, - project: { + workspace: { type: 'object', properties: { name: { @@ -2101,7 +2101,7 @@ export function GET(): NextResponse { }, }, }, - ProjectMember: { + WorkspaceMember: { type: 'object', properties: { id: { diff --git a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts similarity index 71% rename from apps/web/app/api/v1/projects/[slug]/config/domain/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts index c5a2d2b..cc55d24 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts @@ -1,21 +1,21 @@ import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; /* - GET /api/v1/projects/:slug/config/domain + GET /api/v1/workspaces/:slug/domain */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } const [configResponse, domainResponse] = await Promise.all([ fetch( - `https://api.vercel.com/v6/domains/${data.custom_domain}/config${ + `https://api.vercel.com/v6/domains/${workspace.custom_domain}/config${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -27,9 +27,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } ), fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data.custom_domain}${ - process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' - }`, + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ + workspace.custom_domain + }${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'GET', headers: { @@ -58,7 +58,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { if (!domainData.verified && !configData.misconfigured) { const verifyResponse = await fetch( `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ - data.custom_domain + workspace.custom_domain }/verify${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'POST', @@ -102,9 +102,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { ); } - // If verification succeeded, update project config + // If verification succeeded, update workspace config if (domainData?.verified && !configData.misconfigured) { - const { error: updateError } = await updateProjectConfigBySlug( + const { error: updateError } = await updateWorkspaceBySlug( context.params.slug, { custom_domain_verified: true }, 'route' @@ -128,7 +128,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - POST /api/v1/projects/:slug/config/domain + POST /api/v1/workspaces/:slug/domain { "name": "example.com" } @@ -140,20 +140,17 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'Name is required' }, { status: 400 }); } - // Get project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - context.params.slug, - 'route' - ); + // Get workspace config + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (projectConfigError) { - return NextResponse.json({ error: projectConfigError.message }, { status: projectConfigError.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } - // If project already has a domain, return error - if (projectConfig.custom_domain) { - return NextResponse.json({ error: 'Project already has a custom domain' }, { status: 409 }); + // If workspace already has a domain, return error + if (workspace.custom_domain) { + return NextResponse.json({ error: 'Workspace already has a custom domain' }, { status: 409 }); } const response = await fetch( @@ -188,8 +185,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) ); } - // Update project config - const { error } = await updateProjectConfigBySlug( + // Update workspace config + const { error } = await updateWorkspaceBySlug( context.params.slug, { custom_domain: responseData.name, custom_domain_verified: false }, 'route' @@ -205,25 +202,25 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - DELETE /api/v1/projects/:slug/config/domain + DELETE /api/v1/workspaces/:slug/domain */ export async function DELETE(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } // If no domain, return error - if (!data?.custom_domain) { - return NextResponse.json({ error: 'Project does not have a custom domain' }, { status: 400 }); + if (!workspace?.custom_domain) { + return NextResponse.json({ error: 'Workspace does not have a custom domain' }, { status: 400 }); } // Delete domain from Vercel const response = await fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data?.custom_domain}${ + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${workspace?.custom_domain}${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -242,21 +239,21 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: responseData.error.message }, { status: 400 }); } - // Update project config - const { data: updatedProjectConfig, error: updatedProjectConfigError } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error: updatedWorkspaceConfigError } = await updateWorkspaceBySlug( context.params.slug, - { custom_domain: null, custom_domain_verified: null }, + { custom_domain: null, custom_domain_verified: false }, 'route' ); // If any errors thrown, return error - if (updatedProjectConfigError) { + if (updatedWorkspaceConfigError) { return NextResponse.json( - { error: updatedProjectConfigError.message }, - { status: updatedProjectConfigError.status } + { error: updatedWorkspaceConfigError.message }, + { status: updatedWorkspaceConfigError.status } ); } // Return response - return NextResponse.json(updatedProjectConfig, { status: 200 }); + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts index 2c483cf..003e70b 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts @@ -1,37 +1,37 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Discord integration - PATCH /api/v1/projects/:slug/config/integrations/discord + PATCH /api/v1/workspaces/:slug/config/integrations/discord { - status: boolean, + enabled: boolean, webhook: string, roleId: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook, roleId } = (await req.json()) as { - status: boolean; + const { enabled, webhook, roleId } = (await req.json()) as { + enabled: boolean; webhook: string; - roleId: string; + roleId: number; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Discord integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_discord_status: status, - integration_discord_webhook: status ? webhook : null, - integration_discord_role_id: status ? roleId : null, + discord_enabled: enabled, + discord_webhook: enabled ? webhook : null, + discord_role_id: enabled ? roleId : null, }, 'route' ); @@ -41,6 +41,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts index 25496b4..2c310e4 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts @@ -1,34 +1,34 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Slack integration - PATCH /api/v1/projects/:slug/config/integrations/slack + PATCH /api/v1/workspaces/:slug/config/integrations/slack { status: boolean, webhook: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook } = (await req.json()) as { - status: boolean; + const { enabled, webhook } = (await req.json()) as { + enabled: boolean; webhook: string; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Slack integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_slack_status: status, - integration_slack_webhook: status ? webhook : null, + slack_enabled: enabled, + slack_webhook: enabled ? webhook : null, }, 'route' ); @@ -38,6 +38,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts new file mode 100644 index 0000000..700e0f0 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; + +/* + Get workspace module config + GET /api/v1/workspaces/[slug]/modules +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace module config + const { data: moduleConfig, error } = await getWorkspaceModuleConfig(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(moduleConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts new file mode 100644 index 0000000..36ed41c --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { updateWorkspaceBySlug } from '@/lib/api/workspace'; + +/* + Update SSO configuration + PATCH /api/v1/workspaces/:slug/config/integrations/sso + { + enabled: boolean, + url: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { enabled, url } = await req.json(); + + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceBySlug( + context.params.slug, + { + sso_auth_enabled: enabled, + sso_auth_url: url, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts new file mode 100644 index 0000000..eeb54b4 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { createWorkspaceSSOSecret } from '@/lib/api/workspace'; + +/* + Generate random jwt secret + POST /api/v1/workspaces/:slug/config/integrations/sso/secret +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { data, error } = await createWorkspaceSSOSecret(context.params.slug, 'route'); + + // If there is an error, return it + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(data, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts new file mode 100644 index 0000000..0f5eb8b --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceTheme, updateWorkspaceTheme } from '@/lib/api/theme'; + +/* + Get workspace theme + GET /api/v1/workspaces/:slug/theme +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace theme + const { data: theme, error } = await getWorkspaceTheme(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(theme, { status: 200 }); +} + +/* + Update workspace theme + PATCH /api/v1/workspaces/:slug/theme + { + "accent": "string", + "background": "string", + "border": "string", + "foreground": "string", + "root": "string", + "secondary_background": "string", + "theme": "string" + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + } = await req.json(); + + // Update workspace theme + const { data: updatedTheme, error } = await updateWorkspaceTheme( + context.params.slug, + { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + }, + 'route' + ); + + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + return NextResponse.json(updatedTheme, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/analytics/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts index 03d0ba4..eb95e97 100644 --- a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getProjectAnalytics } from '@/lib/api/projects'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; /* Get Analytics - GET /api/v1/projects/:slug/analytics + GET /api/v1/workspaces/:slug/analytics */ export async function GET(req: NextRequest, context: { params: { slug: string } }) { // Check if tinybird variables are set @@ -20,7 +20,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid start or end date.' }, { status: 400 }); } - const { data: analyticsData, error } = await getProjectAnalytics(context.params.slug, 'route'); + const { data: analyticsData, error } = await getWorkspaceAnalytics(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts index 436bbf4..bcf2640 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { deleteProjectApiKey } from '@/lib/api/projects'; +import { deleteWorkspaceApiKey } from '@/lib/api/api-key'; /* - Delete api key for a project - DELETE /api/v1/projects/:slug/config/api/:token + Delete api key for a workspace + DELETE /api/v1/workspaces/:slug/config/api/:token */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { - const { error } = await deleteProjectApiKey(context.params.slug, context.params.id, 'route'); + const { error } = await deleteWorkspaceApiKey(context.params.slug, context.params.id, 'route'); if (error) { return NextResponse.json({ error }, { status: error.status }); diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts similarity index 65% rename from apps/web/app/api/v1/projects/[slug]/api-keys/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts index b40c674..28aab04 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts @@ -1,13 +1,14 @@ import { NextResponse } from 'next/server'; -import { createProjectApiKey, getProjectApiKeys } from '@/lib/api/projects'; +import { createWorkspaceApiKey, getWorkspaceApiKeys } from '@/lib/api/api-key'; +import { ApiKeyPermissions } from '@/lib/types'; /* - Get all API keys for a project - GET /api/v1/projects/:slug/config/api + Get all API keys for a workspace + GET /api/v1/workspaces/:slug/config/api */ export async function GET(req: Request, context: { params: { slug: string } }) { // Get all api keys - const { data: apiKeys, error } = await getProjectApiKeys(context.params.slug, 'route'); + const { data: apiKeys, error } = await getWorkspaceApiKeys(context.params.slug, 'route'); if (error) { return NextResponse.json(error, { status: error.status }); @@ -17,15 +18,15 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Create a new API key for a project - POST /api/v1/projects/:slug/config/api + Create a new API key for a workspace + POST /api/v1/workspaces/:slug/config/api { "name": "string", "permissions": "string" } */ export async function POST(req: Request, context: { params: { slug: string } }) { - const { name, permission } = (await req.json()) as { name: string; permission: string }; + const { name, permission } = (await req.json()) as { name: string; permission: ApiKeyPermissions }; // Validate input if (!name || !permission) { @@ -33,7 +34,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } // Create api key - const { data: apiKey, error } = await createProjectApiKey( + const { data: apiKey, error } = await createWorkspaceApiKey( context.params.slug, { name, permission }, 'route' diff --git a/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts new file mode 100644 index 0000000..3af8b5f --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceBoards } from '@/lib/api/boards'; + +/* + Get all workspace boards + GET /api/v1/workspaces/[slug]/boards +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: boards, error } = await getWorkspaceBoards(context.params.slug, 'route', true, false); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return boards + return NextResponse.json(boards, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts similarity index 88% rename from apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts index c454d15..842afd2 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server'; -import { deleteChangelog, updateChangelog } from '@/lib/api/changelogs'; +import { deleteChangelog, updateChangelog } from '@/lib/api/changelog'; export const runtime = 'edge'; /* - Update project changelog - PUT /api/v1/projects/[slug]/changelogs/[id] + Update workspace changelog + PUT /api/v1/workspaces/[slug]/changelogs/[id] { title: string; summary: string; @@ -35,8 +35,8 @@ export async function PUT(req: Request, context: { params: { slug: string; id: s } /* - Delete project changelog - DELETE /api/v1/projects/[slug]/changelogs/[id] + Delete workspace changelog + DELETE /api/v1/workspaces/[slug]/changelogs/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { const { data, error } = await deleteChangelog(context.params.id, context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts similarity index 74% rename from apps/web/app/api/v1/projects/[slug]/changelogs/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts index bd84f37..8689301 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createChangelog, getAllProjectChangelogs } from '@/lib/api/changelogs'; +import { createChangelog, getAllWorkspaceChangelogs } from '@/lib/api/changelog'; import { ChangelogProps } from '@/lib/types'; export const runtime = 'edge'; /* Create Changelog - POST /api/v1/projects/[slug]/changelogs + POST /api/v1/workspaces/[slug]/changelogs { title: string; summary: string; @@ -21,10 +21,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) title, summary, content, - image, + thumbnail, publish_date: publishDate, published, - } = (await req.json()) as ChangelogProps['Insert']; + notify_subscribers: notifySubscribers, + } = (await req.json()) as ChangelogProps['Insert'] & { notify_subscribers: boolean }; // Validate Request Body if (published) { @@ -42,13 +43,14 @@ export async function POST(req: Request, context: { params: { slug: string } }) title: title || '', summary: summary || '', content: content || '', - image: image || '', + thumbnail: thumbnail || null, publish_date: publishDate || null, published: published || false, - project_id: 'dummy-id', + workspace_id: 'dummy-id', slug: 'dummy-slug', author_id: 'dummy-author', }, + notifySubscribers, 'route' ); @@ -62,11 +64,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(context.params.slug, 'route', true); + const { data: changelogs, error } = await getAllWorkspaceChangelogs(context.params.slug, 'route', true); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts new file mode 100644 index 0000000..de0a23a --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; + +/* + Get the subscribers count for a workspace changelog + GET /api/v1/workspaces/:slug/changelogs/subscribers/count +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return new Response(error.message, { status: error.status }); + } + + return NextResponse.json({ count: subscribers.length }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts similarity index 84% rename from apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts index 914c1f1..ab2621b 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ -import { getChangelogSubscribers } from '@/lib/api/changelogs'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; /* Download changelog subscribers - GET /api/v1/projects/:slug/changelogs/subscribers/download + GET /api/v1/workspaces/:slug/changelogs/subscribers/download */ export async function GET(req: Request, context: { params: { slug: string } }) { const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts index c8e2f0b..36bc132 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { deleteCommentForFeedbackById } from '@/lib/api/comments'; +import { deleteCommentForFeedbackById } from '@/lib/api/comment'; /* Delete comment for feedback by id - DELETE /api/v1/projects/[slug]/feedback/[id]/comments/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id]/comments/[id] */ export async function DELETE( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts similarity index 81% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts index 3c2506b..0c00268 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { upvoteCommentForFeedbackById } from '@/lib/api/comments'; +import { upvoteCommentForFeedbackById } from '@/lib/api/comment'; /* Upvote comment for feedback by id - POST /api/v1/projects/[slug]/feedback/[id]/comments/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/comments/[id]/upvote */ export async function POST( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts similarity index 85% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts index e9b38f9..5ac00b2 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts @@ -1,16 +1,17 @@ import { NextResponse } from 'next/server'; -import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comments'; -import { FeedbackCommentProps } from '@/lib/types'; +import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comment'; +import { CommentProps } from '@/lib/types'; /* Create feedback comment - POST /api/v1/projects/[slug]/feedback/[id]/comments + POST /api/v1/workspaces/[slug]/feedback/[id]/comments { content: string + reply_to_id?: string } */ export async function POST(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { content, reply_to_id: replyToId } = (await req.json()) as FeedbackCommentProps['Insert']; + const { content, reply_to_id: replyToId } = (await req.json()) as CommentProps['Insert']; if (!content) { return NextResponse.json({ error: 'Content cannot be empty' }, { status: 400 }); @@ -38,7 +39,7 @@ export async function POST(req: Request, context: { params: { slug: string; feed /* Get feedback comments - GET /api/v1/projects/[slug]/feedback/[id]/comments + GET /api/v1/workspaces/[slug]/feedback/[id]/comments */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { const { data: comments, error } = await getCommentsForFeedbackById( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts similarity index 69% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts index d75433d..04c859d 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { deleteFeedbackByID, getFeedbackByID, updateFeedbackByID } from '@/lib/api/feedback'; +import { deleteFeedbackById, getFeedbackById, updateFeedbackByID } from '@/lib/api/feedback'; import { FeedbackWithUserInputProps } from '@/lib/types'; /* - Get Project Feedback by ID - GET /api/v1/projects/[slug]/feedback/[id] + Get Workspace Feedback by ID + GET /api/v1/workspaces/[slug]/feedback/[id] */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -24,27 +24,35 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Update Feedback by ID - PATCH /api/v1/projects/[slug]/feedback/[id] + PATCH /api/v1/workspaces/[slug]/feedback/[id] { title: string; - description: string; + content: string; status: string; tags: string[]; + board_id: string; } */ export async function PATCH(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { title, description, status, tags } = (await req.json()) as FeedbackWithUserInputProps; + const { + title, + content, + status, + tags, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; const { data: feedback, error } = await updateFeedbackByID( context.params.feedbackId, context.params.slug, { title: title || '', - description: description || '', - status, - project_id: 'dummy-id', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', user_id: 'dummy-id', tags: tags || undefined, + workspace_id: 'dummy-id', }, 'route' ); @@ -60,10 +68,10 @@ export async function PATCH(req: Request, context: { params: { slug: string; fee /* Delete Feedback by ID - DELETE /api/v1/projects/[slug]/feedback/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await deleteFeedbackByID( + const { data: feedback, error } = await deleteFeedbackById( context.params.feedbackId, context.params.slug, 'route' diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts similarity index 73% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts index 146768d..d3244af 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts @@ -1,13 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getFeedbackByID, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; -import { getCurrentUser } from '@/lib/api/user'; +import { getFeedbackById, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; /* Get feedback upvotes - GET /api/v1/projects/[slug]/feedback/[id]/upvote + GET /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -42,21 +41,14 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Upvote a feedback - POST /api/v1/projects/[slug]/feedback/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function POST(req: NextRequest, context: { params: { slug: string; feedbackId: string } }) { - const hasUpvoted = req.nextUrl.searchParams.get('has_upvoted'); - - // Get current user - const { data: isLoggedIn } = await getCurrentUser('route'); - // Upvote feedback const { data: feedback, error } = await upvoteFeedbackByID( context.params.feedbackId, context.params.slug, - 'route', - hasUpvoted ? hasUpvoted === 'true' : undefined, - !isLoggedIn + 'route' ); // If any errors thrown, return error diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts similarity index 78% rename from apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts index 1eebff8..166bf20 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts @@ -1,12 +1,12 @@ -import { getAllProjectFeedback } from '@/lib/api/feedback'; +import { getAllWorkspaceFeedback } from '@/lib/api/feedback'; import { formatRootUrl } from '@/lib/utils'; /* - Export all feedback for a project - GET /api/v1/projects/:slug/feedback/export + Export all feedback for a workspace + GET /api/v1/workspaces/:slug/feedback/export */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route'); + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -19,7 +19,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return `${feedback.id},${formatRootUrl( context.params.slug, `/feedback/${feedback.id}` - )},"${feedback.title.replace(/"/g, '""')}","${feedback.description.replace(/"/g, '""')}",${ + )},"${feedback.title.replace(/"/g, '""')}","${feedback.content.replace(/"/g, '""')}",${ feedback.status },${feedback.upvotes},${feedback.comment_count},${feedback.user_id},"${feedback.user.full_name.replace( /"/g, diff --git a/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts new file mode 100644 index 0000000..9f8bd23 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createFeedback, getAllBoardFeedback, getAllWorkspaceFeedback } from '@/lib/api/feedback'; +import { FeedbackWithUserInputProps } from '@/lib/types'; + +export const runtime = 'edge'; + +/* + Create Feedback + POST /api/v1/workspaces/[slug]/feedback + { + title: string; + content: string; + status: string; + tags: [id, id, id], + board_id?: string; + } +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { + title, + content, + status, + tags, + user, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; + + // Validate Request Body + if (!title) { + return NextResponse.json({ error: 'title is required.' }, { status: 400 }); + } + + const { data: feedback, error } = await createFeedback( + boardId, + context.params.slug, + { + title: title || '', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', + workspace_id: context.params.slug, + user_id: 'dummy-id', + tags: tags || [], + user: user !== null ? user : undefined, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} + +/* + Get Workspace Feedback + GET /api/v1/workspaces/[slug]/feedback + Query Parameters: + - b (board_id): string +*/ +export async function GET(req: NextRequest, context: { params: { slug: string } }) { + // Get query parameters + const boardId = req.nextUrl.searchParams.get('b'); + + // If boardId is provided, get feedback by boardId + if (boardId) { + const { data: feedback, error } = await getAllBoardFeedback(boardId, context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); + } + + // Get all feedback + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts similarity index 91% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts index 0713c06..0d3608a 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts @@ -3,7 +3,7 @@ import { deleteFeedbackTagByName } from '@/lib/api/feedback'; /* Delete tag by name - DELETE /api/v1/projects/:slug/feedback/tags/:name + DELETE /api/v1/workspaces/:slug/feedback/tags/:name */ export async function DELETE(req: Request, context: { params: { slug: string; name: string } }) { const { data: tag, error } = await deleteFeedbackTagByName( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts similarity index 89% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts index e6bc865..41af1a8 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts @@ -4,7 +4,7 @@ import { FeedbackTagProps } from '@/lib/types'; /* Create new tag - POST /api/v1/projects/:slug/feedback/tags + POST /api/v1/workspaces/:slug/feedback/tags { name: string, color: string @@ -33,10 +33,10 @@ export async function POST(req: Request, context: { params: { slug: string } }) /* Get all feedback tags - GET /api/v1/projects/:slug/feedback/tags + GET /api/v1/workspaces/:slug/feedback/tags */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route'); + const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts similarity index 61% rename from apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts index 1e66f71..b40a54f 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { acceptProjectInvite, deleteProjectInvite } from '@/lib/api/invites'; +import { acceptWorkspaceInvite, deleteWorkspaceInvite } from '@/lib/api/invite'; /* - Accept project invite - POST /api/v1/projects/[slug]/invites/[inviteId] + Accept workspace invite + POST /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function POST(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await acceptProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await acceptWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { @@ -18,11 +18,11 @@ export async function POST(req: Request, context: { params: { slug: string; invi } /* - Delete project invite - DELETE /api/v1/projects/[slug]/invites/[inviteId] + Delete workspace invite + DELETE /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function DELETE(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await deleteProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await deleteWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/invites/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/route.ts index 74256dc..65def20 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createProjectInvite, getProjectInvites } from '@/lib/api/invites'; +import { createWorkspaceInvite, getWorkspaceInvites } from '@/lib/api/invite'; /* - Get all project invites - GET /api/v1/projects/[slug]/invites + Get all workspace invites + GET /api/v1/workspaces/[slug]/invites */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: invites, error } = await getProjectInvites(context.params.slug, 'route'); + const { data: invites, error } = await getWorkspaceInvites(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -18,8 +18,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Invite a new member to a project - POST /api/v1/projects/[slug]/members + Invite a new member to a workspace + POST /api/v1/workspaces/[slug]/members { email: string } @@ -27,14 +27,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { export async function POST(req: Request, context: { params: { slug: string } }) { const { email } = await req.json(); - // Check if email is valid - const emailRegex = new RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); - if (!emailRegex.test(email)) { - return NextResponse.json({ error: 'Invalid email' }, { status: 400 }); - } - // Create invite - const { data: invite, error } = await createProjectInvite(context.params.slug, 'route', email); + const { data: invite, error } = await createWorkspaceInvite(context.params.slug, 'route', email); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/members/route.ts b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts similarity index 60% rename from apps/web/app/api/v1/projects/[slug]/members/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/members/route.ts index cfa5b2c..e73da78 100644 --- a/apps/web/app/api/v1/projects/[slug]/members/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getProjectMembers } from '@/lib/api/projects'; +import { getWorkspaceMembers } from '@/lib/api/workspace'; /* - Get all members of a project - GET /api/v1/projects/[slug]/members + Get all members of a workspace + GET /api/v1/workspaces/[slug]/members */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(context.params.slug, 'route'); + const { data: members, error } = await getWorkspaceMembers(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/route.ts new file mode 100644 index 0000000..309dac2 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { deleteWorkspaceBySlug, getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; +import { WorkspaceProps } from '@/lib/types'; + +/* + Get workspace by slug + GET /api/v1/workspaces/[slug] +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route'); + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return workspace + return NextResponse.json(workspace, { status: 200 }); +} + +/* + Update workspace by slug + PATCH /api/v1/workspaces/[slug] + { + name: string, + slug: string, + icon: string, + icon_radius: string, + og_image: string + icon_redirect_url: string + custom_domain_redirect: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + } = (await req.json()) as WorkspaceProps['Update']; + + const { data: updatedWorkspace, error } = await updateWorkspaceBySlug( + context.params.slug, + { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace + return NextResponse.json(updatedWorkspace, { status: 200 }); +} + +/* + Delete workspace by slug + DELETE /api/v1/workspaces/[slug] +*/ +export async function DELETE(req: Request, context: { params: { slug: string } }) { + const { data, error } = await deleteWorkspaceBySlug(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return success + return NextResponse.json({ data }, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/route.ts b/apps/web/app/api/v1/workspaces/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/route.ts rename to apps/web/app/api/v1/workspaces/route.ts index f65280b..d10836d 100644 --- a/apps/web/app/api/v1/projects/route.ts +++ b/apps/web/app/api/v1/workspaces/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { createProject } from '@/lib/api/projects'; -import { getUserProjects } from '@/lib/api/user'; +import { getUserWorkspaces } from '@/lib/api/user'; +import { createWorkspace } from '@/lib/api/workspace'; /* - Create Project - POST /api/v1/projects + Create Workspace + POST /api/v1/workspaces { - "name": "Project Name", - "slug": "project-slug", + "name": "Workspace Name", + "slug": "workspace-slug", } */ export async function POST(req: Request) { @@ -19,30 +19,30 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'name and slug are required.' }, { status: 400 }); } - // Create Project - const { data: project, error } = await createProject({ name, slug }, 'route'); + // Create Workspace + const { data: workspace, error } = await createWorkspace({ name, slug }, 'route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - return NextResponse.json(project, { status: 201 }); + return NextResponse.json(workspace, { status: 201 }); } /* - Get User Projects - GET /api/v1/projects + Get User Workspaces + GET /api/v1/workspaces */ export async function GET(req: Request) { - // Get User Projects - const { data: projects, error } = await getUserProjects('route'); + // Get User Workspaces + const { data: workspaces, error } = await getUserWorkspaces('route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return projects - return NextResponse.json(projects, { status: 200 }); + // Return workspaces + return NextResponse.json(workspaces, { status: 200 }); } diff --git a/apps/web/app/dash/(auth)/login/page.tsx b/apps/web/app/dash/(auth)/login/page.tsx index 951c959..129dc9e 100644 --- a/apps/web/app/dash/(auth)/login/page.tsx +++ b/apps/web/app/dash/(auth)/login/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign in to Feedbase', @@ -31,7 +38,7 @@ export default async function SignIn() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/(auth)/signup/page.tsx b/apps/web/app/dash/(auth)/signup/page.tsx index 912ed81..ffa038d 100644 --- a/apps/web/app/dash/(auth)/signup/page.tsx +++ b/apps/web/app/dash/(auth)/signup/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign up to Feedbase', @@ -31,7 +38,7 @@ export default async function SignUp() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/[slug]/analytics/loading.tsx b/apps/web/app/dash/[slug]/analytics/loading.tsx deleted file mode 100644 index 50dd5dd..0000000 --- a/apps/web/app/dash/[slug]/analytics/loading.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - - - - - - - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/analytics/page.tsx b/apps/web/app/dash/[slug]/analytics/page.tsx index 631e1be..35cebde 100644 --- a/apps/web/app/dash/[slug]/analytics/page.tsx +++ b/apps/web/app/dash/[slug]/analytics/page.tsx @@ -1,21 +1,55 @@ import React from 'react'; -import { getProjectAnalytics } from '@/lib/api/projects'; -import AnalyticsCards from '@/components/dashboard/analytics/chart-cards'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@feedbase/ui/components/select'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; +import { BarChart } from '@/components/analytics/bar-chart'; +import { LineChart } from '@/components/analytics/line-chart'; export default async function AnalyticsPage({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectAnalytics(params.slug, 'server'); + const { data, error } = await getWorkspaceAnalytics(params.slug, 'server'); if (!data) { return Error: {error?.message}; } return ( - - + + {/* Header */} + + Analytics + + + {/* Timeframe */} + + + + + + + Last 3 months + + + Last 30 days + + + Last 7 days + + + + + + + {/* Chart */} + + + + + ); } diff --git a/apps/web/app/dash/[slug]/changelog/loading.tsx b/apps/web/app/dash/[slug]/changelog/loading.tsx deleted file mode 100644 index 7e7f0a9..0000000 --- a/apps/web/app/dash/[slug]/changelog/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ; -} diff --git a/apps/web/app/dash/[slug]/changelog/page.tsx b/apps/web/app/dash/[slug]/changelog/page.tsx index e56db5e..d715ba5 100644 --- a/apps/web/app/dash/[slug]/changelog/page.tsx +++ b/apps/web/app/dash/[slug]/changelog/page.tsx @@ -1,67 +1,36 @@ +import { Button } from '@feedbase/ui/components/button'; +import { Separator } from '@feedbase/ui/components/separator'; import { Plus } from 'lucide-react'; -import { Button } from 'ui/components/ui/button'; -import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { Separator } from 'ui/components/ui/separator'; -import { getAllProjectChangelogs } from '@/lib/api/changelogs'; -import { ApiSheet } from '@/components/dashboard/changelogs/api-sheet'; -import ChangelogList from '@/components/dashboard/changelogs/changelog-list'; -import { AddChangelogModal } from '@/components/dashboard/modals/add-edit-changelog-modal'; - -export default async function Changelog({ params }: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(params.slug, 'server'); - - if (error) { - return {error.message}; - } +import { ApiSheet } from '@/components/changelog/api-sheet'; +import ChangelogList from '@/components/changelog/changelog-list'; +import { AddChangelogModal } from '@/components/modals/add-edit-changelog-modal'; +export default function Changelog({ params }: { params: { slug: string } }) { return ( - - {/* Content */} - - {/* Header Row */} - {changelogs.length > 0 && ( - - {/* Api Docs Button */} - + + {/* Header */} + + Changelogs - {/* Seperator Line */} - + + {/* Api Docs Button */} + - {/* Create new Button */} - - - New Changelog - - } - /> - - )} + {/* Seperator Line */} + - {/* Changelog List */} - {/* If there is no changelog, show empty text in the center */} - {changelogs.length === 0 && ( - - - No changelogs yet - - Once you create a changelog, it will show up here. - - - - Create first changelog} - /> - - - )} - - {/* If there is changelog, show changelog list */} - {changelogs.length > 0 && } + {/* Create new Button */} + + + + New Changelog + + + + + {/* Changelogs */} + ); } diff --git a/apps/web/app/dash/[slug]/feedback/loading.tsx b/apps/web/app/dash/[slug]/feedback/loading.tsx deleted file mode 100644 index ea9545c..0000000 --- a/apps/web/app/dash/[slug]/feedback/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - <> - - - > - ); -} diff --git a/apps/web/app/dash/[slug]/feedback/page.tsx b/apps/web/app/dash/[slug]/feedback/page.tsx index 700c37d..ea01880 100644 --- a/apps/web/app/dash/[slug]/feedback/page.tsx +++ b/apps/web/app/dash/[slug]/feedback/page.tsx @@ -1,46 +1,14 @@ -import { Card, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getAllFeedbackTags, getAllProjectFeedback } from '@/lib/api/feedback'; -import FeedbackTable from '@/components/dashboard/feedback/feedback-table'; -import FeedbackHeader from '@/components/dashboard/feedback/header-buttons'; - -export default async function Feedback({ - params, - searchParams, -}: { - params: { slug: string }; - searchParams: { tag: string }; -}) { - const { data: feedback, error } = await getAllProjectFeedback(params.slug, 'server'); - if (error) { - return {error.message}; - } - - const { data: tags, error: tagsError } = await getAllFeedbackTags(params.slug, 'server'); - if (tagsError) { - return {tagsError.message}; - } +import FeedbackHeader from '@/components/feedback/dashboard/feedback-header'; +import FeedbackList from '@/components/feedback/dashboard/feedback-list'; +export default async function Feedback() { return ( - - {/* Header Row */} - - - {/* Feedback Posts */} - {/* If there is no feedback, show empty text in the center */} - {feedback.length === 0 && ( - - - No feedback yet - - Once somone submits feedback, it will show up here. Make sure to share it so others can vote on - it! - - - - )} + + {/* Header */} + - {/* If there is feedback, show feedback list */} - {feedback.length > 0 && } + {/* Feedback List */} + ); } diff --git a/apps/web/app/dash/[slug]/layout.tsx b/apps/web/app/dash/[slug]/layout.tsx index aa90d86..eebd0d6 100644 --- a/apps/web/app/dash/[slug]/layout.tsx +++ b/apps/web/app/dash/[slug]/layout.tsx @@ -1,11 +1,11 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { getCurrentUser, getUserProjects } from '@/lib/api/user'; +import { getCurrentUser, getUserWorkspaces } from '@/lib/api/user'; import { DASH_DOMAIN } from '@/lib/constants'; -import InboxPopover from '@/components/layout/inbox-popover'; +import { SidebarTabsProps } from '@/lib/types'; +import DashboardHeader from '@/components/layout/header'; import NavbarMobile from '@/components/layout/nav-bar-mobile'; import Sidebar from '@/components/layout/sidebar'; -import TitleProvider from '@/components/layout/title-provider'; import { AnalyticsIcon, CalendarIcon, @@ -13,41 +13,45 @@ import { SettingsIcon, TagLabelIcon, } from '@/components/shared/icons/icons-animated'; -import { Icons } from '@/components/shared/icons/icons-static'; -import UserDropdown from '@/components/shared/user-dropdown'; -const tabs = [ - { - name: 'Changelog', - icon: TagLabelIcon, - slug: 'changelog', - }, - { - name: 'Feedback', - icon: FeedbackIcon, - slug: 'feedback', - }, - { - name: 'Roadmap (Soon)', - icon: CalendarIcon, - slug: 'roadmap', - }, - { - name: 'Analytics', - icon: AnalyticsIcon, - slug: 'analytics', - }, - { - name: 'Settings', - icon: SettingsIcon, - slug: 'settings', - }, -]; +const tabs: SidebarTabsProps = { + Modules: [ + { + name: 'Changelog', + icon: TagLabelIcon, + slug: 'changelog', + }, + { + name: 'Feedback', + icon: FeedbackIcon, + slug: 'feedback', + }, + { + name: 'Roadmap', + icon: CalendarIcon, + slug: 'roadmap', + }, + ], + Insights: [ + { + name: 'Analytics', + icon: AnalyticsIcon, + slug: 'analytics', + }, + ], + Workspace: [ + { + name: 'Settings', + icon: SettingsIcon, + slug: 'settings/general', + }, + ], +}; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { // Headers const headerList = headers(); - const projectSlug = headerList.get('x-project'); + const workspaceSlug = headerList.get('x-workspace'); const pathname = headerList.get('x-pathname'); // Fetch user @@ -57,67 +61,48 @@ export default async function DashboardLayout({ children }: { children: React.Re return redirect(`${DASH_DOMAIN}/login`); } - // Fetch the user's projects - const { data: projects } = await getUserProjects('server'); + // Fetch the user's workspaces + const { data: workspaces } = await getUserWorkspaces('server'); - // Check if the user has any projects - if (!projects || projects.length === 0) { + // Check if the user has any workspaces + if (!workspaces || workspaces.length === 0) { return redirect(`${DASH_DOMAIN}`); } - // Get the project with the current slug - const currentProject = projects.find((project) => project.slug === projectSlug); + // Get the workspace with the current slug + const currentWorkspace = workspaces.find((workspace) => workspace.slug === workspaceSlug); - // If currentProject is undefined, redirect to the first project - if (!currentProject) { - return redirect(`/${projects[0].slug}`); + // If currentWorkspace is undefined, redirect to the first workspace + if (!currentWorkspace) { + return redirect(`/${workspaces[0].slug}`); } // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.includes(tab.slug)); + const activeTab = Object.values(tabs) + .flatMap((tabArray) => tabArray) + .find((tab) => pathname?.includes(tab.slug)); return ( - - - {/* Header with logo and hub button */} - {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} - {/* https://github.com/radix-ui/primitives/discussions/1100 */} - - {/* Logo */} - - - - - - - - - {/* Sidebar */} - + + {/* Header with logo and hub button */} + {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} + {/* https://github.com/radix-ui/primitives/discussions/1100 */} + - {/* Main content */} - - - {children} - - + {/* Sidebar */} + - {/* Navbar (mobile) */} - + {/* Main content */} + + {children} + + {/* Navbar (mobile) */} + ); } diff --git a/apps/web/app/dash/[slug]/page.tsx b/apps/web/app/dash/[slug]/page.tsx index 1e344c3..4e3668f 100644 --- a/apps/web/app/dash/[slug]/page.tsx +++ b/apps/web/app/dash/[slug]/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -export default async function ProjectPage({ params }: { params: { slug: string } }) { +export default async function WorkspacePage({ params }: { params: { slug: string } }) { // Redirect to changelog redirect(`/${params.slug}/changelog`); } diff --git a/apps/web/app/dash/[slug]/roadmap/page.tsx b/apps/web/app/dash/[slug]/roadmap/page.tsx new file mode 100644 index 0000000..dc1a177 --- /dev/null +++ b/apps/web/app/dash/[slug]/roadmap/page.tsx @@ -0,0 +1,14 @@ +import RoadmapBoard from '@/components/roadmap/dashboard/roadmap-board'; +import RoadmapHeader from '@/components/roadmap/dashboard/roadmap-header'; + +export default async function Roadmap() { + return ( + + {/* Header */} + + + {/* Board */} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/api/page.tsx b/apps/web/app/dash/[slug]/settings/api/page.tsx new file mode 100644 index 0000000..a5c81c1 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/api/page.tsx @@ -0,0 +1,9 @@ +import SettingsCard from '@/components/settings/settings-card'; + +export default function ApiSettings() { + return ( + + s + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/billing/page.tsx b/apps/web/app/dash/[slug]/settings/billing/page.tsx new file mode 100644 index 0000000..c798162 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/billing/page.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { Label } from '@feedbase/ui/components/label'; +import { LucideExternalLink } from 'lucide-react'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function BillingSettings() { + return ( + + + + + Current plan: Starter + + + View plans + + + + + Change plan + Get in touch + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/changelog/page.tsx b/apps/web/app/dash/[slug]/settings/changelog/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/changelog/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/domain/page.tsx b/apps/web/app/dash/[slug]/settings/domain/page.tsx new file mode 100644 index 0000000..3ea7363 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/domain/page.tsx @@ -0,0 +1,599 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feedbase/ui/components/tabs'; +import { cn } from '@feedbase/ui/lib/utils'; +import { fontMono } from '@feedbase/ui/styles/fonts'; +import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { ChevronUpDownIcon } from '@heroicons/react/24/solid'; +import { Check, CheckIcon, ClipboardList, RefreshCcw, Trash2Icon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, fetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +interface domainData { + name: string; + apexName: string; + verified: boolean; + verification: { + type: string; + domain: string; + value: string; + reason: string; + }[]; +} + +export default function DomainSettings({ params }: { params: { slug: string } }) { + const [domain, setDomain] = useState(''); + const { workspace, loading, isValidating, mutate, error } = useWorkspace(); + const [domainStatus, setDomainStatus] = useState<'unset' | 'verifying' | 'verified'>('unset'); + const [hasCopied, setHasCopied] = useState([]); + const [redirectRule, setRedirectRule] = useState<'direct_redirect' | 'root_redirect' | 'no_redirect'>(); + + const { + data: domainData, + isValidating: domainIsValidating, + mutate: domainMutate, + } = useSWR(`/api/v1/workspaces/${params.slug}/domain`, fetcher, { + errorRetryInterval: 30000, + refreshInterval: 5000, + }); + + const { trigger: submitDomainVerification, isMutating: isSubmittingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('verifying'); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: removeDomain, isMutating: isRemovingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('unset'); + setDomain(''); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + toast.success('Redirect rule updated.'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + useEffect(() => { + if (workspace?.custom_domain) { + // Set domain if it is not set + if (!domain) { + setDomain(workspace.custom_domain); + } + + // Set domain status + if (workspace.custom_domain_verified || domainData?.verified) { + setDomainStatus('verified'); + } else { + setDomainStatus('verifying'); + } + } else { + setDomainStatus('unset'); + } + + setRedirectRule(workspace?.custom_domain_redirect); + }, [workspace, domainData]); + + function handleCopyToClipboard(value: string) { + navigator.clipboard.writeText(value); + setHasCopied((prevHasCopied) => [...prevHasCopied, value]); + + setTimeout(() => { + setHasCopied((prevHasCopied) => prevHasCopied.filter((item) => item !== value)); + }, 3000); + } + + // Loading State + if (loading) { + return ( + + + + ); + } + + // Error State + if (error) { + return ( + + + + + + ); + } + + return ( + + + Domain + + { + setDomain(event.target.value); + }} + /> + {domainStatus === 'unset' ? ( + { + submitDomainVerification({ name: domain }); + }} + disabled={isSubmittingDomain || !domain}> + {isSubmittingDomain ? : null} + Connect + + ) : ( + { + removeDomain({ method: 'DELETE' }); + }}> + {isRemovingDomain ? ( + + ) : ( + + )} + + )} + + + Want to host on a sub-path? Check out our{' '} + + documentation + + . + + + + + Redirect Feedbase Subdomain + + + + {redirectRule === 'direct_redirect' && 'Redirect direct path'} + {redirectRule === 'root_redirect' && 'Redirect to root'} + {redirectRule === 'no_redirect' && "Don't Redirect"} + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'direct_redirect' }); + setRedirectRule('direct_redirect'); + }}> + + Redirect direct path + + Redirects to equivalent path on your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'root_redirect' }); + setRedirectRule('root_redirect'); + }}> + + Redirect to root + + Redirects to the root of your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'no_redirect' }); + setRedirectRule('no_redirect'); + }}> + + Don't Redirect + + Does not redirect any requests to your custom domain. + + + + + + + + + + Wether to redirect the default {process.env.NEXT_PUBLIC_ROOT_DOMAIN} subdomain to your custom + domain. + + + + + {domainStatus === 'verifying' ? ( + <> + + + Verification Required + + + {/* Initial Loading state */} + {!domainData && ( + + + Fetching domain data... + + )} + + {domainData ? ( + + {/* Tabs, if domain is not assigned to a vercel workspace yet */} + {domainData.verification === undefined ? ( + + + + + A Record (Recommended) + + + CNAME Record + + + + {/* Refresh Status */} + { + domainMutate(); + }} + disabled={domainIsValidating} + className='text-secondary-foreground hover:text-foreground'> + {domainIsValidating ? ( + + ) : ( + + )} + + + + + + To verify your domain, add the following A Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + A + + + + Name + { + handleCopyToClipboard('@'); + }} + type='button'> + + @ + + + {hasCopied.includes('@') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + 76.76.21.21 + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + + To verify your domain, add the following CNAME Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + CNAME + + + + Name + { + handleCopyToClipboard('www'); + }} + type='button'> + + www + + + {hasCopied.includes('www') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + cname.vercel-dns.com + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + ) : null} + + {/* Verification Instructions for already vercel assigned domains */} + {domainData.verification !== undefined ? ( + + + To prove ownership of{' '} + + {domainData.apexName} + + , please add the following TXT Record to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + TXT + + + + Name + { + handleCopyToClipboard(domainData.verification[0].domain); + }} + type='button'> + + {domainData.verification[0].domain} + + + {hasCopied.includes(domainData.verification[0].domain) ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard(domainData.verification[0].value); + }} + type='button'> + + {domainData.verification[0].value} + + + {hasCopied.includes(domainData.verification[0].value) ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Warning: If{' '} + + {domainData.apexName} + {' '} + is already in use, the TXT Record will transfer away from the existing domain ownership + and break the site. Consider using a subdomain instead. + + + ) : null} + + ) : null} + > + ) : null} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/feedback/page.tsx b/apps/web/app/dash/[slug]/settings/feedback/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/feedback/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/general/loading.tsx b/apps/web/app/dash/[slug]/settings/general/loading.tsx deleted file mode 100644 index 5a31508..0000000 --- a/apps/web/app/dash/[slug]/settings/general/loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function GeneralLoading() { - return ( - // 3 cards with loading state - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/general/page.tsx b/apps/web/app/dash/[slug]/settings/general/page.tsx index 32c6754..cfbee47 100644 --- a/apps/web/app/dash/[slug]/settings/general/page.tsx +++ b/apps/web/app/dash/[slug]/settings/general/page.tsx @@ -1,28 +1,459 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import GeneralConfigCards from '@/components/dashboard/settings/general-cards'; +'use client'; -export default async function GeneralSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); +import { useEffect, useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { cn } from '@feedbase/ui/lib/utils'; +import { Check, ChevronsUpDownIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import useWorkspaceTheme from '@/lib/swr/use-workspace-theme'; +import { WorkspaceProps, WorkspaceThemeProps } from '@/lib/types'; +import { actionFetcher, areObjectsEqual } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import FileDrop from '@/components/shared/file-drop'; +import InputGroup from '@/components/shared/input-group'; - if (error) { - return {error.message}; - } +export default function GeneralSettings({ params }: { params: { slug: string } }) { + const { + workspace: workspaceData, + loading: workspaceLoading, + error: workspaceError, + mutate: workspaceMutate, + } = useWorkspace(); + const { + workspaceTheme: workspaceThemeData, + loading: workspaceThemeLoading, + error: workspaceThemeError, + mutate: workspaceThemeMutate, + } = useWorkspaceTheme(); + + const [workspace, setWorkspace] = useState(); + const [workspaceTheme, setWorkspaceTheme] = useState(); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + // Check if slug has changed + if (workspace?.slug !== workspaceData?.slug) { + // Redirect to new slug + window.location.href = `/${workspace?.slug}/settings/general`; + } + + workspaceMutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); - // Fetch project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - params.slug, - 'server' + const { trigger: updateWorkspaceTheme } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/theme`, + actionFetcher, + { + onError: (error) => { + toast.error(error.message); + }, + } ); - if (projectConfigError) { - return {projectConfigError.message}; + useEffect(() => { + setWorkspace(workspaceData); + setWorkspaceTheme(workspaceThemeData); + }, [workspaceData, workspaceThemeData]); + + if (workspaceError || workspaceThemeError) { + return ( + + ); } - return ( - - {/* General Card */} - - - ); + if (workspaceLoading || workspaceThemeLoading) { + return ( + <> + + + + + + + + + + + + > + ); + } + + if (workspace && workspaceTheme) { + return ( + <> + { + await updateWorkspace({ method: 'PATCH', ...workspace }); + }} + onCancel={() => { + setWorkspace(workspaceData); + }}> + + Name + { + setWorkspace({ + ...workspace, + name: e.target.value, + }); + }} + /> + This is the name of your workspace. + + + + Slug + + { + setWorkspace({ + ...workspace, + slug: e.target.value, + }); + }} + suffix={`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`} + placeholder='feedbase' + /> + + This is the subdomain of your workspace. + + + {/* Workspace Logo */} + + Logo + + {/* File Upload */} + + + + + {workspace.name[0]} + + + + + + Upload + + { + // Set the icon to the uploaded file + if (event.target.files?.[0]) { + // Check if the file is an image, image/png, image/jpeg, etc. + if (!['image/png', 'image/jpeg', 'image/jpg'].includes(event.target.files[0].type)) { + toast.error('Please upload a valid image.'); + return; + } + + // Read the file as a data URL + const reader = new FileReader(); + reader.onload = (e) => { + setWorkspace({ + ...workspace, + icon: e.target?.result as string, + }); + }; + reader.readAsDataURL(event.target.files[0]); + } + }} + /> + {workspace.icon ? ( + setWorkspace({ ...workspace, icon: '' })}> + Remove + + ) : null} + + Recommended size is 256x256. + + + + + + Radius + + + + {workspace.icon_radius === 'rounded-md' + ? 'Rounded' + : workspace.icon_radius === 'rounded-full' + ? 'Circle' + : 'Square'} + + + + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-md', + }); + }}> + Rounded + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-full', + }); + }}> + Circle + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-none', + }); + }}> + Square + + + + + This is the radius of your workspace. + + + + Redirect URL + { + setWorkspace({ + ...workspace, + icon_redirect_url: e.target.value, + }); + }} + placeholder='Leave blank to use default' + /> + + This is the redirect URL of your workspace. + + + + {/* OG Image */} + + OG Image} + image={workspace.opengraph_image} + setImage={(e: string | null) => { + setWorkspace({ + ...workspace, + opengraph_image: e, + }); + }} + className='h-40 w-80' + /> + + + The OG Image used when sharing your workspace. + + + + + + {/* Thanks to https://github.com/openstatushq for the inspiration of the theme previews */} + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'light' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'light' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + + Light + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'dark' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'dark' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + Dark + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'custom' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'custom' }); + }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + System + + + + + + + Delete this Workspace + + + + > + ); + } } diff --git a/apps/web/app/dash/[slug]/settings/hub/loading.tsx b/apps/web/app/dash/[slug]/settings/hub/loading.tsx index 8053295..f8a838e 100644 --- a/apps/web/app/dash/[slug]/settings/hub/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function HubLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/hub/page.tsx b/apps/web/app/dash/[slug]/settings/hub/page.tsx index 09506ef..0109277 100644 --- a/apps/web/app/dash/[slug]/settings/hub/page.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/page.tsx @@ -1,16 +1,18 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import HubConfigCards from '@/components/dashboard/settings/hub-cards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; + +// import HubConfigCards from '@/components/dashboard/settings/hub-cards'; export default async function HubSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); + // Fetch workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.slug, 'server'); if (error) { return {error.message}; } - // Fetch project config - const { data: projectConfig, error: configError } = await getProjectConfigBySlug(params.slug, 'server'); + // Fetch workspace config + const { data: workspaceConfig, error: configError } = await getWorkspaceModuleConfig(params.slug, 'server'); if (configError) { return {configError.message}; @@ -19,7 +21,7 @@ export default async function HubSettings({ params }: { params: { slug: string } return ( {/* Hub Card */} - + {/* */} ); } diff --git a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx index f1cbc8e..b50f052 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function IntegrationsLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/integrations/page.tsx b/apps/web/app/dash/[slug]/settings/integrations/page.tsx index 1bd1711..2b94c5f 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/page.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/page.tsx @@ -1,12 +1,81 @@ -import { getProjectConfigBySlug } from '@/lib/api/projects'; -import IntegrationCards from '@/components/dashboard/settings/integration-cards'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import FeedbackModal from '@/components/modals/send-feedback-modal'; +import SettingsCard from '@/components/settings/settings-card'; -export default async function IntegrationsSettings({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectConfigBySlug(params.slug, 'server'); +function IntegrationCard({ + title, + description, + image, + alt, + onClick, +}: { + title: string; + description: string; + image: string; + alt: string; + onClick?: () => void; +}) { + return ( + + {/* Avatar */} + + + {alt} + - if (error) { - return Error; - } + {/* Name and Description */} + + {title} - return ; + {description} + + + ); +} + +export default function IntegrationSettings({ params }: { params: { slug: string } }) { + return ( + + + + + + + + + + + More integrations coming soon! If you have a specific integration you would like to see,{' '} + + + let us know + + + ! + + + ); } diff --git a/apps/web/app/dash/[slug]/settings/layout.tsx b/apps/web/app/dash/[slug]/settings/layout.tsx index 85b549f..7b7f8f9 100644 --- a/apps/web/app/dash/[slug]/settings/layout.tsx +++ b/apps/web/app/dash/[slug]/settings/layout.tsx @@ -1,24 +1,84 @@ -import { headers } from 'next/headers'; -import CategoryTabs from '@/components/dashboard/settings/category-tabs'; +import { + Blocks, + Earth, + GalleryVerticalEnd, + KanbanSquare, + RefreshCcw, + SquareAsterisk, + SwatchBook, + Terminal, + UsersRoundIcon, + Wallet2, + Webhook, +} from 'lucide-react'; +import { SidebarTabsProps } from '@/lib/types'; +import Sidebar from '@/components/layout/sidebar'; -const tabs = [ - { - name: 'General', - slug: 'general', - }, - { - name: 'Hub', - slug: 'hub', - }, - { - name: 'Team', - slug: 'team', - }, - { - name: 'Integrations', - slug: 'integrations', - }, -]; +const tabs: SidebarTabsProps = { + Workspace: [ + { + name: 'Branding', + customIcon: , + slug: 'settings/general', + }, + { + name: 'Team Members', + customIcon: , + slug: 'settings/team', + }, + { + name: 'Billing', + customIcon: , + slug: 'settings/billing', + }, + ], + Public: [ + { + name: 'Domain', + customIcon: , + slug: 'settings/domain', + }, + { + name: 'SSO', + customIcon: , + slug: 'settings/sso', + }, + ], + Modules: [ + { + name: 'Feedback', + customIcon: , + slug: 'settings/feedback', + }, + { + name: 'Roadmap', + customIcon: , + slug: 'settings/roadmap', + }, + { + name: 'Changelog', + customIcon: , + slug: 'settings/changelog', + }, + ], + Additional: [ + { + name: 'API', + customIcon: , + slug: 'settings/api', + }, + { + name: 'Webhooks', + customIcon: , + slug: 'settings/webhooks', + }, + { + name: 'Integrations', + customIcon: , + slug: 'settings/integrations', + }, + ], +}; export default function SettingsLayout({ children, @@ -27,20 +87,21 @@ export default function SettingsLayout({ children: React.ReactNode; params: { slug: string }; }) { - // Headers - const headerList = headers(); - const pathname = headerList.get('x-pathname'); - - // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.split('/')[3] === tab.slug); - return ( - {/* Navigation tabs */} - + {/* Sidebar */} + {/* Content */} - {children} + + + {children} + + ); } diff --git a/apps/web/app/dash/[slug]/settings/roadmap/page.tsx b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx new file mode 100644 index 0000000..a37a7c4 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx @@ -0,0 +1,17 @@ +import { Label } from '@feedbase/ui/components/label'; +import { Switch } from '@feedbase/ui/components/switch'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Disable Roadmap + + + This will disable the roadmap for your workspace. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/sso/page.tsx b/apps/web/app/dash/[slug]/settings/sso/page.tsx new file mode 100644 index 0000000..ef4b2c9 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/sso/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@feedbase/ui/components/alert-dialog'; +import { Button } from '@feedbase/ui/components/button'; +import { Checkbox } from '@feedbase/ui/components/checkbox'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from '@feedbase/ui/components/responsive-dialog'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import CopyCheckIcon from '@/components/shared/copy-check-icon'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +export default function SSOSettings({ params }: { params: { slug: string } }) { + const [ssoConfig, setSsoConfig] = useState<{ + enabled: boolean; + url: string | null | undefined; + secret: string | null | undefined; + }>({ + enabled: false, + url: null, + secret: '', + }); + const [hasCopied, setHasCopied] = useState(); + const [open, setOpen] = useState(false); + const { workspace, loading, error, mutate } = useWorkspace(); + + const { trigger: updateWorkspaceSSO, isMutating: isUpdatingWorkspaceSSO } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso`, + actionFetcher, + { + onSuccess: () => { + mutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: generateJwtSecret, isMutating: isGenerating } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso/secret`, + actionFetcher, + { + onSuccess: (data: { secret: string }) => { + setSsoConfig({ ...ssoConfig, secret: data.secret }); + setOpen(true); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + useEffect(() => { + if (workspace) { + setSsoConfig({ + enabled: workspace.sso_auth_enabled, + url: workspace.sso_auth_url, + secret: workspace.sso_auth_secret_id ? 'dummy-secret' : undefined, + }); + } + }, [workspace]); + + // Loading state + if (loading && !error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + ); + } + + // Error state + if (error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + + + ); + } + + return ( + { + setSsoConfig({ ...ssoConfig, enabled: checked }); + toast.promise(updateWorkspaceSSO({ method: 'PATCH', enabled: checked }), { + loading: ssoConfig.enabled ? `Disabling Single Sign-On` : `Enabling Single Sign-On`, + success: ssoConfig.enabled ? `Single Sign-On disabled` : `Single Sign-On enabled`, + error: () => { + setSsoConfig({ ...ssoConfig, enabled: !checked }); + return `Failed to ${ssoConfig.enabled ? 'disable' : 'enable'} Single Sign-On`; + }, + }); + }} + description={ + <> + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + } + showSave={workspace?.sso_auth_url !== ssoConfig.url} + onCancel={() => { + setSsoConfig({ ...ssoConfig, url: workspace?.sso_auth_url }); + }} + onSave={async () => { + await updateWorkspaceSSO({ + method: 'PATCH', + enabled: ssoConfig.enabled, + url: ssoConfig.url, + secret: ssoConfig.secret, + }); + }}> + + Login Url + { + setSsoConfig({ ...ssoConfig, url: e.target.value }); + }} + /> + + The url where users will be redirected to login. + + + + + JWT Secret + + + + + + { + setHasCopied(false); + if (!ssoConfig.secret) { + e.preventDefault(); + await generateJwtSecret({}); + setOpen(true); + } + }} + disabled={isGenerating || isUpdatingWorkspaceSSO}> + {isGenerating ? : null} + {ssoConfig.secret ? 'Regenerate' : 'Generate'} + + + + + Are you absolutely sure? + + This action cannot be undone. This will generate a new secret for your Single Sign-On + configuration and invalidate the previous one. + + + + Cancel + { + await generateJwtSecret({}); + }}> + Continue + + + + + + + + Token Created + + This token can not be shown again. Please copy it and store it in a safe place. + + + + + Token + { + setHasCopied(true); + }} + /> + } + /> + + + + + { + setHasCopied(!hasCopied); + }} + id='copied' + /> + + I have copied this token + + + + { + toast.success('Token copied to clipboard'); + }}> + Done + + + + + + + + The secret used to sign the JWT token on your server. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/team/loading.tsx b/apps/web/app/dash/[slug]/settings/team/loading.tsx deleted file mode 100644 index 1753c58..0000000 --- a/apps/web/app/dash/[slug]/settings/team/loading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function TeamLoading() { - return ( - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/team/page.tsx b/apps/web/app/dash/[slug]/settings/team/page.tsx index 1343f64..eb627f2 100644 --- a/apps/web/app/dash/[slug]/settings/team/page.tsx +++ b/apps/web/app/dash/[slug]/settings/team/page.tsx @@ -1,32 +1,396 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getProjectInvites } from '@/lib/api/invites'; -import { getProjectMembers } from '@/lib/api/projects'; -import { TeamTable } from '@/components/dashboard/settings/team-table'; +'use client'; -export default async function TeamSettings({ params }: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(params.slug, 'server'); +import { useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuDestructiveItem, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { ChevronsUpDown, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useTeamInvites from '@/lib/swr/use-team-invites'; +import useTeamMembers from '@/lib/swr/use-team-members'; +import { ExtendedInviteProps, TeamMemberProps } from '@/lib/types'; +import { actionFetcher, formatTimeAgo } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; - if (error) { - return {error.message}; +export default function TeamSettings({ params }: { params: { slug: string } }) { + const { + teamMembers, + error: memberError, + mutate: mutateMember, + isValidating: isValidatingMember, + } = useTeamMembers(); + const { + teamInvites, + error: inviteError, + mutate: mutateInvite, + isValidating: inviteIsValidating, + } = useTeamInvites(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'all' | 'admins' | 'members' | 'invites'>('all'); + const [inviteEmail, setInviteEmail] = useState(''); + + // Filtered + const filteredMembers = teamMembers?.filter( + (member) => + member.full_name.toLowerCase().includes(search.toLowerCase()) || + member.email.toLowerCase().includes(search.toLowerCase()) + ); + const filteredInvites = teamInvites?.filter((invite) => + invite.email.toLowerCase().includes(search.toLowerCase()) + ); + + // Send invite + const { trigger: sendInvite, isMutating: isSendingInvite } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/invites`, + actionFetcher, + { + onSuccess: () => { + toast.success('Invite sent successfully.'); + // mutateMember(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + // Revoking invite + function revokeInvite(inviteId: string) { + const req = fetch(`/api/v1/workspaces/${params.slug}/invites/${inviteId}`, { + method: 'DELETE', + }); + + req.then(() => { + mutateInvite(); + }); + } + + // Error state + if (memberError || inviteError) { + return memberError ? ( + + ) : ( + + ); } - const { data: invites, error: invitesError } = await getProjectInvites(params.slug, 'server'); + // Loading state + if (!filteredMembers && isValidatingMember && !filteredInvites && inviteIsValidating) { + return ( + <> + + + Email + + + + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + + {filter === 'all' && 'members'} + {filter === 'admins' && 'admins'} + {filter === 'members' && 'members'} + {filter === 'invites' && 'pending invites'} + + - if (invitesError) { - return {invitesError.message}; + + + Loading team members... + + + > + ); } return ( - - - - Team - Add or remove users that have access to this project. - - - - - - + <> + + + Email + { + e.preventDefault(); + sendInvite({ email: inviteEmail }); + }}> + { + setInviteEmail(e.target.value); + }} + /> + + {isSendingInvite ? : null} + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + {/* Action Row */} + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + {/* Members */} + + + {filter === 'all' && (filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0)} + {/* TODO: Implement different roles, currently all are handled the same. */} + {filter === 'admins' && (filteredMembers?.length ?? 0)} + {filter === 'members' && (filteredMembers?.length ?? 0)} + {filter === 'invites' && (filteredInvites?.length ?? 0)}{' '} + {filter === 'all' && + ((filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'admins' && ((filteredMembers?.length ?? 0) > 1 ? 'admins' : 'admin')} + {filter === 'members' && ((filteredMembers?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'invites' && + ((filteredInvites?.length ?? 0) > 1 ? 'pending invites' : 'pending invite')} + + + + {/* Team Members */} + {(filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.map((member: TeamMemberProps) => ( + + + + + + {member.full_name[0]} + + + + {member.full_name} + {member.email} + + + + + {/* Joined days ago */} + + Joined {formatTimeAgo(new Date(member.joined_at))} ago + + + + + + + + + Make Admin + Suspend + + + + + ))} + + {/* Pending Invites */} + {(filter === 'all' || filter === 'invites') && + filteredInvites?.map((invite: ExtendedInviteProps) => ( + + + + + {invite.email[0]} + + + + {invite.email} + + Invited by {invite.creator.full_name} + + + + + + + Invited {formatTimeAgo(new Date(invite.created_at))} ago + + + + + + + + + + Resend + { + revokeInvite(invite.id); + }}> + Revoke + + + + + + ))} + + {/* Empty States */} + {((filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.length === 0 && + filteredInvites?.length === 0) || + (filter === 'invites' && filteredInvites?.length === 0) ? ( + + + {filter === 'all' || filter === 'admins' || filter === 'members' + ? 'No members found.' + : 'No pending invites found.'} + + + ) : null} + + + + + > ); } diff --git a/apps/web/app/dash/invite/[inviteId]/page.tsx b/apps/web/app/dash/invite/[inviteId]/page.tsx index 6b817f9..c8fa036 100644 --- a/apps/web/app/dash/invite/[inviteId]/page.tsx +++ b/apps/web/app/dash/invite/[inviteId]/page.tsx @@ -1,10 +1,10 @@ import { notFound } from 'next/navigation'; -import { getProjectInvite } from '@/lib/api/invites'; +import { getWorkspaceInvite } from '@/lib/api/invite'; import { getCurrentUser } from '@/lib/api/user'; -import ProjectInviteForm from '@/components/layout/accept-invite-form'; +import WorkspaceInviteForm from '@/components/workspace/accept-invite-form'; -export default async function ProjectInvite({ params }: { params: { inviteId: string } }) { - const { data: invite, error: inviteError } = await getProjectInvite(params.inviteId, 'server'); +export default async function WorkspaceInvite({ params }: { params: { inviteId: string } }) { + const { data: invite, error: inviteError } = await getWorkspaceInvite(params.inviteId, 'server'); if (inviteError) { return notFound(); @@ -15,7 +15,7 @@ export default async function ProjectInvite({ params }: { params: { inviteId: st return ( - + ); } diff --git a/apps/web/app/dash/page.tsx b/apps/web/app/dash/page.tsx index b9444a0..12760b8 100644 --- a/apps/web/app/dash/page.tsx +++ b/apps/web/app/dash/page.tsx @@ -1,9 +1,10 @@ import { redirect } from 'next/navigation'; -import { getUserProjects } from '@/lib/api/user'; -import Onboarding from '@/components/layout/onboarding'; +import { getUserWorkspaces } from '@/lib/api/user'; +import Onboarding from '@/components/workspace/onboarding'; +import WorkspaceOverview from '@/components/workspace/workspace-overview'; -export default async function Projects() { - const { data: projects, error } = await getUserProjects('server'); +export default async function Workspaces() { + const { data: workspaces, error } = await getUserWorkspaces('server'); if (error) { // Redirect to login if the user is not authenticated @@ -14,15 +15,10 @@ export default async function Projects() { return {error.message}; } - // Redirect to the first project - if (projects.length > 0) { - return redirect(`/${projects[0].slug}`); - } - - // TODO: Improve this and make this redirect to an onboarding page if the user has no projects + // TODO: Improve this and make this redirect to an onboarding page if the user has no workspaces return ( - + {workspaces && workspaces.length > 0 ? : } ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index cfce9bc..13871c1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,77 +1,127 @@ @tailwind base; @tailwind components; @tailwind utilities; - + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* + https://github.com/tailwindlabs/tailwindcss/discussions/2394 + https://github.com/tailwindlabs/tailwindcss/pull/5732 +*/ +@layer utilities { + /* Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .custom-scrollbar::-webkit-scrollbar { + width: 14px; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); + background-clip: padding-box; + border-radius: 50px; + background-color: hsl(var(--border) / 1); + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--foreground) / 0.15); + } +} + @layer base { :root { /* App root background */ - --root-background: 230 35% 3%; /* #000000 */ + --root-background: 0 0% 100%; + --background: 210 20% 98%; + --foreground: 224 71% 4%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 224 71% 4%; + --primary-foreground: 0 0% 98%; + --secondary: 240 5% 95%; + --secondary-foreground: 215 14% 34%; + --muted: 240 5% 96%; + --muted-foreground: 218 11% 65%; + --accent: 240 5% 94%; + --accent-foreground: 224 71% 4%; + --destructive: 0 79% 63%; + --destructive-foreground: 0 0% 100%; + --border: 220 13% 91%; + --input: 220 13% 91%; + --highlight: 231 100% 78%; + --ring: 216 12% 84%; + + /* Charts */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + --radius: 0.55rem; + } + + .dark { + /* App root background */ + --root-background: 235 7% 5%; /* Utility background (Buttons, inputs, etc) */ - --background: 230 11% 12%; /* #0A0C10 | #060606*/ - --foreground: 210 40% 98%; /* #D6D6D6 */ - + --background: 230 7% 16%; + --foreground: 220 9% 94%; + /* Muted background (Skeletons, Avatar, tabs, etc) */ - --muted: 230 11% 8%; /* #20242c */ - --muted-foreground: 215 6% 62%; - - --popover: 230 35% 3%; /* #0A0C10 */ - --popover-foreground: 210 40% 98%; - - --card: 230 35% 3%; /* #0A0C10 */ - --card-foreground: 210 40% 98%; - - --border: 222 9% 22%; /* #20242c */ - --input: 222 9% 22%; /* #20242c */ - - --primary: 210 40% 98%; - --primary-foreground: 230 35% 3%; - + --muted: 219 6% 12%; + --muted-foreground: 219 6% 65%; + + --popover: 235 7% 5%; + --popover-foreground: 220 9% 94%; + + --card: 235 7% 5%; + --card-foreground: 220 9% 94%; + + --border: 223 7% 19%; + --input: 223 6% 22%; + + --primary: 220 9% 94%; + --primary-foreground: 240 4% 10%; + /* Hover & active backgrounds (Nav buttons, etc) */ - --secondary: 227 11% 12%; /* #20242c */ - --secondary-foreground: 210 40% 98%; - + --secondary: 230 7% 16%; + --secondary-foreground: 218 7% 80%; + /* Button background hovers */ - --accent: 227 11% 15%; /* #20242c */ - --accent-foreground: 210 40% 98%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - + --accent: 230 7% 18%; + --accent-foreground: 220 9% 94%; + + --destructive: 0 79% 63%; + --destructive-foreground: 220 9% 94%; + /* Selection & accent color */ --highlight: 231 100% 78%; - - --ring: 230 9% 13%; /* #20242c */ - --radius: 0.5rem; - } - - .light { - /* App root background */ - --root-background: 0 0% 100%; /* #000000 */ - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --highlight: 231 100% 78%; - --ring: 240 5.9% 10%; + /* Charts */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + --ring: 230 9% 13%; } } - + @layer base { * { @apply border-border; @@ -79,4 +129,4 @@ body { @apply bg-root text-foreground selection:bg-highlight/20 selection:text-highlight; } -} \ No newline at end of file +} diff --git a/apps/web/app/home/(pages)/deploy/page.tsx b/apps/web/app/home/(pages)/deploy/page.tsx index b5f2ada..c98b5ee 100644 --- a/apps/web/app/home/(pages)/deploy/page.tsx +++ b/apps/web/app/home/(pages)/deploy/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Button } from '@feedbase/ui/components/button'; import HomeFooter from '@/components/home/footer'; export default function Deploy() { @@ -9,7 +9,7 @@ export default function Deploy() { Deploy Feedbase to Vercel - + You can deploy your own hosted instance of Feedbase to Vercel, for free, in just a few clicks. @@ -27,7 +27,7 @@ export default function Deploy() { Create a Supabase project - + Supabase is an open source Firebase alternative. It provides a database, auth, and storage. @@ -51,7 +51,7 @@ export default function Deploy() { Fork the Supabase database - + Once you have your Supabase project, you can fork the Feedbase database schemas to your own project. @@ -79,7 +79,7 @@ export default function Deploy() { Prepare your environment variables - + After setting up your Supabase project, check the example configuration to see where you can find the necessary environment variables. @@ -107,7 +107,7 @@ export default function Deploy() { Deploy to Vercel - + Once you have your environment variables, you can deploy to Vercel. diff --git a/apps/web/app/home/(pages)/page.tsx b/apps/web/app/home/(pages)/page.tsx index a58c6ad..15e2c4d 100644 --- a/apps/web/app/home/(pages)/page.tsx +++ b/apps/web/app/home/(pages)/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Background } from '@feedbase/ui/components/background/background'; +import { Button } from '@feedbase/ui/components/button'; import { ArrowRight, ChevronRight } from 'lucide-react'; -import { Background } from 'ui/components/ui/background/background'; import { formatRootUrl } from '@/lib/utils'; import ChangelogSection from '@/components/home/changelog-section'; import DashboardSection from '@/components/home/dashboard-section'; @@ -27,7 +27,7 @@ export default function Landing() { in One Central Place - + Feedbase simplifies feedback collection, feature prioritization, and product update sharing, allowing you to focus on building. @@ -39,7 +39,7 @@ export default function Landing() { Star on GitHub @@ -48,7 +48,7 @@ export default function Landing() { + className='text-foreground/60 hover:text-foreground/90 group relative mt-4 flex w-fit flex-row items-center justify-center p-1 text-center text-sm '> See it in action @@ -73,7 +73,7 @@ export default function Landing() { Create your feedback community today. - + Capture feedback, post updates, and engage with your users in one central place. diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8932134..274aa79 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,11 +1,12 @@ import './globals.css'; import { Metadata, Viewport } from 'next'; import Script from 'next/script'; -import { cn } from '@ui/lib/utils'; +import { cn } from '@feedbase/ui/lib/utils'; import { SpeedInsights } from '@vercel/speed-insights/next'; -import { GeistSans } from 'geist/font'; +import { GeistSans } from 'geist/font/sans'; import { Toaster } from 'sonner'; import { formatRootUrl } from '@/lib/utils'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; export const metadata: Metadata = { title: 'Feedbase', @@ -49,9 +50,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) )} - - {children} - + + + {children} + +
+ + {new Date(changelog.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', @@ -156,7 +162,7 @@ export default async function Changelogs({ params }: Props) { {/* Image */} {/* Summary */} - {projectConfig.changelog_preview_style === 'summary' && ( - + {workspaceConfig.changelog_preview_style === 'summary' && ( + {changelog.summary} )} - {projectConfig.changelog_preview_style === 'content' && ( + {workspaceConfig.changelog_preview_style === 'content' && ( )} @@ -198,8 +204,8 @@ export default async function Changelogs({ params }: Props) { {/* Empty State */} {changelogs.length === 0 && ( - No changelogs yet - + No changelogs yet + The latest updates, improvements, and fixes will be posted here. Stay tuned! diff --git a/apps/web/app/[project]/changelog/unsubscribe/page.tsx b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx similarity index 54% rename from apps/web/app/[project]/changelog/unsubscribe/page.tsx rename to apps/web/app/[workspace]/changelog/unsubscribe/page.tsx index a6af8c2..e6d305c 100644 --- a/apps/web/app/[project]/changelog/unsubscribe/page.tsx +++ b/apps/web/app/[workspace]/changelog/unsubscribe/page.tsx @@ -1,12 +1,12 @@ import { notFound, redirect } from 'next/navigation'; -import { getProjectBySlug } from '@/lib/api/projects'; -import UnsubscribeChangelogCard from '@/components/layout/unsubscribe-card'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import UnsubscribeChangelogCard from '@/components/changelog/unsubscribe-card'; export default async function ChangelogUnsubscribe({ params, searchParams, }: { - params: { project: string }; + params: { workspace: string }; searchParams: { subId: string }; }) { if (!searchParams.subId) { @@ -20,17 +20,17 @@ export default async function ChangelogUnsubscribe({ redirect('/'); } - // Get project - const { data: project, error } = await getProjectBySlug(params.project, 'server', true, false); + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); - // If project is undefined redirects to 404 - if (error?.status === 404 || !project) { + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { notFound(); } return ( - + ); } diff --git a/apps/web/app/[project]/feedback/[id]/page.tsx b/apps/web/app/[workspace]/feedback/[id]/page.tsx similarity index 70% rename from apps/web/app/[project]/feedback/[id]/page.tsx rename to apps/web/app/[workspace]/feedback/[id]/page.tsx index 54b8bd6..0a3d013 100644 --- a/apps/web/app/[project]/feedback/[id]/page.tsx +++ b/apps/web/app/[workspace]/feedback/[id]/page.tsx @@ -1,27 +1,31 @@ import { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { Avatar, AvatarFallback, AvatarImage } from '@ui/components/ui/avatar'; -import { Separator } from '@ui/components/ui/separator'; -import { cn } from '@ui/lib/utils'; -import { BadgeCheck, CheckCircle2, CircleDashed, CircleDot, CircleDotDashed, XCircle } from 'lucide-react'; -import { getCommentsForFeedbackById } from '@/lib/api/comments'; -import { getPublicProjectFeedback } from '@/lib/api/public'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Separator } from '@feedbase/ui/components/separator'; +import { cn } from '@feedbase/ui/lib/utils'; +import { BadgeCheck } from 'lucide-react'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; import { getCurrentUser } from '@/lib/api/user'; -import { PROSE_CN } from '@/lib/constants'; -import AnalyticsWrapper from '@/components/hub/analytics-wrapper'; -import CommentsList from '@/components/hub/feedback/comments/comments-list'; +import { PROSE_CN, STATUS_OPTIONS } from '@/lib/constants'; +import CommentsList from '@/components/feedback/hub/comments-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; type Props = { - params: { project: string; id: string }; + params: { workspace: string; id: string }; }; // Metadata export async function generateMetadata({ params }: Props): Promise { // Get feedback - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); - // If project is undefined redirects to 404 + // If workspace is undefined redirects to 404 if (error?.status === 404 || !feedbackList) { notFound(); } @@ -36,36 +40,17 @@ export async function generateMetadata({ params }: Props): Promise { return { title: feedback.title, - description: feedback.description, + description: feedback.content, }; } -// Status options -const statusOptions = [ - { - label: 'Backlog', - icon: CircleDashed, - }, - { - label: 'Planned', - icon: CircleDotDashed, - }, - { - label: 'In Progress', - icon: CircleDot, - }, - { - label: 'Completed', - icon: CheckCircle2, - }, - { - label: 'Rejected', - icon: XCircle, - }, -]; - export default async function FeedbackDetails({ params }: Props) { - const { data: feedbackList, error } = await getPublicProjectFeedback(params.project, 'server', true, false); + const { data: feedbackList, error } = await getPublicWorkspaceFeedback( + params.workspace, + 'server', + true, + false + ); if (error || !feedbackList) { return {error.message}; @@ -79,31 +64,19 @@ export default async function FeedbackDetails({ params }: Props) { notFound(); } - // Get comments - const { data: comments, error: commentsError } = await getCommentsForFeedbackById( - params.id, - params.project, - 'server', - false - ); - - if (commentsError) { - return {commentsError.message}; - } - // Get current user const { data: user } = await getCurrentUser('server'); return ( - + {/* // Row Splitting up date and Content */} {/* Back Button */} - - + + ← Back to Posts @@ -117,8 +90,8 @@ export default async function FeedbackDetails({ params }: Props) { {feedback.title} @@ -128,38 +101,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - - {currentStatus.label} - + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -171,7 +142,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -181,7 +152,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -193,9 +164,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -206,17 +177,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -226,19 +195,14 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} {/* Comments */} - + @@ -248,36 +212,36 @@ export default async function FeedbackDetails({ params }: Props) { {/* Upvotes */} - Upvotes + Upvotes {/* Upvotes */} - {feedback.upvotes} + {feedback.upvotes} {/* Status */} - Status + Status {(() => { if (feedback.status) { const currentStatus = - statusOptions.find( + STATUS_OPTIONS.find( (option) => option.label.toLowerCase() === feedback.status?.toLowerCase() - ) || statusOptions[0]; + ) || STATUS_OPTIONS[0]; return ( - + - {currentStatus.label} + {currentStatus.label} ); } - return No status; + return No status; })()} {/* Tags */} - Tags + Tags {/* Grid with all tags */} @@ -289,7 +253,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Tag color */} {/* Tag name */} - + {tag.name} @@ -299,7 +263,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Empty State */} {(!feedback.tags || feedback.tags.length === 0) && ( - No tags + No tags )} @@ -311,9 +275,9 @@ export default async function FeedbackDetails({ params }: Props) { {/* Created */} - Created + Created - + {new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -324,17 +288,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */} - Author + Author {/* Author */} - + {/* User */} - - {feedback.user.full_name[0]} - + {feedback.user.full_name[0]} {/* If team member, add small verified badge to top of profile picture */} {feedback.user.isTeamMember ? ( @@ -345,7 +307,7 @@ export default async function FeedbackDetails({ params }: Props) { {/* Name */} - {feedback.user.full_name} + {feedback.user.full_name} diff --git a/apps/web/app/[project]/page.tsx b/apps/web/app/[workspace]/feedback/page.tsx similarity index 73% rename from apps/web/app/[project]/page.tsx rename to apps/web/app/[workspace]/feedback/page.tsx index c6f6f88..2c59ed8 100644 --- a/apps/web/app/[project]/page.tsx +++ b/apps/web/app/[workspace]/feedback/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; -export default async function Hub() { +export default async function Feedback() { // Redirect to changelog // This page doesn't really get called as this is catched in layout.tsx but still needed to cause 404 - redirect(`/feedback`); + redirect(`/`); } diff --git a/apps/web/app/[workspace]/layout.tsx b/apps/web/app/[workspace]/layout.tsx new file mode 100644 index 0000000..da4072e --- /dev/null +++ b/apps/web/app/[workspace]/layout.tsx @@ -0,0 +1,160 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceTheme } from '@/lib/api/theme'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Header from '@/components/layout/nav-bar'; +import CustomThemeWrapper from '@/components/layout/theme-wrapper'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; + +type Props = { + children: React.ReactNode; + params: { workspace: string }; + searchParams: Record; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: workspace.name, + description: `Discover the latest updates, roadmaps, submit feedback, and explore more about ${workspace.name}.`, + icons: workspace.icon, + openGraph: { + images: [ + { + url: workspace.opengraph_image || '', + width: 1200, + height: 600, + alt: workspace.name, + }, + ], + }, + }; +} + +const tabs: { name: string; link: string; items?: { name: string; link: string }[] }[] = [ + { + name: 'Boards', + link: '/', + items: [], + }, + { + name: 'Roadmap', + link: '/roadmap', + }, + { + name: 'Changelog', + link: '/changelog', + }, +]; + +export default async function HubLayout({ children, params, searchParams }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + const hostname = headerList.get('host'); + + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + if (error?.status === 404 || !workspace) { + notFound(); + } + + // Get workspace boards + const { data: boards, error: boardsError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardsError || !boards) { + notFound(); + } + + // Set workspace boards to tabs + tabs[0].items = boards.map((board) => ({ + name: board.name, + link: `/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`, + })); + + // Get workspace config + const { data: config } = await getWorkspaceModuleConfig(params.workspace, 'server', true, false); + + // Get workspace theme + const { data: workspaceTheme } = await getWorkspaceTheme(params.workspace, 'server', true, false); + + if (!config || !workspaceTheme) { + notFound(); + } + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Check if custom domain is set and redirect to it + if (workspace.custom_domain && workspace.custom_domain_verified && hostname !== workspace.custom_domain) { + // Validate redirect rules + switch (workspace.custom_domain_redirect) { + case 'root_redirect': + return redirect(`https://${workspace.custom_domain}`); + case 'direct_redirect': + return redirect(`https://${workspace.custom_domain}${pathname}`); + case 'no_redirect': + break; + } + } + + // Get current tab + const currentTab = tabs.find((tab) => { + if (tab.link === pathname) { + return true; + } + if (tab.items) { + const subItem = tab.items.find((item) => item.link === pathname); + if (subItem) { + // Return the subitem directly + return subItem; + } + } + return false; + }); + + // Extract subItem if found, otherwise use the main tab + const foundItem = currentTab?.items?.find((item) => item.link === pathname) || currentTab; + + // Check if any modules are disabled and remove them from the tabs + if (!config.changelog_enabled) { + tabs.splice(1, 1); + } + + return ( + // + + {/* Header */} + + + {/* Main content */} + + {children} + + + ); +} diff --git a/apps/web/app/[workspace]/page.tsx b/apps/web/app/[workspace]/page.tsx new file mode 100644 index 0000000..ff32044 --- /dev/null +++ b/apps/web/app/[workspace]/page.tsx @@ -0,0 +1,98 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceBoards } from '@/lib/api/boards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import FeedbackBoardList from '@/components/feedback/hub/board-list'; +import FeedbackHeader from '@/components/feedback/hub/button-header'; +import FeedbackList from '@/components/feedback/hub/feedback-list'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Feedback - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Fetch feedback boards + const { data: boards, error: boardError } = await getWorkspaceBoards( + params.workspace, + 'server', + true, + false + ); + + if (boardError) { + return {boardError.message}; + } + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + // Search for the initial board mathing the pathname by /board/board-name format + const initialBoard = boards.find( + (board) => pathname?.includes(`/board/${board.name.toLowerCase().replace(/\s+/g, '-')}`) + ); + + return ( + + {/* Title, Description */} + + + Feedback + + Have a suggestion or found a bug? Let us know! + + + + + {/* Seperator */} + + + + {/* Filter Header, Feedback List */} + + {/* Filter Header */} + + {/* Feedback List */} + + + {/* Boards */} + + + + ); +} diff --git a/apps/web/app/[workspace]/roadmap/page.tsx b/apps/web/app/[workspace]/roadmap/page.tsx new file mode 100644 index 0000000..3648c14 --- /dev/null +++ b/apps/web/app/[workspace]/roadmap/page.tsx @@ -0,0 +1,69 @@ +import { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { Separator } from '@feedbase/ui/components/separator'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getCurrentUser } from '@/lib/api/user'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; +import Roadmap from '@/components/roadmap/hub/roadmap'; +import AnalyticsWrapper from '@/components/shared/analytics-wrapper'; + +type Props = { + params: { workspace: string }; +}; + +// Metadata +export async function generateMetadata({ params }: Props): Promise { + // Get workspace + const { data: workspace, error } = await getWorkspaceBySlug(params.workspace, 'server', true, false); + + // If workspace is undefined redirects to 404 + if (error?.status === 404 || !workspace) { + notFound(); + } + + return { + title: `Roadmap - ${workspace.name}`, + description: 'Have a suggestion or found a bug? Let us know!', + }; +} + +export default async function Feedback({ params }: Props) { + const headerList = headers(); + const pathname = headerList.get('x-pathname'); + + // Get current user + const { data: user } = await getCurrentUser('server'); + + // Get workspace module config + const { data: moduleConfig, error: moduleError } = await getWorkspaceModuleConfig( + params.workspace, + 'server', + true, + false + ); + + if (moduleError) { + return {moduleError.message}; + } + + return ( + + {/* Title, Description */} + + + Roadmap + + View what we're working on and what's coming next. + + + + + {/* Seperator */} + + + {/* Roadmap */} + + + ); +} diff --git a/apps/web/app/api/trigger/route.ts b/apps/web/app/api/trigger/route.ts new file mode 100644 index 0000000..0b08117 --- /dev/null +++ b/apps/web/app/api/trigger/route.ts @@ -0,0 +1,9 @@ +import { client } from '@feedbase/triggers'; +import { createAppRoute } from '@trigger.dev/nextjs'; +import '@feedbase/triggers/jobs'; + +//this route is used to send and receive data with Trigger.dev +export const { POST, dynamic } = createAppRoute(client); + +//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration +export const maxDuration = 60; diff --git a/apps/web/app/api/v1/[slug]/atom/route.ts b/apps/web/app/api/v1/[slug]/atom/route.ts index 1218c5b..198eaeb 100644 --- a/apps/web/app/api/v1/[slug]/atom/route.ts +++ b/apps/web/app/api/v1/[slug]/atom/route.ts @@ -1,20 +1,20 @@ import { NextResponse } from 'next/server'; -import { getProjectBySlug } from '@/lib/api/projects'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; /* - Generate atom feed for project changelog + Generate atom feed for workspace changelog */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project data - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route', true, false); + // Get workspace data + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - const { data: changelogs, error: changelogError } = await getPublicProjectChangelogs( + const { data: changelogs, error: changelogError } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, @@ -30,12 +30,12 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return new Response( ` - ${project.name} Changelog - ${project.name}'s Changelog + ${workspace.name} Changelog + ${workspace.name}'s Changelog ${changelogs[0].publish_date} - ${project.id}${changelogs + ${workspace.id}${changelogs .map((post) => { return ` diff --git a/apps/web/app/api/v1/[slug]/changelogs/route.ts b/apps/web/app/api/v1/[slug]/changelogs/route.ts index 8650092..1f46ed6 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getPublicProjectChangelogs } from '@/lib/api/public'; +import { getPublicWorkspaceChangelogs } from '@/lib/api/public'; /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getPublicProjectChangelogs( + const { data: changelogs, error } = await getPublicWorkspaceChangelogs( context.params.slug, 'route', true, diff --git a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts index f225b75..0f96984 100644 --- a/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import { subscribeToProjectChangelogs, unsubscribeFromProjectChangelogs } from '@/lib/api/public'; +import { subscribeToWorkspaceChangelogs, unsubscribeFromWorkspaceChangelogs } from '@/lib/api/public'; /* - Subscribe to project changelogs + Subscribe to workspace changelogs POST /api/v1/:slug/changelogs/subscribers { email: string, @@ -16,8 +16,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'email is required.' }, { status: 400 }); } - // Subscribe to project changelogs - const { data: subscriber, error } = await subscribeToProjectChangelogs(context.params.slug, email); + // Subscribe to workspace changelogs + const { data: subscriber, error } = await subscribeToWorkspaceChangelogs(context.params.slug, email); // If any errors thrown, return error if (error) { @@ -29,7 +29,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Unsubscribe from project changelogs + Unsubscribe from workspace changelogs DELETE /api/v1/:slug/changelogs/subscribers { subId: string, @@ -43,8 +43,8 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: 'subId is required.' }, { status: 400 }); } - // Unsubscribe from project changelogs - const { error } = await unsubscribeFromProjectChangelogs(context.params.slug, subId); + // Unsubscribe from workspace changelogs + const { error } = await unsubscribeFromWorkspaceChangelogs(context.params.slug, subId); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/[slug]/feedback/route.ts b/apps/web/app/api/v1/[slug]/feedback/route.ts new file mode 100644 index 0000000..51e8479 --- /dev/null +++ b/apps/web/app/api/v1/[slug]/feedback/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { getPublicWorkspaceFeedback } from '@/lib/api/public'; + +/* + Get public feedback + GET /api/v1/[slug]/feedback + */ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: feedback, error } = await getPublicWorkspaceFeedback( + context.params.slug, + 'route', + true, + false + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/[slug]/sso/route.ts b/apps/web/app/api/v1/[slug]/sso/route.ts index 889291e..92ead6c 100644 --- a/apps/web/app/api/v1/[slug]/sso/route.ts +++ b/apps/web/app/api/v1/[slug]/sso/route.ts @@ -2,7 +2,7 @@ import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@supabase/supabase-js'; import { JwtPayload, verify } from 'jsonwebtoken'; -import { getProjectBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; export async function GET(req: NextRequest, context: { params: { slug: string } }) { const redirectTo = req.nextUrl.searchParams.get('redirect_to'); @@ -18,33 +18,33 @@ export async function GET(req: NextRequest, context: { params: { slug: string } const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY); - // Get project by slug - const { data: project, error: projectError } = await getProjectBySlug( + // Get workspace by slug + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug( context.params.slug, 'route', true, false ); - if (projectError) { - return NextResponse.json(projectError, { status: 500 }); + if (workspaceError) { + return NextResponse.json(workspaceError, { status: 500 }); } - // Get projects jwt secret - const { data: projectConfig, error: projectConfigError } = await supabase - .from('project_configs') + // Get workspaces jwt secret + const { data: workspaceConfig, error: workspaceConfigError } = await supabase + .from('workspace_configs') .select('integration_sso_secret') - .eq('project_id', project.id) + .eq('workspace_id', workspace.id) .single(); - if (projectConfigError) { - return NextResponse.json(projectConfigError.message, { status: 500 }); + if (workspaceConfigError) { + return NextResponse.json(workspaceConfigError.message, { status: 500 }); } // Verify jwt let payload: JwtPayload; try { - const decoded = verify(jwtPayload, projectConfig?.integration_sso_secret as string); + const decoded = verify(jwtPayload, workspaceConfig?.integration_sso_secret as string); payload = decoded as JwtPayload; } catch (error) { if (error instanceof Error) { @@ -59,7 +59,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid payload' }, { status: 500 }); } - const email = (payload.email as string).replace('@', `+${project.id}@`); + const email = (payload.email as string).replace('@', `+${workspace.id}@`); // Create user based on jwt payload const { error } = await supabase.auth.admin.createUser({ diff --git a/apps/web/app/api/v1/[slug]/views/route.ts b/apps/web/app/api/v1/[slug]/views/route.ts index 9b038e7..ec0681e 100644 --- a/apps/web/app/api/v1/[slug]/views/route.ts +++ b/apps/web/app/api/v1/[slug]/views/route.ts @@ -3,7 +3,7 @@ import { recordClick } from '@/lib/tinybird'; /* Record page view - POST /api/v1/[project]/views + POST /api/v1/[workspace]/views { "feedbackId": "string", "changelogId": "string", @@ -19,7 +19,7 @@ export async function POST(req: NextRequest, context: { params: { slug: string } const data = await recordClick({ req, - projectId: context.params.slug, + workspaceId: context.params.slug, feedbackId, changelogId, }); diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts b/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts deleted file mode 100644 index 72a5472..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/sso/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; - -/* - Update SSO configuration - PATCH /api/v1/projects/:slug/config/integrations/sso - { - status: boolean, - url: string, - secret: string, - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, url, secret } = await req.json(); - - if (status && (!url || !secret)) { - return NextResponse.json( - { error: 'url and secret are required when enabling SSO integration.' }, - { status: 400 } - ); - } - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - integration_sso_status: status, - integration_sso_url: status ? url : null, - integration_sso_secret: status ? secret : null, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/config/route.ts b/apps/web/app/api/v1/projects/[slug]/config/route.ts deleted file mode 100644 index 938fdfb..0000000 --- a/apps/web/app/api/v1/projects/[slug]/config/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; -import { ProjectConfigProps } from '@/lib/types'; - -/* - Get Project Config by slug - GET /api/v1/projects/[slug]/config -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: projectConfig, error } = await getProjectConfigBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project config - return NextResponse.json(projectConfig, { status: 200 }); -} - -/* - Update Project Config by slug - PATCH /api/v1/projects/[slug]/config - { - changelog_preview_style: string; - changelog_twitter_handle: string; - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - } = (await req.json()) as ProjectConfigProps['Update']; - - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( - context.params.slug, - { - changelog_enabled: changelogEnabled, - changelog_preview_style: changelogPreviewStyle, - changelog_twitter_handle: changelogTwitterHandle, - feedback_allow_anon_upvoting: feedbackAllowAnonUpvoting, - custom_theme: customTheme, - custom_theme_root: customThemeRoot, - custom_theme_primary_foreground: customThemePrimaryForeground, - custom_theme_background: customThemeBackground, - custom_theme_secondary_background: customThemeSecondaryBackground, - custom_theme_accent: customThemeAccent, - custom_theme_border: customThemeBorder, - logo_redirect_url: logoRedirectUrl, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts b/apps/web/app/api/v1/projects/[slug]/feedback/route.ts deleted file mode 100644 index 7c24e3d..0000000 --- a/apps/web/app/api/v1/projects/[slug]/feedback/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createFeedback, getAllProjectFeedback } from '@/lib/api/feedback'; -import { FeedbackWithUserInputProps } from '@/lib/types'; - -export const runtime = 'edge'; - -/* - Create Feedback - POST /api/v1/projects/[slug]/feedback - { - title: string; - description: string; - status: string; - tags: [id, id, id] - } -*/ -export async function POST(req: Request, context: { params: { slug: string } }) { - const { title, description, status, tags, user } = (await req.json()) as FeedbackWithUserInputProps; - - // Validate Request Body - if (!title) { - return NextResponse.json({ error: 'title is required when creating feedback.' }, { status: 400 }); - } - - const { data: feedback, error } = await createFeedback( - context.params.slug, - { - title: title || '', - description: description || '', - status: status || '', - project_id: 'dummy-id', - user_id: 'dummy-id', - tags: tags || [], - user: user !== null ? user : undefined, - }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} - -/* - Get Project Feedback - GET /api/v1/projects/[slug]/feedback -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route', false); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return feedback - return NextResponse.json(feedback, { status: 200 }); -} diff --git a/apps/web/app/api/v1/projects/[slug]/route.ts b/apps/web/app/api/v1/projects/[slug]/route.ts deleted file mode 100644 index ddfa07b..0000000 --- a/apps/web/app/api/v1/projects/[slug]/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextResponse } from 'next/server'; -import { deleteProjectBySlug, getProjectBySlug, updateProjectBySlug } from '@/lib/api/projects'; -import { ProjectProps } from '@/lib/types'; - -/* - Get project by slug - GET /api/v1/projects/[slug] -*/ -export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: project, error } = await getProjectBySlug(context.params.slug, 'route'); - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return project - return NextResponse.json({ project }, { status: 200 }); -} - -/* - Update project by slug - PATCH /api/v1/projects/[slug] - { - name: string, - slug: string, - icon: string, - icon_radius: string, - og_image: string - } -*/ -export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { - name, - slug, - icon, - icon_radius: iconRadius, - og_image: OGImage, - } = (await req.json()) as ProjectProps['Update']; - - const { data: updatedProject, error } = await updateProjectBySlug( - context.params.slug, - { name, slug, icon, icon_radius: iconRadius, og_image: OGImage }, - 'route' - ); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return updated project - return NextResponse.json(updatedProject, { status: 200 }); -} - -/* - Delete project by slug - DELETE /api/v1/projects/[slug] -*/ -export async function DELETE(req: Request, context: { params: { slug: string } }) { - const { data, error } = await deleteProjectBySlug(context.params.slug, 'route'); - - // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - - // Return success - return NextResponse.json({ data }, { status: 200 }); -} diff --git a/apps/web/app/api/v1/route.ts b/apps/web/app/api/v1/route.ts index 56224ea..eb89aaf 100644 --- a/apps/web/app/api/v1/route.ts +++ b/apps/web/app/api/v1/route.ts @@ -23,16 +23,16 @@ export function GET(): NextResponse { }, ], paths: { - '/{projectSlug}/atom': { + '/{workspaceSlug}/atom': { get: { - description: 'Generate atom feed for project changelog', - operationId: 'getProjectChangelogsAtom', + description: 'Generate atom feed for workspace changelog', + operationId: 'getWorkspaceChangelogsAtom', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -51,7 +51,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -61,7 +61,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -73,16 +73,16 @@ export function GET(): NextResponse { }, }, }, - '/{projectSlug}/changelogs': { + '/{workspaceSlug}/changelogs': { get: { - description: 'Get public project changelogs', - operationId: 'getPublicProjectChangelogs', + description: 'Get public workspace changelogs', + operationId: 'getPublicWorkspaceChangelogs', security: [], parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -104,7 +104,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -114,7 +114,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -126,15 +126,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}': { + '/workspaces/{workspaceSlug}': { get: { - description: 'Get a project by slug', - operationId: 'getProjectBySlug', + description: 'Get a workspace by slug', + operationId: 'getWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -147,13 +147,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -163,7 +163,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -175,13 +175,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update a project by slug', - operationId: 'updateProjectBySlug', + description: 'Update a workspace by slug', + operationId: 'updateWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -189,12 +189,12 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project object that needs to be updated', + description: 'Workspace object that needs to be updated', required: true, content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectUpdate', + $ref: '#/components/schemas/WorkspaceUpdate', }, }, }, @@ -205,13 +205,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -221,7 +221,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -233,13 +233,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete a project by slug', - operationId: 'deleteProjectBySlug', + description: 'Delete a workspace by slug', + operationId: 'deleteWorkspaceBySlug', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -252,13 +252,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/Project', + $ref: '#/components/schemas/Workspace', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -268,7 +268,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -280,15 +280,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs': { + '/workspaces/{workspaceSlug}/changelogs': { get: { - description: 'Get all project changelogs', - operationId: 'getProjectChangelogs', + description: 'Get all workspace changelogs', + operationId: 'getWorkspaceChangelogs', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -310,7 +310,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -320,7 +320,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -332,13 +332,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project changelog', - operationId: 'createProjectChangelog', + description: 'Create workspace changelog', + operationId: 'createWorkspaceChangelog', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -368,7 +368,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -378,7 +378,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -390,15 +390,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/changelogs/{changelogId}': { + '/workspaces/{workspaceSlug}/changelogs/{changelogId}': { put: { - description: 'Update project changelog by id', - operationId: 'updateProjectChangelogById', + description: 'Update workspace changelog by id', + operationId: 'updateWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -437,21 +437,21 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, delete: { - description: 'Delete project changelog by id', - operationId: 'deleteProjectChangelogById', + description: 'Delete workspace changelog by id', + operationId: 'deleteWorkspaceChangelogById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -479,23 +479,23 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or changelog id supplied', + description: 'Invalid workspace slug or changelog id supplied', }, 404: { - description: 'Project or changelog not found', + description: 'Workspace or changelog not found', }, }, }, }, - '/projects/{projectSlug}/config': { + '/workspaces/{workspaceSlug}/config': { get: { - description: 'Get project config', - operationId: 'getProjectConfig', + description: 'Get workspace config', + operationId: 'getWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -508,13 +508,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -524,7 +524,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -536,13 +536,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project config', - operationId: 'updateProjectConfig', + description: 'Update workspace config', + operationId: 'updateWorkspaceConfig', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -550,13 +550,13 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project config object that needs to be updated', + description: 'Workspace config object that needs to be updated', required: true, content: { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfigUpdate', + $ref: '#/components/schemas/WorkspaceConfigUpdate', }, }, }, @@ -568,13 +568,13 @@ export function GET(): NextResponse { 'application/json': { schema: { type: 'object', - $ref: '#/components/schemas/ProjectConfig', + $ref: '#/components/schemas/WorkspaceConfig', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -584,7 +584,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -596,15 +596,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback': { + '/workspaces/{workspaceSlug}/feedback': { get: { - description: 'Get all project feedback', - operationId: 'getProjectFeedback', + description: 'Get all workspace feedback', + operationId: 'getWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -626,7 +626,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -636,7 +636,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -648,13 +648,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create project feedback', - operationId: 'createProjectFeedback', + description: 'Create workspace feedback', + operationId: 'createWorkspaceFeedback', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -684,7 +684,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -694,7 +694,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -706,15 +706,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}': { get: { - description: 'Get project feedback by id', - operationId: 'getProjectFeedbackById', + description: 'Get workspace feedback by id', + operationId: 'getWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -742,7 +742,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -764,13 +764,13 @@ export function GET(): NextResponse { }, }, patch: { - description: 'Update project feedback by id', - operationId: 'updateProjectFeedbackById', + description: 'Update workspace feedback by id', + operationId: 'updateWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -809,7 +809,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -831,13 +831,13 @@ export function GET(): NextResponse { }, }, delete: { - description: 'Delete project feedback by id', - operationId: 'deleteProjectFeedbackById', + description: 'Delete workspace feedback by id', + operationId: 'deleteWorkspaceFeedbackById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -865,7 +865,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -887,15 +887,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments': { get: { description: 'Get feedback comments', operationId: 'getFeedbackComments', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -926,7 +926,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -936,7 +936,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -948,15 +948,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/comments/{commentId}': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/comments/{commentId}': { delete: { - description: 'Delete project feedback comment by id', - operationId: 'deleteProjectFeedbackCommentById', + description: 'Delete workspace feedback comment by id', + operationId: 'deleteWorkspaceFeedbackCommentById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -993,7 +993,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1003,7 +1003,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or feedback not found', + description: 'Workspace or feedback not found', content: { 'application/json': { schema: { @@ -1015,15 +1015,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/{feedbackId}/upvotes': { + '/workspaces/{workspaceSlug}/feedback/{feedbackId}/upvotes': { get: { description: 'Get feedback upvoters', operationId: 'getFeedbackUpvoters', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1054,7 +1054,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or feedback id supplied', + description: 'Invalid workspace slug or feedback id supplied', content: { 'application/json': { schema: { @@ -1076,15 +1076,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags': { + '/workspaces/{workspaceSlug}/feedback/tags': { get: { description: 'Get feedback tags', operationId: 'getFeedbackTags', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1106,7 +1106,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1116,7 +1116,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1132,9 +1132,9 @@ export function GET(): NextResponse { operationId: 'createFeedbackTag', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1164,7 +1164,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1174,7 +1174,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1186,15 +1186,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/feedback/tags/{tagName}': { + '/workspaces/{workspaceSlug}/feedback/tags/{tagName}': { delete: { description: 'Delete feedback tag by name', operationId: 'deleteFeedbackTagByName', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1222,7 +1222,7 @@ export function GET(): NextResponse { }, }, 400: { - description: 'Invalid project slug or tag name supplied', + description: 'Invalid workspace slug or tag name supplied', content: { 'application/json': { schema: { @@ -1232,7 +1232,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project or tag not found', + description: 'Workspace or tag not found', content: { 'application/json': { schema: { @@ -1244,15 +1244,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites': { + '/workspaces/{workspaceSlug}/invites': { get: { - description: 'List project team invites', - operationId: 'getProjectInvites', + description: 'List workspace team invites', + operationId: 'getWorkspaceInvites', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1267,14 +1267,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ExtendedProjectInvite', + $ref: '#/components/schemas/ExtendedWorkspaceInvite', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1284,7 +1284,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1296,13 +1296,13 @@ export function GET(): NextResponse { }, }, post: { - description: 'Create a project invite', - operationId: 'createProjectInvite', + description: 'Create a workspace invite', + operationId: 'createWorkspaceInvite', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1310,7 +1310,7 @@ export function GET(): NextResponse { }, ], requestBody: { - description: 'Project invite object that needs to be created', + description: 'Workspace invite object that needs to be created', required: true, content: { 'application/json': { @@ -1331,13 +1331,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1347,7 +1347,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1359,15 +1359,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/invites/{inviteId}': { + '/workspaces/{workspaceSlug}/invites/{inviteId}': { delete: { - description: 'Delete project invite by id', - operationId: 'deleteProjectInviteById', + description: 'Delete workspace invite by id', + operationId: 'deleteWorkspaceInviteById', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1389,13 +1389,13 @@ export function GET(): NextResponse { content: { 'application/json': { schema: { - $ref: '#/components/schemas/ProjectInvite', + $ref: '#/components/schemas/WorkspaceInvite', }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1405,7 +1405,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1417,15 +1417,15 @@ export function GET(): NextResponse { }, }, }, - '/projects/{projectSlug}/members': { + '/workspaces/{workspaceSlug}/members': { get: { - description: 'Get project members', - operationId: 'getProjectMembers', + description: 'Get workspace members', + operationId: 'getWorkspaceMembers', parameters: [ { - name: 'projectSlug', + name: 'workspaceSlug', in: 'path', - description: 'Project slug', + description: 'Workspace slug', required: true, schema: { type: 'string', @@ -1441,14 +1441,14 @@ export function GET(): NextResponse { schema: { type: 'array', items: { - $ref: '#/components/schemas/ProjectMember', + $ref: '#/components/schemas/WorkspaceMember', }, }, }, }, }, 400: { - description: 'Invalid project slug supplied', + description: 'Invalid workspace slug supplied', content: { 'application/json': { schema: { @@ -1458,7 +1458,7 @@ export function GET(): NextResponse { }, }, 404: { - description: 'Project not found', + description: 'Workspace not found', content: { 'application/json': { schema: { @@ -1473,7 +1473,7 @@ export function GET(): NextResponse { }, components: { schemas: { - Project: { + Workspace: { type: 'object', properties: { id: { @@ -1501,7 +1501,7 @@ export function GET(): NextResponse { }, }, }, - ProjectUpdate: { + WorkspaceUpdate: { type: 'object', properties: { name: { @@ -1528,7 +1528,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1567,7 +1567,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1667,14 +1667,14 @@ export function GET(): NextResponse { }, }, }, - ProjectConfig: { + WorkspaceConfig: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1705,7 +1705,7 @@ export function GET(): NextResponse { }, }, }, - ProjectConfigUpdate: { + WorkspaceConfigUpdate: { type: 'object', properties: { changelog_preview_style: { @@ -1723,7 +1723,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1759,7 +1759,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -1998,7 +1998,7 @@ export function GET(): NextResponse { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2025,14 +2025,14 @@ export function GET(): NextResponse { }, }, }, - ProjectInvite: { + WorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, @@ -2052,18 +2052,18 @@ export function GET(): NextResponse { }, }, }, - ExtendedProjectInvite: { + ExtendedWorkspaceInvite: { type: 'object', properties: { id: { type: 'string', format: 'uuid', }, - project_id: { + workspace_id: { type: 'string', format: 'uuid', }, - project: { + workspace: { type: 'object', properties: { name: { @@ -2101,7 +2101,7 @@ export function GET(): NextResponse { }, }, }, - ProjectMember: { + WorkspaceMember: { type: 'object', properties: { id: { diff --git a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts similarity index 71% rename from apps/web/app/api/v1/projects/[slug]/config/domain/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts index c5a2d2b..cc55d24 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/domain/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/domain/route.ts @@ -1,21 +1,21 @@ import { NextResponse } from 'next/server'; -import { getProjectConfigBySlug, updateProjectConfigBySlug } from '@/lib/api/projects'; +import { getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; /* - GET /api/v1/projects/:slug/config/domain + GET /api/v1/workspaces/:slug/domain */ export async function GET(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } const [configResponse, domainResponse] = await Promise.all([ fetch( - `https://api.vercel.com/v6/domains/${data.custom_domain}/config${ + `https://api.vercel.com/v6/domains/${workspace.custom_domain}/config${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -27,9 +27,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } ), fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data.custom_domain}${ - process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' - }`, + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ + workspace.custom_domain + }${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'GET', headers: { @@ -58,7 +58,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { if (!domainData.verified && !configData.misconfigured) { const verifyResponse = await fetch( `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${ - data.custom_domain + workspace.custom_domain }/verify${process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : ''}`, { method: 'POST', @@ -102,9 +102,9 @@ export async function GET(req: Request, context: { params: { slug: string } }) { ); } - // If verification succeeded, update project config + // If verification succeeded, update workspace config if (domainData?.verified && !configData.misconfigured) { - const { error: updateError } = await updateProjectConfigBySlug( + const { error: updateError } = await updateWorkspaceBySlug( context.params.slug, { custom_domain_verified: true }, 'route' @@ -128,7 +128,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - POST /api/v1/projects/:slug/config/domain + POST /api/v1/workspaces/:slug/domain { "name": "example.com" } @@ -140,20 +140,17 @@ export async function POST(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: 'Name is required' }, { status: 400 }); } - // Get project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - context.params.slug, - 'route' - ); + // Get workspace config + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (projectConfigError) { - return NextResponse.json({ error: projectConfigError.message }, { status: projectConfigError.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } - // If project already has a domain, return error - if (projectConfig.custom_domain) { - return NextResponse.json({ error: 'Project already has a custom domain' }, { status: 409 }); + // If workspace already has a domain, return error + if (workspace.custom_domain) { + return NextResponse.json({ error: 'Workspace already has a custom domain' }, { status: 409 }); } const response = await fetch( @@ -188,8 +185,8 @@ export async function POST(req: Request, context: { params: { slug: string } }) ); } - // Update project config - const { error } = await updateProjectConfigBySlug( + // Update workspace config + const { error } = await updateWorkspaceBySlug( context.params.slug, { custom_domain: responseData.name, custom_domain_verified: false }, 'route' @@ -205,25 +202,25 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - DELETE /api/v1/projects/:slug/config/domain + DELETE /api/v1/workspaces/:slug/domain */ export async function DELETE(req: Request, context: { params: { slug: string } }) { - // Get project - const { data, error } = await getProjectConfigBySlug(context.params.slug, 'route'); + // Get workspace + const { data: workspace, error: workspaceError } = await getWorkspaceBySlug(context.params.slug, 'route'); // If any errors thrown, return error - if (error) { - return NextResponse.json({ error: error.message }, { status: error.status }); + if (workspaceError) { + return NextResponse.json({ error: workspaceError.message }, { status: workspaceError.status }); } // If no domain, return error - if (!data?.custom_domain) { - return NextResponse.json({ error: 'Project does not have a custom domain' }, { status: 400 }); + if (!workspace?.custom_domain) { + return NextResponse.json({ error: 'Workspace does not have a custom domain' }, { status: 400 }); } // Delete domain from Vercel const response = await fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${data?.custom_domain}${ + `https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${workspace?.custom_domain}${ process.env.VERCEL_TEAM_ID ? `?teamId=${process.env.VERCEL_TEAM_ID}` : '' }`, { @@ -242,21 +239,21 @@ export async function DELETE(req: Request, context: { params: { slug: string } } return NextResponse.json({ error: responseData.error.message }, { status: 400 }); } - // Update project config - const { data: updatedProjectConfig, error: updatedProjectConfigError } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error: updatedWorkspaceConfigError } = await updateWorkspaceBySlug( context.params.slug, - { custom_domain: null, custom_domain_verified: null }, + { custom_domain: null, custom_domain_verified: false }, 'route' ); // If any errors thrown, return error - if (updatedProjectConfigError) { + if (updatedWorkspaceConfigError) { return NextResponse.json( - { error: updatedProjectConfigError.message }, - { status: updatedProjectConfigError.status } + { error: updatedWorkspaceConfigError.message }, + { status: updatedWorkspaceConfigError.status } ); } // Return response - return NextResponse.json(updatedProjectConfig, { status: 200 }); + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts index 2c483cf..003e70b 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/discord/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/discord/route.ts @@ -1,37 +1,37 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Discord integration - PATCH /api/v1/projects/:slug/config/integrations/discord + PATCH /api/v1/workspaces/:slug/config/integrations/discord { - status: boolean, + enabled: boolean, webhook: string, roleId: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook, roleId } = (await req.json()) as { - status: boolean; + const { enabled, webhook, roleId } = (await req.json()) as { + enabled: boolean; webhook: string; - roleId: string; + roleId: number; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Discord integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_discord_status: status, - integration_discord_webhook: status ? webhook : null, - integration_discord_role_id: status ? roleId : null, + discord_enabled: enabled, + discord_webhook: enabled ? webhook : null, + discord_role_id: enabled ? roleId : null, }, 'route' ); @@ -41,6 +41,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts index 25496b4..2c310e4 100644 --- a/apps/web/app/api/v1/projects/[slug]/config/integrations/slack/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/integrations/slack/route.ts @@ -1,34 +1,34 @@ import { NextResponse } from 'next/server'; -import { updateProjectConfigBySlug } from '@/lib/api/projects'; +import { updateWorkspaceIntegrations } from '@/lib/api/integration'; /* Update Slack integration - PATCH /api/v1/projects/:slug/config/integrations/slack + PATCH /api/v1/workspaces/:slug/config/integrations/slack { status: boolean, webhook: string, } */ export async function PATCH(req: Request, context: { params: { slug: string } }) { - const { status, webhook } = (await req.json()) as { - status: boolean; + const { enabled, webhook } = (await req.json()) as { + enabled: boolean; webhook: string; }; // If status is true, make sure webhook and roleId are not empty - if (status && !webhook) { + if (enabled && !webhook) { return NextResponse.json( { error: 'webhook is required when enabling Slack integration.' }, { status: 400 } ); } - // Update project config - const { data: updatedProjectConfig, error } = await updateProjectConfigBySlug( + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceIntegrations( context.params.slug, { - integration_slack_status: status, - integration_slack_webhook: status ? webhook : null, + slack_enabled: enabled, + slack_webhook: enabled ? webhook : null, }, 'route' ); @@ -38,6 +38,6 @@ export async function PATCH(req: Request, context: { params: { slug: string } }) return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return updated project config - return NextResponse.json(updatedProjectConfig, { status: 200 }); + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); } diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts new file mode 100644 index 0000000..700e0f0 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/modules/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; + +/* + Get workspace module config + GET /api/v1/workspaces/[slug]/modules +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace module config + const { data: moduleConfig, error } = await getWorkspaceModuleConfig(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(moduleConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts new file mode 100644 index 0000000..36ed41c --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { updateWorkspaceBySlug } from '@/lib/api/workspace'; + +/* + Update SSO configuration + PATCH /api/v1/workspaces/:slug/config/integrations/sso + { + enabled: boolean, + url: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { enabled, url } = await req.json(); + + // Update workspace config + const { data: updatedWorkspaceConfig, error } = await updateWorkspaceBySlug( + context.params.slug, + { + sso_auth_enabled: enabled, + sso_auth_url: url, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace config + return NextResponse.json(updatedWorkspaceConfig, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts new file mode 100644 index 0000000..eeb54b4 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/sso/secret/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { createWorkspaceSSOSecret } from '@/lib/api/workspace'; + +/* + Generate random jwt secret + POST /api/v1/workspaces/:slug/config/integrations/sso/secret +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { data, error } = await createWorkspaceSSOSecret(context.params.slug, 'route'); + + // If there is an error, return it + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(data, { status: 200 }); +} diff --git a/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts new file mode 100644 index 0000000..0f5eb8b --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/(config)/theme/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceTheme, updateWorkspaceTheme } from '@/lib/api/theme'; + +/* + Get workspace theme + GET /api/v1/workspaces/:slug/theme +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + // Get workspace theme + const { data: theme, error } = await getWorkspaceTheme(context.params.slug, 'route'); + + if (error) { + return NextResponse.json(error, { status: error.status }); + } + + return NextResponse.json(theme, { status: 200 }); +} + +/* + Update workspace theme + PATCH /api/v1/workspaces/:slug/theme + { + "accent": "string", + "background": "string", + "border": "string", + "foreground": "string", + "root": "string", + "secondary_background": "string", + "theme": "string" + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + } = await req.json(); + + // Update workspace theme + const { data: updatedTheme, error } = await updateWorkspaceTheme( + context.params.slug, + { + accent, + background, + border, + foreground, + root, + secondary_background: secondaryBackground, + theme, + }, + 'route' + ); + + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + return NextResponse.json(updatedTheme, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/analytics/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts index 03d0ba4..eb95e97 100644 --- a/apps/web/app/api/v1/projects/[slug]/analytics/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/analytics/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getProjectAnalytics } from '@/lib/api/projects'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; /* Get Analytics - GET /api/v1/projects/:slug/analytics + GET /api/v1/workspaces/:slug/analytics */ export async function GET(req: NextRequest, context: { params: { slug: string } }) { // Check if tinybird variables are set @@ -20,7 +20,7 @@ export async function GET(req: NextRequest, context: { params: { slug: string } return NextResponse.json({ error: 'Invalid start or end date.' }, { status: 400 }); } - const { data: analyticsData, error } = await getProjectAnalytics(context.params.slug, 'route'); + const { data: analyticsData, error } = await getWorkspaceAnalytics(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts index 436bbf4..bcf2640 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/[id]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { deleteProjectApiKey } from '@/lib/api/projects'; +import { deleteWorkspaceApiKey } from '@/lib/api/api-key'; /* - Delete api key for a project - DELETE /api/v1/projects/:slug/config/api/:token + Delete api key for a workspace + DELETE /api/v1/workspaces/:slug/config/api/:token */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { - const { error } = await deleteProjectApiKey(context.params.slug, context.params.id, 'route'); + const { error } = await deleteWorkspaceApiKey(context.params.slug, context.params.id, 'route'); if (error) { return NextResponse.json({ error }, { status: error.status }); diff --git a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts similarity index 65% rename from apps/web/app/api/v1/projects/[slug]/api-keys/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts index b40c674..28aab04 100644 --- a/apps/web/app/api/v1/projects/[slug]/api-keys/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/api-keys/route.ts @@ -1,13 +1,14 @@ import { NextResponse } from 'next/server'; -import { createProjectApiKey, getProjectApiKeys } from '@/lib/api/projects'; +import { createWorkspaceApiKey, getWorkspaceApiKeys } from '@/lib/api/api-key'; +import { ApiKeyPermissions } from '@/lib/types'; /* - Get all API keys for a project - GET /api/v1/projects/:slug/config/api + Get all API keys for a workspace + GET /api/v1/workspaces/:slug/config/api */ export async function GET(req: Request, context: { params: { slug: string } }) { // Get all api keys - const { data: apiKeys, error } = await getProjectApiKeys(context.params.slug, 'route'); + const { data: apiKeys, error } = await getWorkspaceApiKeys(context.params.slug, 'route'); if (error) { return NextResponse.json(error, { status: error.status }); @@ -17,15 +18,15 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Create a new API key for a project - POST /api/v1/projects/:slug/config/api + Create a new API key for a workspace + POST /api/v1/workspaces/:slug/config/api { "name": "string", "permissions": "string" } */ export async function POST(req: Request, context: { params: { slug: string } }) { - const { name, permission } = (await req.json()) as { name: string; permission: string }; + const { name, permission } = (await req.json()) as { name: string; permission: ApiKeyPermissions }; // Validate input if (!name || !permission) { @@ -33,7 +34,7 @@ export async function POST(req: Request, context: { params: { slug: string } }) } // Create api key - const { data: apiKey, error } = await createProjectApiKey( + const { data: apiKey, error } = await createWorkspaceApiKey( context.params.slug, { name, permission }, 'route' diff --git a/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts new file mode 100644 index 0000000..3af8b5f --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/boards/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { getWorkspaceBoards } from '@/lib/api/boards'; + +/* + Get all workspace boards + GET /api/v1/workspaces/[slug]/boards +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: boards, error } = await getWorkspaceBoards(context.params.slug, 'route', true, false); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return boards + return NextResponse.json(boards, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts similarity index 88% rename from apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts index c454d15..842afd2 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/[id]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/[id]/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server'; -import { deleteChangelog, updateChangelog } from '@/lib/api/changelogs'; +import { deleteChangelog, updateChangelog } from '@/lib/api/changelog'; export const runtime = 'edge'; /* - Update project changelog - PUT /api/v1/projects/[slug]/changelogs/[id] + Update workspace changelog + PUT /api/v1/workspaces/[slug]/changelogs/[id] { title: string; summary: string; @@ -35,8 +35,8 @@ export async function PUT(req: Request, context: { params: { slug: string; id: s } /* - Delete project changelog - DELETE /api/v1/projects/[slug]/changelogs/[id] + Delete workspace changelog + DELETE /api/v1/workspaces/[slug]/changelogs/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; id: string } }) { const { data, error } = await deleteChangelog(context.params.id, context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts similarity index 74% rename from apps/web/app/api/v1/projects/[slug]/changelogs/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts index bd84f37..8689301 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createChangelog, getAllProjectChangelogs } from '@/lib/api/changelogs'; +import { createChangelog, getAllWorkspaceChangelogs } from '@/lib/api/changelog'; import { ChangelogProps } from '@/lib/types'; export const runtime = 'edge'; /* Create Changelog - POST /api/v1/projects/[slug]/changelogs + POST /api/v1/workspaces/[slug]/changelogs { title: string; summary: string; @@ -21,10 +21,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) title, summary, content, - image, + thumbnail, publish_date: publishDate, published, - } = (await req.json()) as ChangelogProps['Insert']; + notify_subscribers: notifySubscribers, + } = (await req.json()) as ChangelogProps['Insert'] & { notify_subscribers: boolean }; // Validate Request Body if (published) { @@ -42,13 +43,14 @@ export async function POST(req: Request, context: { params: { slug: string } }) title: title || '', summary: summary || '', content: content || '', - image: image || '', + thumbnail: thumbnail || null, publish_date: publishDate || null, published: published || false, - project_id: 'dummy-id', + workspace_id: 'dummy-id', slug: 'dummy-slug', author_id: 'dummy-author', }, + notifySubscribers, 'route' ); @@ -62,11 +64,11 @@ export async function POST(req: Request, context: { params: { slug: string } }) } /* - Get project changelogs - GET /api/v1/projects/[slug]/changelogs + Get workspace changelogs + GET /api/v1/workspaces/[slug]/changelogs */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(context.params.slug, 'route', true); + const { data: changelogs, error } = await getAllWorkspaceChangelogs(context.params.slug, 'route', true); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts new file mode 100644 index 0000000..de0a23a --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/count/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; + +/* + Get the subscribers count for a workspace changelog + GET /api/v1/workspaces/:slug/changelogs/subscribers/count +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return new Response(error.message, { status: error.status }); + } + + return NextResponse.json({ count: subscribers.length }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts similarity index 84% rename from apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts index 914c1f1..ab2621b 100644 --- a/apps/web/app/api/v1/projects/[slug]/changelogs/subscribers/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/changelogs/subscribers/route.ts @@ -1,8 +1,8 @@ -import { getChangelogSubscribers } from '@/lib/api/changelogs'; +import { getChangelogSubscribers } from '@/lib/api/changelog'; /* Download changelog subscribers - GET /api/v1/projects/:slug/changelogs/subscribers/download + GET /api/v1/workspaces/:slug/changelogs/subscribers/download */ export async function GET(req: Request, context: { params: { slug: string } }) { const { data: subscribers, error } = await getChangelogSubscribers(context.params.slug, 'route'); diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts similarity index 82% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts index c8e2f0b..36bc132 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { deleteCommentForFeedbackById } from '@/lib/api/comments'; +import { deleteCommentForFeedbackById } from '@/lib/api/comment'; /* Delete comment for feedback by id - DELETE /api/v1/projects/[slug]/feedback/[id]/comments/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id]/comments/[id] */ export async function DELETE( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts similarity index 81% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts index 3c2506b..0c00268 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/[commentId]/upvote/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { upvoteCommentForFeedbackById } from '@/lib/api/comments'; +import { upvoteCommentForFeedbackById } from '@/lib/api/comment'; /* Upvote comment for feedback by id - POST /api/v1/projects/[slug]/feedback/[id]/comments/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/comments/[id]/upvote */ export async function POST( req: Request, diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts similarity index 85% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts index e9b38f9..5ac00b2 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/comments/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/comments/route.ts @@ -1,16 +1,17 @@ import { NextResponse } from 'next/server'; -import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comments'; -import { FeedbackCommentProps } from '@/lib/types'; +import { createCommentForFeedbackById, getCommentsForFeedbackById } from '@/lib/api/comment'; +import { CommentProps } from '@/lib/types'; /* Create feedback comment - POST /api/v1/projects/[slug]/feedback/[id]/comments + POST /api/v1/workspaces/[slug]/feedback/[id]/comments { content: string + reply_to_id?: string } */ export async function POST(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { content, reply_to_id: replyToId } = (await req.json()) as FeedbackCommentProps['Insert']; + const { content, reply_to_id: replyToId } = (await req.json()) as CommentProps['Insert']; if (!content) { return NextResponse.json({ error: 'Content cannot be empty' }, { status: 400 }); @@ -38,7 +39,7 @@ export async function POST(req: Request, context: { params: { slug: string; feed /* Get feedback comments - GET /api/v1/projects/[slug]/feedback/[id]/comments + GET /api/v1/workspaces/[slug]/feedback/[id]/comments */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { const { data: comments, error } = await getCommentsForFeedbackById( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts similarity index 69% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts index d75433d..04c859d 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { deleteFeedbackByID, getFeedbackByID, updateFeedbackByID } from '@/lib/api/feedback'; +import { deleteFeedbackById, getFeedbackById, updateFeedbackByID } from '@/lib/api/feedback'; import { FeedbackWithUserInputProps } from '@/lib/types'; /* - Get Project Feedback by ID - GET /api/v1/projects/[slug]/feedback/[id] + Get Workspace Feedback by ID + GET /api/v1/workspaces/[slug]/feedback/[id] */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -24,27 +24,35 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Update Feedback by ID - PATCH /api/v1/projects/[slug]/feedback/[id] + PATCH /api/v1/workspaces/[slug]/feedback/[id] { title: string; - description: string; + content: string; status: string; tags: string[]; + board_id: string; } */ export async function PATCH(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { title, description, status, tags } = (await req.json()) as FeedbackWithUserInputProps; + const { + title, + content, + status, + tags, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; const { data: feedback, error } = await updateFeedbackByID( context.params.feedbackId, context.params.slug, { title: title || '', - description: description || '', - status, - project_id: 'dummy-id', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', user_id: 'dummy-id', tags: tags || undefined, + workspace_id: 'dummy-id', }, 'route' ); @@ -60,10 +68,10 @@ export async function PATCH(req: Request, context: { params: { slug: string; fee /* Delete Feedback by ID - DELETE /api/v1/projects/[slug]/feedback/[id] + DELETE /api/v1/workspaces/[slug]/feedback/[id] */ export async function DELETE(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await deleteFeedbackByID( + const { data: feedback, error } = await deleteFeedbackById( context.params.feedbackId, context.params.slug, 'route' diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts similarity index 73% rename from apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts index 146768d..d3244af 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/[feedbackId]/upvotes/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/[feedbackId]/upvotes/route.ts @@ -1,13 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getFeedbackByID, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; -import { getCurrentUser } from '@/lib/api/user'; +import { getFeedbackById, getFeedbackUpvotersById, upvoteFeedbackByID } from '@/lib/api/feedback'; /* Get feedback upvotes - GET /api/v1/projects/[slug]/feedback/[id]/upvote + GET /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function GET(req: Request, context: { params: { slug: string; feedbackId: string } }) { - const { data: feedback, error } = await getFeedbackByID( + const { data: feedback, error } = await getFeedbackById( context.params.feedbackId, context.params.slug, 'route' @@ -42,21 +41,14 @@ export async function GET(req: Request, context: { params: { slug: string; feedb /* Upvote a feedback - POST /api/v1/projects/[slug]/feedback/[id]/upvote + POST /api/v1/workspaces/[slug]/feedback/[id]/upvotes */ export async function POST(req: NextRequest, context: { params: { slug: string; feedbackId: string } }) { - const hasUpvoted = req.nextUrl.searchParams.get('has_upvoted'); - - // Get current user - const { data: isLoggedIn } = await getCurrentUser('route'); - // Upvote feedback const { data: feedback, error } = await upvoteFeedbackByID( context.params.feedbackId, context.params.slug, - 'route', - hasUpvoted ? hasUpvoted === 'true' : undefined, - !isLoggedIn + 'route' ); // If any errors thrown, return error diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts similarity index 78% rename from apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts index 1eebff8..166bf20 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/export/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/export/route.ts @@ -1,12 +1,12 @@ -import { getAllProjectFeedback } from '@/lib/api/feedback'; +import { getAllWorkspaceFeedback } from '@/lib/api/feedback'; import { formatRootUrl } from '@/lib/utils'; /* - Export all feedback for a project - GET /api/v1/projects/:slug/feedback/export + Export all feedback for a workspace + GET /api/v1/workspaces/:slug/feedback/export */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: feedback, error } = await getAllProjectFeedback(context.params.slug, 'route'); + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -19,7 +19,7 @@ export async function GET(req: Request, context: { params: { slug: string } }) { return `${feedback.id},${formatRootUrl( context.params.slug, `/feedback/${feedback.id}` - )},"${feedback.title.replace(/"/g, '""')}","${feedback.description.replace(/"/g, '""')}",${ + )},"${feedback.title.replace(/"/g, '""')}","${feedback.content.replace(/"/g, '""')}",${ feedback.status },${feedback.upvotes},${feedback.comment_count},${feedback.user_id},"${feedback.user.full_name.replace( /"/g, diff --git a/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts new file mode 100644 index 0000000..9f8bd23 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createFeedback, getAllBoardFeedback, getAllWorkspaceFeedback } from '@/lib/api/feedback'; +import { FeedbackWithUserInputProps } from '@/lib/types'; + +export const runtime = 'edge'; + +/* + Create Feedback + POST /api/v1/workspaces/[slug]/feedback + { + title: string; + content: string; + status: string; + tags: [id, id, id], + board_id?: string; + } +*/ +export async function POST(req: Request, context: { params: { slug: string } }) { + const { + title, + content, + status, + tags, + user, + board_id: boardId, + } = (await req.json()) as FeedbackWithUserInputProps; + + // Validate Request Body + if (!title) { + return NextResponse.json({ error: 'title is required.' }, { status: 400 }); + } + + const { data: feedback, error } = await createFeedback( + boardId, + context.params.slug, + { + title: title || '', + content: content || '', + status: status?.toLowerCase() as FeedbackWithUserInputProps['status'], + board_id: boardId || '', + workspace_id: context.params.slug, + user_id: 'dummy-id', + tags: tags || [], + user: user !== null ? user : undefined, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} + +/* + Get Workspace Feedback + GET /api/v1/workspaces/[slug]/feedback + Query Parameters: + - b (board_id): string +*/ +export async function GET(req: NextRequest, context: { params: { slug: string } }) { + // Get query parameters + const boardId = req.nextUrl.searchParams.get('b'); + + // If boardId is provided, get feedback by boardId + if (boardId) { + const { data: feedback, error } = await getAllBoardFeedback(boardId, context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); + } + + // Get all feedback + const { data: feedback, error } = await getAllWorkspaceFeedback(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return feedback + return NextResponse.json(feedback, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts similarity index 91% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts index 0713c06..0d3608a 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/[name]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/[name]/route.ts @@ -3,7 +3,7 @@ import { deleteFeedbackTagByName } from '@/lib/api/feedback'; /* Delete tag by name - DELETE /api/v1/projects/:slug/feedback/tags/:name + DELETE /api/v1/workspaces/:slug/feedback/tags/:name */ export async function DELETE(req: Request, context: { params: { slug: string; name: string } }) { const { data: tag, error } = await deleteFeedbackTagByName( diff --git a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts similarity index 89% rename from apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts index e6bc865..41af1a8 100644 --- a/apps/web/app/api/v1/projects/[slug]/feedback/tags/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/feedback/tags/route.ts @@ -4,7 +4,7 @@ import { FeedbackTagProps } from '@/lib/types'; /* Create new tag - POST /api/v1/projects/:slug/feedback/tags + POST /api/v1/workspaces/:slug/feedback/tags { name: string, color: string @@ -33,10 +33,10 @@ export async function POST(req: Request, context: { params: { slug: string } }) /* Get all feedback tags - GET /api/v1/projects/:slug/feedback/tags + GET /api/v1/workspaces/:slug/feedback/tags */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route'); + const { data: tags, error } = await getAllFeedbackTags(context.params.slug, 'route', true, false); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts similarity index 61% rename from apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts index 1e66f71..b40a54f 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/[inviteId]/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/[inviteId]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { acceptProjectInvite, deleteProjectInvite } from '@/lib/api/invites'; +import { acceptWorkspaceInvite, deleteWorkspaceInvite } from '@/lib/api/invite'; /* - Accept project invite - POST /api/v1/projects/[slug]/invites/[inviteId] + Accept workspace invite + POST /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function POST(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await acceptProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await acceptWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { @@ -18,11 +18,11 @@ export async function POST(req: Request, context: { params: { slug: string; invi } /* - Delete project invite - DELETE /api/v1/projects/[slug]/invites/[inviteId] + Delete workspace invite + DELETE /api/v1/workspaces/[slug]/invites/[inviteId] */ export async function DELETE(req: Request, context: { params: { slug: string; inviteId: string } }) { - const { data: invite, error } = await deleteProjectInvite(context.params.inviteId, 'route'); + const { data: invite, error } = await deleteWorkspaceInvite(context.params.inviteId, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/invites/route.ts b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts similarity index 55% rename from apps/web/app/api/v1/projects/[slug]/invites/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/invites/route.ts index 74256dc..65def20 100644 --- a/apps/web/app/api/v1/projects/[slug]/invites/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/invites/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { createProjectInvite, getProjectInvites } from '@/lib/api/invites'; +import { createWorkspaceInvite, getWorkspaceInvites } from '@/lib/api/invite'; /* - Get all project invites - GET /api/v1/projects/[slug]/invites + Get all workspace invites + GET /api/v1/workspaces/[slug]/invites */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: invites, error } = await getProjectInvites(context.params.slug, 'route'); + const { data: invites, error } = await getWorkspaceInvites(context.params.slug, 'route'); // If any errors thrown, return error if (error) { @@ -18,8 +18,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { } /* - Invite a new member to a project - POST /api/v1/projects/[slug]/members + Invite a new member to a workspace + POST /api/v1/workspaces/[slug]/members { email: string } @@ -27,14 +27,8 @@ export async function GET(req: Request, context: { params: { slug: string } }) { export async function POST(req: Request, context: { params: { slug: string } }) { const { email } = await req.json(); - // Check if email is valid - const emailRegex = new RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/); - if (!emailRegex.test(email)) { - return NextResponse.json({ error: 'Invalid email' }, { status: 400 }); - } - // Create invite - const { data: invite, error } = await createProjectInvite(context.params.slug, 'route', email); + const { data: invite, error } = await createWorkspaceInvite(context.params.slug, 'route', email); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/projects/[slug]/members/route.ts b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts similarity index 60% rename from apps/web/app/api/v1/projects/[slug]/members/route.ts rename to apps/web/app/api/v1/workspaces/[slug]/members/route.ts index cfa5b2c..e73da78 100644 --- a/apps/web/app/api/v1/projects/[slug]/members/route.ts +++ b/apps/web/app/api/v1/workspaces/[slug]/members/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; -import { getProjectMembers } from '@/lib/api/projects'; +import { getWorkspaceMembers } from '@/lib/api/workspace'; /* - Get all members of a project - GET /api/v1/projects/[slug]/members + Get all members of a workspace + GET /api/v1/workspaces/[slug]/members */ export async function GET(req: Request, context: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(context.params.slug, 'route'); + const { data: members, error } = await getWorkspaceMembers(context.params.slug, 'route'); // If any errors thrown, return error if (error) { diff --git a/apps/web/app/api/v1/workspaces/[slug]/route.ts b/apps/web/app/api/v1/workspaces/[slug]/route.ts new file mode 100644 index 0000000..309dac2 --- /dev/null +++ b/apps/web/app/api/v1/workspaces/[slug]/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { deleteWorkspaceBySlug, getWorkspaceBySlug, updateWorkspaceBySlug } from '@/lib/api/workspace'; +import { WorkspaceProps } from '@/lib/types'; + +/* + Get workspace by slug + GET /api/v1/workspaces/[slug] +*/ +export async function GET(req: Request, context: { params: { slug: string } }) { + const { data: workspace, error } = await getWorkspaceBySlug(context.params.slug, 'route'); + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return workspace + return NextResponse.json(workspace, { status: 200 }); +} + +/* + Update workspace by slug + PATCH /api/v1/workspaces/[slug] + { + name: string, + slug: string, + icon: string, + icon_radius: string, + og_image: string + icon_redirect_url: string + custom_domain_redirect: string + } +*/ +export async function PATCH(req: Request, context: { params: { slug: string } }) { + const { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + } = (await req.json()) as WorkspaceProps['Update']; + + const { data: updatedWorkspace, error } = await updateWorkspaceBySlug( + context.params.slug, + { + name, + slug, + icon, + icon_radius: iconRadius, + opengraph_image: OGImage, + icon_redirect_url: iconRedirectURL, + custom_domain_redirect: customDomainRedirect, + }, + 'route' + ); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return updated workspace + return NextResponse.json(updatedWorkspace, { status: 200 }); +} + +/* + Delete workspace by slug + DELETE /api/v1/workspaces/[slug] +*/ +export async function DELETE(req: Request, context: { params: { slug: string } }) { + const { data, error } = await deleteWorkspaceBySlug(context.params.slug, 'route'); + + // If any errors thrown, return error + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + + // Return success + return NextResponse.json({ data }, { status: 200 }); +} diff --git a/apps/web/app/api/v1/projects/route.ts b/apps/web/app/api/v1/workspaces/route.ts similarity index 50% rename from apps/web/app/api/v1/projects/route.ts rename to apps/web/app/api/v1/workspaces/route.ts index f65280b..d10836d 100644 --- a/apps/web/app/api/v1/projects/route.ts +++ b/apps/web/app/api/v1/workspaces/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { createProject } from '@/lib/api/projects'; -import { getUserProjects } from '@/lib/api/user'; +import { getUserWorkspaces } from '@/lib/api/user'; +import { createWorkspace } from '@/lib/api/workspace'; /* - Create Project - POST /api/v1/projects + Create Workspace + POST /api/v1/workspaces { - "name": "Project Name", - "slug": "project-slug", + "name": "Workspace Name", + "slug": "workspace-slug", } */ export async function POST(req: Request) { @@ -19,30 +19,30 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'name and slug are required.' }, { status: 400 }); } - // Create Project - const { data: project, error } = await createProject({ name, slug }, 'route'); + // Create Workspace + const { data: workspace, error } = await createWorkspace({ name, slug }, 'route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - return NextResponse.json(project, { status: 201 }); + return NextResponse.json(workspace, { status: 201 }); } /* - Get User Projects - GET /api/v1/projects + Get User Workspaces + GET /api/v1/workspaces */ export async function GET(req: Request) { - // Get User Projects - const { data: projects, error } = await getUserProjects('route'); + // Get User Workspaces + const { data: workspaces, error } = await getUserWorkspaces('route'); // Check for errors if (error) { return NextResponse.json({ error: error.message }, { status: error.status }); } - // Return projects - return NextResponse.json(projects, { status: 200 }); + // Return workspaces + return NextResponse.json(workspaces, { status: 200 }); } diff --git a/apps/web/app/dash/(auth)/login/page.tsx b/apps/web/app/dash/(auth)/login/page.tsx index 951c959..129dc9e 100644 --- a/apps/web/app/dash/(auth)/login/page.tsx +++ b/apps/web/app/dash/(auth)/login/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign in to Feedbase', @@ -31,7 +38,7 @@ export default async function SignIn() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/(auth)/signup/page.tsx b/apps/web/app/dash/(auth)/signup/page.tsx index 912ed81..ffa038d 100644 --- a/apps/web/app/dash/(auth)/signup/page.tsx +++ b/apps/web/app/dash/(auth)/signup/page.tsx @@ -2,9 +2,16 @@ import { Metadata } from 'next'; import { cookies } from 'next/headers'; import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@feedbase/ui/components/card'; import { createServerClient } from '@supabase/ssr'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { UserAuthForm } from '@/components/user-auth-form'; +import { UserAuthForm } from '@/components/shared/user-auth-form'; export const metadata: Metadata = { title: 'Sign up to Feedbase', @@ -31,7 +38,7 @@ export default async function SignUp() { data: { user }, } = await supabase.auth.getUser(); - // If there is a session, redirect to projects + // If there is a session, redirect to workspaces if (user) { redirect('/'); } diff --git a/apps/web/app/dash/[slug]/analytics/loading.tsx b/apps/web/app/dash/[slug]/analytics/loading.tsx deleted file mode 100644 index 50dd5dd..0000000 --- a/apps/web/app/dash/[slug]/analytics/loading.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - - - - - - - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/analytics/page.tsx b/apps/web/app/dash/[slug]/analytics/page.tsx index 631e1be..35cebde 100644 --- a/apps/web/app/dash/[slug]/analytics/page.tsx +++ b/apps/web/app/dash/[slug]/analytics/page.tsx @@ -1,21 +1,55 @@ import React from 'react'; -import { getProjectAnalytics } from '@/lib/api/projects'; -import AnalyticsCards from '@/components/dashboard/analytics/chart-cards'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@feedbase/ui/components/select'; +import { getWorkspaceAnalytics } from '@/lib/api/workspace'; +import { BarChart } from '@/components/analytics/bar-chart'; +import { LineChart } from '@/components/analytics/line-chart'; export default async function AnalyticsPage({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectAnalytics(params.slug, 'server'); + const { data, error } = await getWorkspaceAnalytics(params.slug, 'server'); if (!data) { return Error: {error?.message}; } return ( - - + + {/* Header */} + + Analytics + + + {/* Timeframe */} + + + + + + + Last 3 months + + + Last 30 days + + + Last 7 days + + + + + + + {/* Chart */} + + + + + ); } diff --git a/apps/web/app/dash/[slug]/changelog/loading.tsx b/apps/web/app/dash/[slug]/changelog/loading.tsx deleted file mode 100644 index 7e7f0a9..0000000 --- a/apps/web/app/dash/[slug]/changelog/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ; -} diff --git a/apps/web/app/dash/[slug]/changelog/page.tsx b/apps/web/app/dash/[slug]/changelog/page.tsx index e56db5e..d715ba5 100644 --- a/apps/web/app/dash/[slug]/changelog/page.tsx +++ b/apps/web/app/dash/[slug]/changelog/page.tsx @@ -1,67 +1,36 @@ +import { Button } from '@feedbase/ui/components/button'; +import { Separator } from '@feedbase/ui/components/separator'; import { Plus } from 'lucide-react'; -import { Button } from 'ui/components/ui/button'; -import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { Separator } from 'ui/components/ui/separator'; -import { getAllProjectChangelogs } from '@/lib/api/changelogs'; -import { ApiSheet } from '@/components/dashboard/changelogs/api-sheet'; -import ChangelogList from '@/components/dashboard/changelogs/changelog-list'; -import { AddChangelogModal } from '@/components/dashboard/modals/add-edit-changelog-modal'; - -export default async function Changelog({ params }: { params: { slug: string } }) { - const { data: changelogs, error } = await getAllProjectChangelogs(params.slug, 'server'); - - if (error) { - return {error.message}; - } +import { ApiSheet } from '@/components/changelog/api-sheet'; +import ChangelogList from '@/components/changelog/changelog-list'; +import { AddChangelogModal } from '@/components/modals/add-edit-changelog-modal'; +export default function Changelog({ params }: { params: { slug: string } }) { return ( - - {/* Content */} - - {/* Header Row */} - {changelogs.length > 0 && ( - - {/* Api Docs Button */} - + + {/* Header */} + + Changelogs - {/* Seperator Line */} - + + {/* Api Docs Button */} + - {/* Create new Button */} - - - New Changelog - - } - /> - - )} + {/* Seperator Line */} + - {/* Changelog List */} - {/* If there is no changelog, show empty text in the center */} - {changelogs.length === 0 && ( - - - No changelogs yet - - Once you create a changelog, it will show up here. - - - - Create first changelog} - /> - - - )} - - {/* If there is changelog, show changelog list */} - {changelogs.length > 0 && } + {/* Create new Button */} + + + + New Changelog + + + + + {/* Changelogs */} + ); } diff --git a/apps/web/app/dash/[slug]/feedback/loading.tsx b/apps/web/app/dash/[slug]/feedback/loading.tsx deleted file mode 100644 index ea9545c..0000000 --- a/apps/web/app/dash/[slug]/feedback/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Skeleton } from '@ui/components/ui/skeleton'; - -export default function FeedbackLoading() { - return ( - <> - - - > - ); -} diff --git a/apps/web/app/dash/[slug]/feedback/page.tsx b/apps/web/app/dash/[slug]/feedback/page.tsx index 700c37d..ea01880 100644 --- a/apps/web/app/dash/[slug]/feedback/page.tsx +++ b/apps/web/app/dash/[slug]/feedback/page.tsx @@ -1,46 +1,14 @@ -import { Card, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getAllFeedbackTags, getAllProjectFeedback } from '@/lib/api/feedback'; -import FeedbackTable from '@/components/dashboard/feedback/feedback-table'; -import FeedbackHeader from '@/components/dashboard/feedback/header-buttons'; - -export default async function Feedback({ - params, - searchParams, -}: { - params: { slug: string }; - searchParams: { tag: string }; -}) { - const { data: feedback, error } = await getAllProjectFeedback(params.slug, 'server'); - if (error) { - return {error.message}; - } - - const { data: tags, error: tagsError } = await getAllFeedbackTags(params.slug, 'server'); - if (tagsError) { - return {tagsError.message}; - } +import FeedbackHeader from '@/components/feedback/dashboard/feedback-header'; +import FeedbackList from '@/components/feedback/dashboard/feedback-list'; +export default async function Feedback() { return ( - - {/* Header Row */} - - - {/* Feedback Posts */} - {/* If there is no feedback, show empty text in the center */} - {feedback.length === 0 && ( - - - No feedback yet - - Once somone submits feedback, it will show up here. Make sure to share it so others can vote on - it! - - - - )} + + {/* Header */} + - {/* If there is feedback, show feedback list */} - {feedback.length > 0 && } + {/* Feedback List */} + ); } diff --git a/apps/web/app/dash/[slug]/layout.tsx b/apps/web/app/dash/[slug]/layout.tsx index aa90d86..eebd0d6 100644 --- a/apps/web/app/dash/[slug]/layout.tsx +++ b/apps/web/app/dash/[slug]/layout.tsx @@ -1,11 +1,11 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { getCurrentUser, getUserProjects } from '@/lib/api/user'; +import { getCurrentUser, getUserWorkspaces } from '@/lib/api/user'; import { DASH_DOMAIN } from '@/lib/constants'; -import InboxPopover from '@/components/layout/inbox-popover'; +import { SidebarTabsProps } from '@/lib/types'; +import DashboardHeader from '@/components/layout/header'; import NavbarMobile from '@/components/layout/nav-bar-mobile'; import Sidebar from '@/components/layout/sidebar'; -import TitleProvider from '@/components/layout/title-provider'; import { AnalyticsIcon, CalendarIcon, @@ -13,41 +13,45 @@ import { SettingsIcon, TagLabelIcon, } from '@/components/shared/icons/icons-animated'; -import { Icons } from '@/components/shared/icons/icons-static'; -import UserDropdown from '@/components/shared/user-dropdown'; -const tabs = [ - { - name: 'Changelog', - icon: TagLabelIcon, - slug: 'changelog', - }, - { - name: 'Feedback', - icon: FeedbackIcon, - slug: 'feedback', - }, - { - name: 'Roadmap (Soon)', - icon: CalendarIcon, - slug: 'roadmap', - }, - { - name: 'Analytics', - icon: AnalyticsIcon, - slug: 'analytics', - }, - { - name: 'Settings', - icon: SettingsIcon, - slug: 'settings', - }, -]; +const tabs: SidebarTabsProps = { + Modules: [ + { + name: 'Changelog', + icon: TagLabelIcon, + slug: 'changelog', + }, + { + name: 'Feedback', + icon: FeedbackIcon, + slug: 'feedback', + }, + { + name: 'Roadmap', + icon: CalendarIcon, + slug: 'roadmap', + }, + ], + Insights: [ + { + name: 'Analytics', + icon: AnalyticsIcon, + slug: 'analytics', + }, + ], + Workspace: [ + { + name: 'Settings', + icon: SettingsIcon, + slug: 'settings/general', + }, + ], +}; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { // Headers const headerList = headers(); - const projectSlug = headerList.get('x-project'); + const workspaceSlug = headerList.get('x-workspace'); const pathname = headerList.get('x-pathname'); // Fetch user @@ -57,67 +61,48 @@ export default async function DashboardLayout({ children }: { children: React.Re return redirect(`${DASH_DOMAIN}/login`); } - // Fetch the user's projects - const { data: projects } = await getUserProjects('server'); + // Fetch the user's workspaces + const { data: workspaces } = await getUserWorkspaces('server'); - // Check if the user has any projects - if (!projects || projects.length === 0) { + // Check if the user has any workspaces + if (!workspaces || workspaces.length === 0) { return redirect(`${DASH_DOMAIN}`); } - // Get the project with the current slug - const currentProject = projects.find((project) => project.slug === projectSlug); + // Get the workspace with the current slug + const currentWorkspace = workspaces.find((workspace) => workspace.slug === workspaceSlug); - // If currentProject is undefined, redirect to the first project - if (!currentProject) { - return redirect(`/${projects[0].slug}`); + // If currentWorkspace is undefined, redirect to the first workspace + if (!currentWorkspace) { + return redirect(`/${workspaces[0].slug}`); } // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.includes(tab.slug)); + const activeTab = Object.values(tabs) + .flatMap((tabArray) => tabArray) + .find((tab) => pathname?.includes(tab.slug)); return ( - - - {/* Header with logo and hub button */} - {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} - {/* https://github.com/radix-ui/primitives/discussions/1100 */} - - {/* Logo */} - - - - - - - - - {/* Sidebar */} - + + {/* Header with logo and hub button */} + {/* BUG: Find a way to solve issue of scroll bar getting removed on avatar dialog open */} + {/* https://github.com/radix-ui/primitives/discussions/1100 */} + - {/* Main content */} - - - {children} - - + {/* Sidebar */} + - {/* Navbar (mobile) */} - + {/* Main content */} + + {children} + + {/* Navbar (mobile) */} + ); } diff --git a/apps/web/app/dash/[slug]/page.tsx b/apps/web/app/dash/[slug]/page.tsx index 1e344c3..4e3668f 100644 --- a/apps/web/app/dash/[slug]/page.tsx +++ b/apps/web/app/dash/[slug]/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -export default async function ProjectPage({ params }: { params: { slug: string } }) { +export default async function WorkspacePage({ params }: { params: { slug: string } }) { // Redirect to changelog redirect(`/${params.slug}/changelog`); } diff --git a/apps/web/app/dash/[slug]/roadmap/page.tsx b/apps/web/app/dash/[slug]/roadmap/page.tsx new file mode 100644 index 0000000..dc1a177 --- /dev/null +++ b/apps/web/app/dash/[slug]/roadmap/page.tsx @@ -0,0 +1,14 @@ +import RoadmapBoard from '@/components/roadmap/dashboard/roadmap-board'; +import RoadmapHeader from '@/components/roadmap/dashboard/roadmap-header'; + +export default async function Roadmap() { + return ( + + {/* Header */} + + + {/* Board */} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/api/page.tsx b/apps/web/app/dash/[slug]/settings/api/page.tsx new file mode 100644 index 0000000..a5c81c1 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/api/page.tsx @@ -0,0 +1,9 @@ +import SettingsCard from '@/components/settings/settings-card'; + +export default function ApiSettings() { + return ( + + s + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/billing/page.tsx b/apps/web/app/dash/[slug]/settings/billing/page.tsx new file mode 100644 index 0000000..c798162 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/billing/page.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { Label } from '@feedbase/ui/components/label'; +import { LucideExternalLink } from 'lucide-react'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function BillingSettings() { + return ( + + + + + Current plan: Starter + + + View plans + + + + + Change plan + Get in touch + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/changelog/page.tsx b/apps/web/app/dash/[slug]/settings/changelog/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/changelog/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/domain/page.tsx b/apps/web/app/dash/[slug]/settings/domain/page.tsx new file mode 100644 index 0000000..3ea7363 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/domain/page.tsx @@ -0,0 +1,599 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feedbase/ui/components/tabs'; +import { cn } from '@feedbase/ui/lib/utils'; +import { fontMono } from '@feedbase/ui/styles/fonts'; +import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { ChevronUpDownIcon } from '@heroicons/react/24/solid'; +import { Check, CheckIcon, ClipboardList, RefreshCcw, Trash2Icon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, fetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +interface domainData { + name: string; + apexName: string; + verified: boolean; + verification: { + type: string; + domain: string; + value: string; + reason: string; + }[]; +} + +export default function DomainSettings({ params }: { params: { slug: string } }) { + const [domain, setDomain] = useState(''); + const { workspace, loading, isValidating, mutate, error } = useWorkspace(); + const [domainStatus, setDomainStatus] = useState<'unset' | 'verifying' | 'verified'>('unset'); + const [hasCopied, setHasCopied] = useState([]); + const [redirectRule, setRedirectRule] = useState<'direct_redirect' | 'root_redirect' | 'no_redirect'>(); + + const { + data: domainData, + isValidating: domainIsValidating, + mutate: domainMutate, + } = useSWR(`/api/v1/workspaces/${params.slug}/domain`, fetcher, { + errorRetryInterval: 30000, + refreshInterval: 5000, + }); + + const { trigger: submitDomainVerification, isMutating: isSubmittingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('verifying'); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: removeDomain, isMutating: isRemovingDomain } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/domain`, + actionFetcher, + { + onSuccess: () => { + setDomainStatus('unset'); + setDomain(''); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + toast.success('Redirect rule updated.'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + useEffect(() => { + if (workspace?.custom_domain) { + // Set domain if it is not set + if (!domain) { + setDomain(workspace.custom_domain); + } + + // Set domain status + if (workspace.custom_domain_verified || domainData?.verified) { + setDomainStatus('verified'); + } else { + setDomainStatus('verifying'); + } + } else { + setDomainStatus('unset'); + } + + setRedirectRule(workspace?.custom_domain_redirect); + }, [workspace, domainData]); + + function handleCopyToClipboard(value: string) { + navigator.clipboard.writeText(value); + setHasCopied((prevHasCopied) => [...prevHasCopied, value]); + + setTimeout(() => { + setHasCopied((prevHasCopied) => prevHasCopied.filter((item) => item !== value)); + }, 3000); + } + + // Loading State + if (loading) { + return ( + + + + ); + } + + // Error State + if (error) { + return ( + + + + + + ); + } + + return ( + + + Domain + + { + setDomain(event.target.value); + }} + /> + {domainStatus === 'unset' ? ( + { + submitDomainVerification({ name: domain }); + }} + disabled={isSubmittingDomain || !domain}> + {isSubmittingDomain ? : null} + Connect + + ) : ( + { + removeDomain({ method: 'DELETE' }); + }}> + {isRemovingDomain ? ( + + ) : ( + + )} + + )} + + + Want to host on a sub-path? Check out our{' '} + + documentation + + . + + + + + Redirect Feedbase Subdomain + + + + {redirectRule === 'direct_redirect' && 'Redirect direct path'} + {redirectRule === 'root_redirect' && 'Redirect to root'} + {redirectRule === 'no_redirect' && "Don't Redirect"} + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'direct_redirect' }); + setRedirectRule('direct_redirect'); + }}> + + Redirect direct path + + Redirects to equivalent path on your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'root_redirect' }); + setRedirectRule('root_redirect'); + }}> + + Redirect to root + + Redirects to the root of your custom domain. + + + + + + { + updateWorkspace({ method: 'PATCH', custom_domain_redirect: 'no_redirect' }); + setRedirectRule('no_redirect'); + }}> + + Don't Redirect + + Does not redirect any requests to your custom domain. + + + + + + + + + + Wether to redirect the default {process.env.NEXT_PUBLIC_ROOT_DOMAIN} subdomain to your custom + domain. + + + + + {domainStatus === 'verifying' ? ( + <> + + + Verification Required + + + {/* Initial Loading state */} + {!domainData && ( + + + Fetching domain data... + + )} + + {domainData ? ( + + {/* Tabs, if domain is not assigned to a vercel workspace yet */} + {domainData.verification === undefined ? ( + + + + + A Record (Recommended) + + + CNAME Record + + + + {/* Refresh Status */} + { + domainMutate(); + }} + disabled={domainIsValidating} + className='text-secondary-foreground hover:text-foreground'> + {domainIsValidating ? ( + + ) : ( + + )} + + + + + + To verify your domain, add the following A Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + A + + + + Name + { + handleCopyToClipboard('@'); + }} + type='button'> + + @ + + + {hasCopied.includes('@') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + 76.76.21.21 + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + + To verify your domain, add the following CNAME Records to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + CNAME + + + + Name + { + handleCopyToClipboard('www'); + }} + type='button'> + + www + + + {hasCopied.includes('www') ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard('76.76.21.21'); + }} + type='button'> + + cname.vercel-dns.com + + + {hasCopied.includes('76.76.21.21') ? ( + + ) : ( + + )} + + + + TTL + { + handleCopyToClipboard('86400'); + }} + type='button'> + + 86400 + + + {hasCopied.includes('86400') ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Note: If{' '} + + 86400 + {' '} + is not supported for TLL, make sure to use the highest TTL value available. + + + + ) : null} + + {/* Verification Instructions for already vercel assigned domains */} + {domainData.verification !== undefined ? ( + + + To prove ownership of{' '} + + {domainData.apexName} + + , please add the following TXT Record to your DNS settings. + + + {/* Info Table */} + + {/* Type */} + + + Type + + TXT + + + + Name + { + handleCopyToClipboard(domainData.verification[0].domain); + }} + type='button'> + + {domainData.verification[0].domain} + + + {hasCopied.includes(domainData.verification[0].domain) ? ( + + ) : ( + + )} + + + + Value + + { + handleCopyToClipboard(domainData.verification[0].value); + }} + type='button'> + + {domainData.verification[0].value} + + + {hasCopied.includes(domainData.verification[0].value) ? ( + + ) : ( + + )} + + + + + + {/* Note */} + + Warning: If{' '} + + {domainData.apexName} + {' '} + is already in use, the TXT Record will transfer away from the existing domain ownership + and break the site. Consider using a subdomain instead. + + + ) : null} + + ) : null} + > + ) : null} + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/feedback/page.tsx b/apps/web/app/dash/[slug]/settings/feedback/page.tsx new file mode 100644 index 0000000..5b3429f --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/feedback/page.tsx @@ -0,0 +1,15 @@ +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Name + + This is the name of your feedback board. + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/general/loading.tsx b/apps/web/app/dash/[slug]/settings/general/loading.tsx deleted file mode 100644 index 5a31508..0000000 --- a/apps/web/app/dash/[slug]/settings/general/loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function GeneralLoading() { - return ( - // 3 cards with loading state - - - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/general/page.tsx b/apps/web/app/dash/[slug]/settings/general/page.tsx index 32c6754..cfbee47 100644 --- a/apps/web/app/dash/[slug]/settings/general/page.tsx +++ b/apps/web/app/dash/[slug]/settings/general/page.tsx @@ -1,28 +1,459 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import GeneralConfigCards from '@/components/dashboard/settings/general-cards'; +'use client'; -export default async function GeneralSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); +import { useEffect, useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { cn } from '@feedbase/ui/lib/utils'; +import { Check, ChevronsUpDownIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import useWorkspaceTheme from '@/lib/swr/use-workspace-theme'; +import { WorkspaceProps, WorkspaceThemeProps } from '@/lib/types'; +import { actionFetcher, areObjectsEqual } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import FileDrop from '@/components/shared/file-drop'; +import InputGroup from '@/components/shared/input-group'; - if (error) { - return {error.message}; - } +export default function GeneralSettings({ params }: { params: { slug: string } }) { + const { + workspace: workspaceData, + loading: workspaceLoading, + error: workspaceError, + mutate: workspaceMutate, + } = useWorkspace(); + const { + workspaceTheme: workspaceThemeData, + loading: workspaceThemeLoading, + error: workspaceThemeError, + mutate: workspaceThemeMutate, + } = useWorkspaceTheme(); + + const [workspace, setWorkspace] = useState(); + const [workspaceTheme, setWorkspaceTheme] = useState(); + + const { trigger: updateWorkspace } = useSWRMutation(`/api/v1/workspaces/${params.slug}`, actionFetcher, { + onSuccess: () => { + // Check if slug has changed + if (workspace?.slug !== workspaceData?.slug) { + // Redirect to new slug + window.location.href = `/${workspace?.slug}/settings/general`; + } + + workspaceMutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); - // Fetch project config - const { data: projectConfig, error: projectConfigError } = await getProjectConfigBySlug( - params.slug, - 'server' + const { trigger: updateWorkspaceTheme } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/theme`, + actionFetcher, + { + onError: (error) => { + toast.error(error.message); + }, + } ); - if (projectConfigError) { - return {projectConfigError.message}; + useEffect(() => { + setWorkspace(workspaceData); + setWorkspaceTheme(workspaceThemeData); + }, [workspaceData, workspaceThemeData]); + + if (workspaceError || workspaceThemeError) { + return ( + + ); } - return ( - - {/* General Card */} - - - ); + if (workspaceLoading || workspaceThemeLoading) { + return ( + <> + + + + + + + + + + + + > + ); + } + + if (workspace && workspaceTheme) { + return ( + <> + { + await updateWorkspace({ method: 'PATCH', ...workspace }); + }} + onCancel={() => { + setWorkspace(workspaceData); + }}> + + Name + { + setWorkspace({ + ...workspace, + name: e.target.value, + }); + }} + /> + This is the name of your workspace. + + + + Slug + + { + setWorkspace({ + ...workspace, + slug: e.target.value, + }); + }} + suffix={`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`} + placeholder='feedbase' + /> + + This is the subdomain of your workspace. + + + {/* Workspace Logo */} + + Logo + + {/* File Upload */} + + + + + {workspace.name[0]} + + + + + + Upload + + { + // Set the icon to the uploaded file + if (event.target.files?.[0]) { + // Check if the file is an image, image/png, image/jpeg, etc. + if (!['image/png', 'image/jpeg', 'image/jpg'].includes(event.target.files[0].type)) { + toast.error('Please upload a valid image.'); + return; + } + + // Read the file as a data URL + const reader = new FileReader(); + reader.onload = (e) => { + setWorkspace({ + ...workspace, + icon: e.target?.result as string, + }); + }; + reader.readAsDataURL(event.target.files[0]); + } + }} + /> + {workspace.icon ? ( + setWorkspace({ ...workspace, icon: '' })}> + Remove + + ) : null} + + Recommended size is 256x256. + + + + + + Radius + + + + {workspace.icon_radius === 'rounded-md' + ? 'Rounded' + : workspace.icon_radius === 'rounded-full' + ? 'Circle' + : 'Square'} + + + + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-md', + }); + }}> + Rounded + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-full', + }); + }}> + Circle + + { + setWorkspace({ + ...workspace, + icon_radius: 'rounded-none', + }); + }}> + Square + + + + + This is the radius of your workspace. + + + + Redirect URL + { + setWorkspace({ + ...workspace, + icon_redirect_url: e.target.value, + }); + }} + placeholder='Leave blank to use default' + /> + + This is the redirect URL of your workspace. + + + + {/* OG Image */} + + OG Image} + image={workspace.opengraph_image} + setImage={(e: string | null) => { + setWorkspace({ + ...workspace, + opengraph_image: e, + }); + }} + className='h-40 w-80' + /> + + + The OG Image used when sharing your workspace. + + + + + + {/* Thanks to https://github.com/openstatushq for the inspiration of the theme previews */} + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'light' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'light' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + + Light + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'dark' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'dark' }); + }} + type='button'> + + + + + + + + + + + + + + + + + + + + + Dark + + + { + toast.promise(updateWorkspaceTheme({ method: 'PATCH', theme: 'custom' }), { + loading: 'Updating theme...', + success: 'Theme updated successfully.', + error: 'Failed to update theme.', + }); + setWorkspaceTheme({ ...workspaceTheme, theme: 'custom' }); + }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + System + + + + + + + Delete this Workspace + + + + > + ); + } } diff --git a/apps/web/app/dash/[slug]/settings/hub/loading.tsx b/apps/web/app/dash/[slug]/settings/hub/loading.tsx index 8053295..f8a838e 100644 --- a/apps/web/app/dash/[slug]/settings/hub/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function HubLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/hub/page.tsx b/apps/web/app/dash/[slug]/settings/hub/page.tsx index 09506ef..0109277 100644 --- a/apps/web/app/dash/[slug]/settings/hub/page.tsx +++ b/apps/web/app/dash/[slug]/settings/hub/page.tsx @@ -1,16 +1,18 @@ -import { getProjectBySlug, getProjectConfigBySlug } from '@/lib/api/projects'; -import HubConfigCards from '@/components/dashboard/settings/hub-cards'; +import { getWorkspaceModuleConfig } from '@/lib/api/module'; +import { getWorkspaceBySlug } from '@/lib/api/workspace'; + +// import HubConfigCards from '@/components/dashboard/settings/hub-cards'; export default async function HubSettings({ params }: { params: { slug: string } }) { - // Fetch project data - const { data: project, error } = await getProjectBySlug(params.slug, 'server'); + // Fetch workspace data + const { data: workspace, error } = await getWorkspaceBySlug(params.slug, 'server'); if (error) { return {error.message}; } - // Fetch project config - const { data: projectConfig, error: configError } = await getProjectConfigBySlug(params.slug, 'server'); + // Fetch workspace config + const { data: workspaceConfig, error: configError } = await getWorkspaceModuleConfig(params.slug, 'server'); if (configError) { return {configError.message}; @@ -19,7 +21,7 @@ export default async function HubSettings({ params }: { params: { slug: string } return ( {/* Hub Card */} - + {/* */} ); } diff --git a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx index f1cbc8e..b50f052 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/loading.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; export default function IntegrationsLoading() { return ( diff --git a/apps/web/app/dash/[slug]/settings/integrations/page.tsx b/apps/web/app/dash/[slug]/settings/integrations/page.tsx index 1bd1711..2b94c5f 100644 --- a/apps/web/app/dash/[slug]/settings/integrations/page.tsx +++ b/apps/web/app/dash/[slug]/settings/integrations/page.tsx @@ -1,12 +1,81 @@ -import { getProjectConfigBySlug } from '@/lib/api/projects'; -import IntegrationCards from '@/components/dashboard/settings/integration-cards'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import FeedbackModal from '@/components/modals/send-feedback-modal'; +import SettingsCard from '@/components/settings/settings-card'; -export default async function IntegrationsSettings({ params }: { params: { slug: string } }) { - const { data, error } = await getProjectConfigBySlug(params.slug, 'server'); +function IntegrationCard({ + title, + description, + image, + alt, + onClick, +}: { + title: string; + description: string; + image: string; + alt: string; + onClick?: () => void; +}) { + return ( + + {/* Avatar */} + + + {alt} + - if (error) { - return Error; - } + {/* Name and Description */} + + {title} - return ; + {description} + + + ); +} + +export default function IntegrationSettings({ params }: { params: { slug: string } }) { + return ( + + + + + + + + + + + More integrations coming soon! If you have a specific integration you would like to see,{' '} + + + let us know + + + ! + + + ); } diff --git a/apps/web/app/dash/[slug]/settings/layout.tsx b/apps/web/app/dash/[slug]/settings/layout.tsx index 85b549f..7b7f8f9 100644 --- a/apps/web/app/dash/[slug]/settings/layout.tsx +++ b/apps/web/app/dash/[slug]/settings/layout.tsx @@ -1,24 +1,84 @@ -import { headers } from 'next/headers'; -import CategoryTabs from '@/components/dashboard/settings/category-tabs'; +import { + Blocks, + Earth, + GalleryVerticalEnd, + KanbanSquare, + RefreshCcw, + SquareAsterisk, + SwatchBook, + Terminal, + UsersRoundIcon, + Wallet2, + Webhook, +} from 'lucide-react'; +import { SidebarTabsProps } from '@/lib/types'; +import Sidebar from '@/components/layout/sidebar'; -const tabs = [ - { - name: 'General', - slug: 'general', - }, - { - name: 'Hub', - slug: 'hub', - }, - { - name: 'Team', - slug: 'team', - }, - { - name: 'Integrations', - slug: 'integrations', - }, -]; +const tabs: SidebarTabsProps = { + Workspace: [ + { + name: 'Branding', + customIcon: , + slug: 'settings/general', + }, + { + name: 'Team Members', + customIcon: , + slug: 'settings/team', + }, + { + name: 'Billing', + customIcon: , + slug: 'settings/billing', + }, + ], + Public: [ + { + name: 'Domain', + customIcon: , + slug: 'settings/domain', + }, + { + name: 'SSO', + customIcon: , + slug: 'settings/sso', + }, + ], + Modules: [ + { + name: 'Feedback', + customIcon: , + slug: 'settings/feedback', + }, + { + name: 'Roadmap', + customIcon: , + slug: 'settings/roadmap', + }, + { + name: 'Changelog', + customIcon: , + slug: 'settings/changelog', + }, + ], + Additional: [ + { + name: 'API', + customIcon: , + slug: 'settings/api', + }, + { + name: 'Webhooks', + customIcon: , + slug: 'settings/webhooks', + }, + { + name: 'Integrations', + customIcon: , + slug: 'settings/integrations', + }, + ], +}; export default function SettingsLayout({ children, @@ -27,20 +87,21 @@ export default function SettingsLayout({ children: React.ReactNode; params: { slug: string }; }) { - // Headers - const headerList = headers(); - const pathname = headerList.get('x-pathname'); - - // Retrieve the currently active tab - const activeTabIndex = tabs.findIndex((tab) => pathname?.split('/')[3] === tab.slug); - return ( - {/* Navigation tabs */} - + {/* Sidebar */} + {/* Content */} - {children} + + + {children} + + ); } diff --git a/apps/web/app/dash/[slug]/settings/roadmap/page.tsx b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx new file mode 100644 index 0000000..a37a7c4 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/roadmap/page.tsx @@ -0,0 +1,17 @@ +import { Label } from '@feedbase/ui/components/label'; +import { Switch } from '@feedbase/ui/components/switch'; +import SettingsCard from '@/components/settings/settings-card'; + +export default function FeedbackSettings() { + return ( + + + Disable Roadmap + + + This will disable the roadmap for your workspace. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/sso/page.tsx b/apps/web/app/dash/[slug]/settings/sso/page.tsx new file mode 100644 index 0000000..ef4b2c9 --- /dev/null +++ b/apps/web/app/dash/[slug]/settings/sso/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@feedbase/ui/components/alert-dialog'; +import { Button } from '@feedbase/ui/components/button'; +import { Checkbox } from '@feedbase/ui/components/checkbox'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from '@feedbase/ui/components/responsive-dialog'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useWorkspace from '@/lib/swr/use-workspace'; +import { actionFetcher, formatRootUrl } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import CopyCheckIcon from '@/components/shared/copy-check-icon'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; +import InputGroup from '@/components/shared/input-group'; + +export default function SSOSettings({ params }: { params: { slug: string } }) { + const [ssoConfig, setSsoConfig] = useState<{ + enabled: boolean; + url: string | null | undefined; + secret: string | null | undefined; + }>({ + enabled: false, + url: null, + secret: '', + }); + const [hasCopied, setHasCopied] = useState(); + const [open, setOpen] = useState(false); + const { workspace, loading, error, mutate } = useWorkspace(); + + const { trigger: updateWorkspaceSSO, isMutating: isUpdatingWorkspaceSSO } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso`, + actionFetcher, + { + onSuccess: () => { + mutate(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + const { trigger: generateJwtSecret, isMutating: isGenerating } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/sso/secret`, + actionFetcher, + { + onSuccess: (data: { secret: string }) => { + setSsoConfig({ ...ssoConfig, secret: data.secret }); + setOpen(true); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + useEffect(() => { + if (workspace) { + setSsoConfig({ + enabled: workspace.sso_auth_enabled, + url: workspace.sso_auth_url, + secret: workspace.sso_auth_secret_id ? 'dummy-secret' : undefined, + }); + } + }, [workspace]); + + // Loading state + if (loading && !error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + ); + } + + // Error state + if (error) { + return ( + + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + }> + + + + + ); + } + + return ( + { + setSsoConfig({ ...ssoConfig, enabled: checked }); + toast.promise(updateWorkspaceSSO({ method: 'PATCH', enabled: checked }), { + loading: ssoConfig.enabled ? `Disabling Single Sign-On` : `Enabling Single Sign-On`, + success: ssoConfig.enabled ? `Single Sign-On disabled` : `Single Sign-On enabled`, + error: () => { + setSsoConfig({ ...ssoConfig, enabled: !checked }); + return `Failed to ${ssoConfig.enabled ? 'disable' : 'enable'} Single Sign-On`; + }, + }); + }} + description={ + <> + Configure single sign-on for your workspace.{' '} + + Learn more + + . + > + } + showSave={workspace?.sso_auth_url !== ssoConfig.url} + onCancel={() => { + setSsoConfig({ ...ssoConfig, url: workspace?.sso_auth_url }); + }} + onSave={async () => { + await updateWorkspaceSSO({ + method: 'PATCH', + enabled: ssoConfig.enabled, + url: ssoConfig.url, + secret: ssoConfig.secret, + }); + }}> + + Login Url + { + setSsoConfig({ ...ssoConfig, url: e.target.value }); + }} + /> + + The url where users will be redirected to login. + + + + + JWT Secret + + + + + + { + setHasCopied(false); + if (!ssoConfig.secret) { + e.preventDefault(); + await generateJwtSecret({}); + setOpen(true); + } + }} + disabled={isGenerating || isUpdatingWorkspaceSSO}> + {isGenerating ? : null} + {ssoConfig.secret ? 'Regenerate' : 'Generate'} + + + + + Are you absolutely sure? + + This action cannot be undone. This will generate a new secret for your Single Sign-On + configuration and invalidate the previous one. + + + + Cancel + { + await generateJwtSecret({}); + }}> + Continue + + + + + + + + Token Created + + This token can not be shown again. Please copy it and store it in a safe place. + + + + + Token + { + setHasCopied(true); + }} + /> + } + /> + + + + + { + setHasCopied(!hasCopied); + }} + id='copied' + /> + + I have copied this token + + + + { + toast.success('Token copied to clipboard'); + }}> + Done + + + + + + + + The secret used to sign the JWT token on your server. + + + + ); +} diff --git a/apps/web/app/dash/[slug]/settings/team/loading.tsx b/apps/web/app/dash/[slug]/settings/team/loading.tsx deleted file mode 100644 index 1753c58..0000000 --- a/apps/web/app/dash/[slug]/settings/team/loading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Skeleton } from 'ui/components/ui/skeleton'; - -export default function TeamLoading() { - return ( - - - - ); -} diff --git a/apps/web/app/dash/[slug]/settings/team/page.tsx b/apps/web/app/dash/[slug]/settings/team/page.tsx index 1343f64..eb627f2 100644 --- a/apps/web/app/dash/[slug]/settings/team/page.tsx +++ b/apps/web/app/dash/[slug]/settings/team/page.tsx @@ -1,32 +1,396 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui/components/ui/card'; -import { getProjectInvites } from '@/lib/api/invites'; -import { getProjectMembers } from '@/lib/api/projects'; -import { TeamTable } from '@/components/dashboard/settings/team-table'; +'use client'; -export default async function TeamSettings({ params }: { params: { slug: string } }) { - const { data: members, error } = await getProjectMembers(params.slug, 'server'); +import { useState } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@feedbase/ui/components/avatar'; +import { Button } from '@feedbase/ui/components/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuDestructiveItem, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@feedbase/ui/components/dropdown-menu'; +import { Input } from '@feedbase/ui/components/input'; +import { Label } from '@feedbase/ui/components/label'; +import { Skeleton } from '@feedbase/ui/components/skeleton'; +import { ChevronsUpDown, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; +import useSWRMutation from 'swr/mutation'; +import useTeamInvites from '@/lib/swr/use-team-invites'; +import useTeamMembers from '@/lib/swr/use-team-members'; +import { ExtendedInviteProps, TeamMemberProps } from '@/lib/types'; +import { actionFetcher, formatTimeAgo } from '@/lib/utils'; +import SettingsCard from '@/components/settings/settings-card'; +import FetchError from '@/components/shared/fetch-error'; +import { Icons } from '@/components/shared/icons/icons-static'; - if (error) { - return {error.message}; +export default function TeamSettings({ params }: { params: { slug: string } }) { + const { + teamMembers, + error: memberError, + mutate: mutateMember, + isValidating: isValidatingMember, + } = useTeamMembers(); + const { + teamInvites, + error: inviteError, + mutate: mutateInvite, + isValidating: inviteIsValidating, + } = useTeamInvites(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'all' | 'admins' | 'members' | 'invites'>('all'); + const [inviteEmail, setInviteEmail] = useState(''); + + // Filtered + const filteredMembers = teamMembers?.filter( + (member) => + member.full_name.toLowerCase().includes(search.toLowerCase()) || + member.email.toLowerCase().includes(search.toLowerCase()) + ); + const filteredInvites = teamInvites?.filter((invite) => + invite.email.toLowerCase().includes(search.toLowerCase()) + ); + + // Send invite + const { trigger: sendInvite, isMutating: isSendingInvite } = useSWRMutation( + `/api/v1/workspaces/${params.slug}/invites`, + actionFetcher, + { + onSuccess: () => { + toast.success('Invite sent successfully.'); + // mutateMember(); + }, + onError: (error) => { + toast.error(error.message); + }, + } + ); + + // Revoking invite + function revokeInvite(inviteId: string) { + const req = fetch(`/api/v1/workspaces/${params.slug}/invites/${inviteId}`, { + method: 'DELETE', + }); + + req.then(() => { + mutateInvite(); + }); + } + + // Error state + if (memberError || inviteError) { + return memberError ? ( + + ) : ( + + ); } - const { data: invites, error: invitesError } = await getProjectInvites(params.slug, 'server'); + // Loading state + if (!filteredMembers && isValidatingMember && !filteredInvites && inviteIsValidating) { + return ( + <> + + + Email + + + + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + + {filter === 'all' && 'members'} + {filter === 'admins' && 'admins'} + {filter === 'members' && 'members'} + {filter === 'invites' && 'pending invites'} + + - if (invitesError) { - return {invitesError.message}; + + + Loading team members... + + + > + ); } return ( - - - - Team - Add or remove users that have access to this project. - - - - - - + <> + + + Email + { + e.preventDefault(); + sendInvite({ email: inviteEmail }); + }}> + { + setInviteEmail(e.target.value); + }} + /> + + {isSendingInvite ? : null} + Send Invite + + + + The user will receive an email with an invite link. + + + + + + + {/* Action Row */} + + { + setSearch(e.target.value); + }} + value={search} + /> + + + + {filter === 'all' + ? 'All' + : filter === 'admins' + ? 'Admins' + : filter === 'members' + ? 'Members' + : 'Invites'} + + + + + { + setFilter('all'); + }}> + All + + { + setFilter('admins'); + }}> + Admins + + { + setFilter('members'); + }}> + Members + + { + setFilter('invites'); + }}> + Pending Invites + + + + + + {/* Members */} + + + {filter === 'all' && (filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0)} + {/* TODO: Implement different roles, currently all are handled the same. */} + {filter === 'admins' && (filteredMembers?.length ?? 0)} + {filter === 'members' && (filteredMembers?.length ?? 0)} + {filter === 'invites' && (filteredInvites?.length ?? 0)}{' '} + {filter === 'all' && + ((filteredMembers?.length ?? 0) + (filteredInvites?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'admins' && ((filteredMembers?.length ?? 0) > 1 ? 'admins' : 'admin')} + {filter === 'members' && ((filteredMembers?.length ?? 0) > 1 ? 'members' : 'member')} + {filter === 'invites' && + ((filteredInvites?.length ?? 0) > 1 ? 'pending invites' : 'pending invite')} + + + + {/* Team Members */} + {(filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.map((member: TeamMemberProps) => ( + + + + + + {member.full_name[0]} + + + + {member.full_name} + {member.email} + + + + + {/* Joined days ago */} + + Joined {formatTimeAgo(new Date(member.joined_at))} ago + + + + + + + + + Make Admin + Suspend + + + + + ))} + + {/* Pending Invites */} + {(filter === 'all' || filter === 'invites') && + filteredInvites?.map((invite: ExtendedInviteProps) => ( + + + + + {invite.email[0]} + + + + {invite.email} + + Invited by {invite.creator.full_name} + + + + + + + Invited {formatTimeAgo(new Date(invite.created_at))} ago + + + + + + + + + + Resend + { + revokeInvite(invite.id); + }}> + Revoke + + + + + + ))} + + {/* Empty States */} + {((filter === 'all' || filter === 'admins' || filter === 'members') && + filteredMembers?.length === 0 && + filteredInvites?.length === 0) || + (filter === 'invites' && filteredInvites?.length === 0) ? ( + + + {filter === 'all' || filter === 'admins' || filter === 'members' + ? 'No members found.' + : 'No pending invites found.'} + + + ) : null} + + + + + > ); } diff --git a/apps/web/app/dash/invite/[inviteId]/page.tsx b/apps/web/app/dash/invite/[inviteId]/page.tsx index 6b817f9..c8fa036 100644 --- a/apps/web/app/dash/invite/[inviteId]/page.tsx +++ b/apps/web/app/dash/invite/[inviteId]/page.tsx @@ -1,10 +1,10 @@ import { notFound } from 'next/navigation'; -import { getProjectInvite } from '@/lib/api/invites'; +import { getWorkspaceInvite } from '@/lib/api/invite'; import { getCurrentUser } from '@/lib/api/user'; -import ProjectInviteForm from '@/components/layout/accept-invite-form'; +import WorkspaceInviteForm from '@/components/workspace/accept-invite-form'; -export default async function ProjectInvite({ params }: { params: { inviteId: string } }) { - const { data: invite, error: inviteError } = await getProjectInvite(params.inviteId, 'server'); +export default async function WorkspaceInvite({ params }: { params: { inviteId: string } }) { + const { data: invite, error: inviteError } = await getWorkspaceInvite(params.inviteId, 'server'); if (inviteError) { return notFound(); @@ -15,7 +15,7 @@ export default async function ProjectInvite({ params }: { params: { inviteId: st return ( - + ); } diff --git a/apps/web/app/dash/page.tsx b/apps/web/app/dash/page.tsx index b9444a0..12760b8 100644 --- a/apps/web/app/dash/page.tsx +++ b/apps/web/app/dash/page.tsx @@ -1,9 +1,10 @@ import { redirect } from 'next/navigation'; -import { getUserProjects } from '@/lib/api/user'; -import Onboarding from '@/components/layout/onboarding'; +import { getUserWorkspaces } from '@/lib/api/user'; +import Onboarding from '@/components/workspace/onboarding'; +import WorkspaceOverview from '@/components/workspace/workspace-overview'; -export default async function Projects() { - const { data: projects, error } = await getUserProjects('server'); +export default async function Workspaces() { + const { data: workspaces, error } = await getUserWorkspaces('server'); if (error) { // Redirect to login if the user is not authenticated @@ -14,15 +15,10 @@ export default async function Projects() { return {error.message}; } - // Redirect to the first project - if (projects.length > 0) { - return redirect(`/${projects[0].slug}`); - } - - // TODO: Improve this and make this redirect to an onboarding page if the user has no projects + // TODO: Improve this and make this redirect to an onboarding page if the user has no workspaces return ( - + {workspaces && workspaces.length > 0 ? : } ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index cfce9bc..13871c1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,77 +1,127 @@ @tailwind base; @tailwind components; @tailwind utilities; - + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* + https://github.com/tailwindlabs/tailwindcss/discussions/2394 + https://github.com/tailwindlabs/tailwindcss/pull/5732 +*/ +@layer utilities { + /* Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .custom-scrollbar::-webkit-scrollbar { + width: 14px; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); + background-clip: padding-box; + border-radius: 50px; + background-color: hsl(var(--border) / 1); + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--foreground) / 0.15); + } +} + @layer base { :root { /* App root background */ - --root-background: 230 35% 3%; /* #000000 */ + --root-background: 0 0% 100%; + --background: 210 20% 98%; + --foreground: 224 71% 4%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 224 71% 4%; + --primary-foreground: 0 0% 98%; + --secondary: 240 5% 95%; + --secondary-foreground: 215 14% 34%; + --muted: 240 5% 96%; + --muted-foreground: 218 11% 65%; + --accent: 240 5% 94%; + --accent-foreground: 224 71% 4%; + --destructive: 0 79% 63%; + --destructive-foreground: 0 0% 100%; + --border: 220 13% 91%; + --input: 220 13% 91%; + --highlight: 231 100% 78%; + --ring: 216 12% 84%; + + /* Charts */ + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + --radius: 0.55rem; + } + + .dark { + /* App root background */ + --root-background: 235 7% 5%; /* Utility background (Buttons, inputs, etc) */ - --background: 230 11% 12%; /* #0A0C10 | #060606*/ - --foreground: 210 40% 98%; /* #D6D6D6 */ - + --background: 230 7% 16%; + --foreground: 220 9% 94%; + /* Muted background (Skeletons, Avatar, tabs, etc) */ - --muted: 230 11% 8%; /* #20242c */ - --muted-foreground: 215 6% 62%; - - --popover: 230 35% 3%; /* #0A0C10 */ - --popover-foreground: 210 40% 98%; - - --card: 230 35% 3%; /* #0A0C10 */ - --card-foreground: 210 40% 98%; - - --border: 222 9% 22%; /* #20242c */ - --input: 222 9% 22%; /* #20242c */ - - --primary: 210 40% 98%; - --primary-foreground: 230 35% 3%; - + --muted: 219 6% 12%; + --muted-foreground: 219 6% 65%; + + --popover: 235 7% 5%; + --popover-foreground: 220 9% 94%; + + --card: 235 7% 5%; + --card-foreground: 220 9% 94%; + + --border: 223 7% 19%; + --input: 223 6% 22%; + + --primary: 220 9% 94%; + --primary-foreground: 240 4% 10%; + /* Hover & active backgrounds (Nav buttons, etc) */ - --secondary: 227 11% 12%; /* #20242c */ - --secondary-foreground: 210 40% 98%; - + --secondary: 230 7% 16%; + --secondary-foreground: 218 7% 80%; + /* Button background hovers */ - --accent: 227 11% 15%; /* #20242c */ - --accent-foreground: 210 40% 98%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - + --accent: 230 7% 18%; + --accent-foreground: 220 9% 94%; + + --destructive: 0 79% 63%; + --destructive-foreground: 220 9% 94%; + /* Selection & accent color */ --highlight: 231 100% 78%; - - --ring: 230 9% 13%; /* #20242c */ - --radius: 0.5rem; - } - - .light { - /* App root background */ - --root-background: 0 0% 100%; /* #000000 */ - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --highlight: 231 100% 78%; - --ring: 240 5.9% 10%; + /* Charts */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + --ring: 230 9% 13%; } } - + @layer base { * { @apply border-border; @@ -79,4 +129,4 @@ body { @apply bg-root text-foreground selection:bg-highlight/20 selection:text-highlight; } -} \ No newline at end of file +} diff --git a/apps/web/app/home/(pages)/deploy/page.tsx b/apps/web/app/home/(pages)/deploy/page.tsx index b5f2ada..c98b5ee 100644 --- a/apps/web/app/home/(pages)/deploy/page.tsx +++ b/apps/web/app/home/(pages)/deploy/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Button } from '@feedbase/ui/components/button'; import HomeFooter from '@/components/home/footer'; export default function Deploy() { @@ -9,7 +9,7 @@ export default function Deploy() { Deploy Feedbase to Vercel - + You can deploy your own hosted instance of Feedbase to Vercel, for free, in just a few clicks. @@ -27,7 +27,7 @@ export default function Deploy() { Create a Supabase project - + Supabase is an open source Firebase alternative. It provides a database, auth, and storage. @@ -51,7 +51,7 @@ export default function Deploy() { Fork the Supabase database - + Once you have your Supabase project, you can fork the Feedbase database schemas to your own project. @@ -79,7 +79,7 @@ export default function Deploy() { Prepare your environment variables - + After setting up your Supabase project, check the example configuration to see where you can find the necessary environment variables. @@ -107,7 +107,7 @@ export default function Deploy() { Deploy to Vercel - + Once you have your environment variables, you can deploy to Vercel. diff --git a/apps/web/app/home/(pages)/page.tsx b/apps/web/app/home/(pages)/page.tsx index a58c6ad..15e2c4d 100644 --- a/apps/web/app/home/(pages)/page.tsx +++ b/apps/web/app/home/(pages)/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; -import { Button } from '@ui/components/ui/button'; +import { Background } from '@feedbase/ui/components/background/background'; +import { Button } from '@feedbase/ui/components/button'; import { ArrowRight, ChevronRight } from 'lucide-react'; -import { Background } from 'ui/components/ui/background/background'; import { formatRootUrl } from '@/lib/utils'; import ChangelogSection from '@/components/home/changelog-section'; import DashboardSection from '@/components/home/dashboard-section'; @@ -27,7 +27,7 @@ export default function Landing() { in One Central Place - + Feedbase simplifies feedback collection, feature prioritization, and product update sharing, allowing you to focus on building. @@ -39,7 +39,7 @@ export default function Landing() { Star on GitHub @@ -48,7 +48,7 @@ export default function Landing() { + className='text-foreground/60 hover:text-foreground/90 group relative mt-4 flex w-fit flex-row items-center justify-center p-1 text-center text-sm '> See it in action @@ -73,7 +73,7 @@ export default function Landing() { Create your feedback community today. - + Capture feedback, post updates, and engage with your users in one central place. diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8932134..274aa79 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,11 +1,12 @@ import './globals.css'; import { Metadata, Viewport } from 'next'; import Script from 'next/script'; -import { cn } from '@ui/lib/utils'; +import { cn } from '@feedbase/ui/lib/utils'; import { SpeedInsights } from '@vercel/speed-insights/next'; -import { GeistSans } from 'geist/font'; +import { GeistSans } from 'geist/font/sans'; import { Toaster } from 'sonner'; import { formatRootUrl } from '@/lib/utils'; +import { ThemeProvider as NextThemeProvider } from '@/components/theme-provider'; export const metadata: Metadata = { title: 'Feedbase', @@ -49,9 +50,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) )} - - {children} - + + + {children} + +
+ {workspaceConfig.changelog_preview_style === 'summary' && ( +
{changelog.summary}
The latest updates, improvements, and fixes will be posted here. Stay tuned!
+ +
← Back to Posts
{new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -206,17 +177,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */}
{new Date(feedback.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', @@ -324,17 +288,15 @@ export default async function FeedbackDetails({ params }: Props) { {/* Author */}
+ Have a suggestion or found a bug? Let us know! +
+ View what we're working on and what's coming next. +
You can deploy your own hosted instance of Feedbase to Vercel, for free, in just a few clicks.
Supabase is an open source Firebase alternative. It provides a database, auth, and storage.
Once you have your Supabase project, you can fork the Feedbase database schemas to your own project.
After setting up your Supabase project, check the example configuration to see where you can find the necessary environment variables.
Once you have your environment variables, you can deploy to Vercel.
Feedbase simplifies feedback collection, feature prioritization, and product update sharing, allowing you to focus on building.
See it in action
Capture feedback, post updates, and engage with your users in one central place.