├── src ├── lib │ ├── queries.ts │ ├── api.ts │ ├── server.ts │ ├── router.ts │ ├── db.ts │ ├── sessions.ts │ └── adminQueries.ts ├── images │ └── screenshots │ │ ├── list.png │ │ ├── builder.png │ │ ├── digest.png │ │ └── discover.png ├── theme │ ├── admin.css │ ├── app.css │ ├── shadows.ts │ └── globals.css ├── components │ ├── Droppable.tsx │ ├── digests │ │ ├── block-card │ │ │ ├── bookmark-card │ │ │ │ └── card-style │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── Tweet.tsx │ │ │ │ │ └── Tweet.module.css │ │ │ └── BlockCard.tsx │ │ ├── AddTextBlockButton.tsx │ │ ├── templates │ │ │ └── SelectTemplateModal.tsx │ │ └── dialog │ │ │ └── SummaryButton.tsx │ ├── heading │ │ └── SectionTitle.tsx │ ├── teams │ │ ├── BookmarkCountBadge.tsx │ │ ├── form │ │ │ └── settings │ │ │ │ ├── TeamIntegrations.tsx │ │ │ │ └── TeamTemplates.tsx │ │ ├── TeamAvatar.tsx │ │ ├── settings-tabs │ │ │ ├── invitations │ │ │ │ ├── InvitationItem.tsx │ │ │ │ └── InvitationList.tsx │ │ │ ├── subscribers │ │ │ │ └── List.tsx │ │ │ └── members │ │ │ │ └── List.tsx │ │ └── Breadcrumb.tsx │ ├── layout │ │ ├── NavMenu │ │ │ └── Divider.tsx │ │ ├── BadgeOnline.tsx │ │ ├── SectionContainer.tsx │ │ ├── BrandIcon.tsx │ │ ├── HeaderSkeleton.tsx │ │ ├── PageContainer.tsx │ │ ├── NoContent.tsx │ │ ├── Logo.tsx │ │ ├── Header.tsx │ │ └── PublicPageTemplate.tsx │ ├── buttons │ │ └── SubmitButton.tsx │ ├── Loading.tsx │ ├── charts │ │ ├── ChartsSkeleton.tsx │ │ ├── ChartsTooltip.tsx │ │ ├── ChartsServer.tsx │ │ └── Charts.tsx │ ├── CounterTag.tsx │ ├── Loader.tsx │ ├── account │ │ └── UserInvitations.tsx │ ├── home │ │ ├── Section.tsx │ │ ├── HomeDigests.tsx │ │ ├── HomeOpenSource.tsx │ │ └── Hero.tsx │ ├── Card.tsx │ ├── Tooltip.tsx │ ├── pages │ │ ├── Homepage.tsx │ │ ├── DigestPublicPage.tsx │ │ └── DigestEditVisit.tsx │ ├── admin │ │ └── widgets │ │ │ ├── LinksOverTime.tsx │ │ │ ├── LinksByWebsite.tsx │ │ │ └── DataOverTime.tsx │ ├── Tag.tsx │ ├── bookmark │ │ ├── CreateBookmarkButton.tsx │ │ ├── BookmarksListControls.tsx │ │ ├── HeaderCreateBookmarkButton.tsx │ │ └── BookmarkListDnd.tsx │ ├── ActiveTeams.tsx │ ├── TagsList.tsx │ ├── Avatar.tsx │ └── RssButton.tsx ├── app │ ├── [...not_found] │ │ ├── page.tsx │ │ └── layout.tsx │ ├── (app) │ │ ├── logout │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── (routes) │ │ │ ├── layout.tsx │ │ │ ├── teams │ │ │ │ ├── layout.tsx │ │ │ │ ├── [teamSlug] │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── settings │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── integrations │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── templates │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── digests │ │ │ │ │ │ └── [digestId] │ │ │ │ │ │ └── edit │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── create │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── [teamSlug] │ │ │ │ ├── (feed) │ │ │ │ │ ├── rss.xml │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── atom.xml │ │ │ │ │ │ └── route.ts │ │ │ │ └── [digestSlug] │ │ │ │ │ ├── preview │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── auth │ │ │ │ ├── login │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── unsubscribe │ │ │ │ └── page.tsx │ │ │ ├── invitations │ │ │ │ └── [invitationId] │ │ │ │ │ └── accept │ │ │ │ │ └── page.tsx │ │ │ └── account │ │ │ │ └── page.tsx │ │ ├── providers.tsx │ │ ├── error.tsx │ │ ├── not-found.tsx │ │ └── updates │ │ │ └── page.tsx │ ├── robots.ts │ └── (admin) │ │ ├── api │ │ └── admin │ │ │ └── [[...nextadmin]] │ │ │ └── route.ts │ │ └── layout.tsx ├── utils │ ├── prisma.ts │ ├── url.ts │ ├── open-graph-url.ts │ ├── actionOnList.ts │ ├── openai.ts │ ├── date.ts │ ├── slack.ts │ ├── page.ts │ ├── string.ts │ ├── color.ts │ ├── apiError.tsx │ ├── template.ts │ ├── rateLimit.ts │ ├── bookmark.ts │ ├── feed.ts │ └── link │ │ └── index.ts ├── services │ └── database │ │ ├── subscription.ts │ │ ├── user.ts │ │ ├── membership.ts │ │ └── invitation.ts ├── emails │ ├── theme.ts │ ├── index.ts │ └── templates │ │ └── InvitationEmail.tsx ├── hooks │ ├── useCustomToast.tsx │ └── useTransitionRefresh.ts ├── contexts │ └── TeamContext.tsx ├── pages │ ├── _error.tsx │ └── api │ │ ├── tags │ │ └── index.ts │ │ ├── user │ │ ├── default-team.ts │ │ └── [userId] │ │ │ └── index.ts │ │ ├── teams │ │ ├── [teamId] │ │ │ ├── bookmark │ │ │ │ ├── [bookmarkId] │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── invitations │ │ │ │ └── [invitationId] │ │ │ │ │ ├── delete.tsx │ │ │ │ │ └── accept.tsx │ │ │ ├── index.tsx │ │ │ ├── key │ │ │ │ └── new.tsx │ │ │ ├── digests │ │ │ │ ├── index.ts │ │ │ │ └── [digestId] │ │ │ │ │ └── typefully.ts │ │ │ └── members │ │ │ │ └── [memberId].tsx │ │ └── connect │ │ │ └── typefully.ts │ │ ├── auth │ │ └── [...nextauth].tsx │ │ ├── bookmark-og.tsx │ │ ├── team-og.tsx │ │ ├── digest-og.tsx │ │ └── webhooks │ │ └── slack │ │ └── shortcuts.ts └── actions │ ├── update-team-info.ts │ ├── update-user.ts │ ├── delete-invitation.ts │ ├── utils.ts │ └── generate-api-key.ts ├── public ├── favicon.ico ├── og-cover.png ├── logo-digest.png ├── changelogs │ ├── changelog-001.webp │ ├── changelog-002.webp │ ├── changelog-003.webp │ ├── changelog-004.webp │ ├── changelog-005.webp │ ├── changelog-006.webp │ ├── changelog-007.webp │ ├── changelog-008.webp │ └── changelog-009.webp ├── vercel.svg └── slack.svg ├── .eslintrc.json ├── styles ├── Montserrat-Thin.ttf ├── Montserrat-Regular.ttf └── Montserrat-SemiBold.ttf ├── types ├── api.d.ts └── next-auth.d.ts ├── postcss.config.js ├── prisma └── migrations │ ├── 20230315223842_update_enum │ └── migration.sql │ ├── 20231009130715_team_color │ └── migration.sql │ ├── 20230620145041_add_api_key │ └── migration.sql │ ├── 20230818095838_add_logo_to_link │ └── migration.sql │ ├── 20230315093524_remove_unique_constraint_title │ └── migration.sql │ ├── 20230321144039_add_digest_slug │ └── migration.sql │ ├── 20230609100206_rename_bookmark_digests_to_digest_block │ └── migration.sql │ ├── 20230316093529_add_metadata_bookmark │ └── migration.sql │ ├── 20230428093324_add_typefully_key │ └── migration.sql │ ├── 20230821114929_add_tweet_embed_style │ └── migration.sql │ ├── 20230505145858_add_typefully_thread_id │ └── migration.sql │ ├── 20231003081810_predict_digest_name │ └── migration.sql │ ├── 20230912152515_add_featured_flag │ └── migration.sql │ ├── 20231003092620_add_digest_template │ └── migration.sql │ ├── migration_lock.toml │ ├── 20230315170514_add_slack_token │ └── migration.sql │ ├── 20231024154423_bookmark_summary │ └── migration.sql │ ├── 20231005083313_digestblock_istemplate │ └── migration.sql │ ├── 20230531144239_add_hassentnewsletter_field │ └── migration.sql │ ├── 20230612095908_add_created_at_to_teams │ └── migration.sql │ ├── 20230130170117_add_created_date_to_invitations │ └── migration.sql │ ├── 20230407083642_add_title_and_desc_to_bookmark_digest │ └── migration.sql │ ├── 20230322135339_add_profile_fields │ └── migration.sql │ ├── 20231102150640_digest_views │ └── migration.sql │ ├── 20230412121851_add_bookmarkdigest_style │ └── migration.sql │ ├── 20230530100155_update_subscription │ └── migration.sql │ ├── 20230210104913_add_default_team │ └── migration.sql │ ├── 20230605160311_add_text_block_in_digest │ └── migration.sql │ ├── 20230321144114_add_slug_unique_constraint │ └── migration.sql │ ├── 20230316100407_add_unique_constraint_team_id │ └── migration.sql │ ├── 20230524124748_rename_typefully_thread_id_to_thread_url │ └── migration.sql │ ├── 20230322082759_set_digest_slug_non_nullable │ └── migration.sql │ ├── 20230606094441_rename │ └── migration.sql │ ├── 20230530095640_added_subscription │ └── migration.sql │ ├── 20230609121534_update_digest_block_relationships │ └── migration.sql │ ├── 20230316154959_add_delete_cascade_bookmark │ └── migration.sql │ ├── 20230315222747_add_team │ └── migration.sql │ ├── 20230307140817_add_digest_model_squashed_migrations │ └── migration.sql │ ├── 20230315134428_add_bookmark_digest │ └── migration.sql │ ├── 20230213142412_create_links_and_bookmarks │ └── migration.sql │ ├── 20230316171352_add_cascade_behaviours │ └── migration.sql │ └── 20231206160616_add_tag_table │ └── migration.sql ├── sentry.properties ├── Pipfile ├── .prettierrc ├── .vscode └── settings.json ├── .husky └── pre-commit ├── jest.config.ts ├── .env.example ├── changelogs ├── changelog-009.md ├── changelog-008.md ├── changelog-006.md ├── changelog-005.md ├── changelog-001.md └── changelog-004.md ├── sentry.server.config.ts ├── sentry.edge.config.ts ├── docker-compose.yml ├── contentlayer.config.ts ├── .github └── workflows │ ├── code-check.yml │ └── test.yml ├── .gitignore ├── tsconfig.json ├── sentry.client.config.ts ├── LICENSE └── next.config.js /src/lib/queries.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/og-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/og-cover.png -------------------------------------------------------------------------------- /public/logo-digest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/logo-digest.png -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { "no-console": "error" } 4 | } 5 | -------------------------------------------------------------------------------- /styles/Montserrat-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/styles/Montserrat-Thin.ttf -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default axios.create({ 4 | baseURL: '/api/', 5 | }); 6 | -------------------------------------------------------------------------------- /styles/Montserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/styles/Montserrat-Regular.ttf -------------------------------------------------------------------------------- /types/api.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@postlight/parser'; 2 | 3 | type ErrorResponse = { 4 | error: string; 5 | }; 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/migrations/20230315223842_update_enum/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "Provider" ADD VALUE 'SLACK'; 3 | -------------------------------------------------------------------------------- /src/images/screenshots/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/src/images/screenshots/list.png -------------------------------------------------------------------------------- /styles/Montserrat-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/styles/Montserrat-SemiBold.ttf -------------------------------------------------------------------------------- /prisma/migrations/20231009130715_team_color/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "color" TEXT; 3 | -------------------------------------------------------------------------------- /src/images/screenshots/builder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/src/images/screenshots/builder.png -------------------------------------------------------------------------------- /src/images/screenshots/digest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/src/images/screenshots/digest.png -------------------------------------------------------------------------------- /prisma/migrations/20230620145041_add_api_key/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "apiKey" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230818095838_add_logo_to_link/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "links" ADD COLUMN "logo" TEXT; 3 | -------------------------------------------------------------------------------- /public/changelogs/changelog-001.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-001.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-002.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-002.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-003.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-003.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-004.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-004.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-005.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-005.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-006.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-006.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-007.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-007.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-008.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-008.webp -------------------------------------------------------------------------------- /public/changelogs/changelog-009.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/public/changelogs/changelog-009.webp -------------------------------------------------------------------------------- /src/images/screenshots/discover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/premieroctet/digestclub/HEAD/src/images/screenshots/discover.png -------------------------------------------------------------------------------- /prisma/migrations/20230315093524_remove_unique_constraint_title/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "digests_teamId_title_key"; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230321144039_add_digest_slug/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "digests" ADD COLUMN "slug" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230609100206_rename_bookmark_digests_to_digest_block/migration.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "bookmark_digest" RENAME TO "digest_blocks"; -------------------------------------------------------------------------------- /src/theme/admin.css: -------------------------------------------------------------------------------- 1 | @config "../../tailwind.admin.config.js"; 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20230316093529_add_metadata_bookmark/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "bookmarks" ADD COLUMN "metadata" JSONB; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230428093324_add_typefully_key/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "typefullyToken" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230821114929_add_tweet_embed_style/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "BookmarkDigestStyle" ADD VALUE 'TWEET_EMBED'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230505145858_add_typefully_thread_id/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "digests" ADD COLUMN "typefullyThreadId" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20231003081810_predict_digest_name/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "nextSuggestedDigestTitle" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230912152515_add_featured_flag/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "digests" ADD COLUMN "isFeatured" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20231003092620_add_digest_template/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "digests" ADD COLUMN "isTemplate" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/migrations/20230315170514_add_slack_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "slackTeamId" TEXT, 3 | ADD COLUMN "slackToken" TEXT; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20231024154423_bookmark_summary/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "prompt" TEXT, 3 | ADD COLUMN "subscriptionId" TEXT; 4 | -------------------------------------------------------------------------------- /sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.url=https://sentry.io/ 2 | defaults.org=premier-octet-z6 3 | defaults.project=digestclub 4 | cli.executable=node_modules/@sentry/cli/bin/sentry-cli 5 | -------------------------------------------------------------------------------- /prisma/migrations/20231005083313_digestblock_istemplate/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "digest_blocks" ADD COLUMN "isTemplate" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230531144239_add_hassentnewsletter_field/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "digests" ADD COLUMN "hasSentNewsletter" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230612095908_add_created_at_to_teams/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230130170117_add_created_date_to_invitations/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "invitations" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /src/components/Droppable.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic'; 2 | 3 | export default dynamic( 4 | () => import('react-beautiful-dnd').then((res) => res.Droppable), 5 | { ssr: false } 6 | ); 7 | -------------------------------------------------------------------------------- /prisma/migrations/20230407083642_add_title_and_desc_to_bookmark_digest/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "bookmark_digest" ADD COLUMN "description" TEXT, 3 | ADD COLUMN "title" TEXT; 4 | -------------------------------------------------------------------------------- /src/app/[...not_found]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { notFound } from 'next/navigation'; 3 | 4 | export default function NotFoundCatchAll() { 5 | notFound(); 6 | return null; 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | export const PRISMA_NOT_FOUND_ERROR_CODES = ['P2018', 'P2025']; 2 | 3 | export function isPrismaNotFoundError(error: any) { 4 | return PRISMA_NOT_FOUND_ERROR_CODES.includes(error.code); 5 | } 6 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | docker-compose = "*" 8 | 9 | [dev-packages] 10 | 11 | [requires] 12 | python_version = "3.9" 13 | -------------------------------------------------------------------------------- /prisma/migrations/20230322135339_add_profile_fields/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "teams" ADD COLUMN "bio" TEXT, 3 | ADD COLUMN "github" TEXT, 4 | ADD COLUMN "twitter" TEXT, 5 | ADD COLUMN "website" TEXT; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20231102150640_digest_views/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "bookmarks" ADD COLUMN "views" INTEGER NOT NULL DEFAULT 0; 3 | 4 | -- AlterTable 5 | ALTER TABLE "digests" ADD COLUMN "views" INTEGER NOT NULL DEFAULT 0; 6 | -------------------------------------------------------------------------------- /src/components/digests/block-card/bookmark-card/card-style/index.ts: -------------------------------------------------------------------------------- 1 | import CardStyleBlock from './Block'; 2 | import CardStyleInline from './Inline'; 3 | import CardStyleTweet from './Tweet'; 4 | 5 | export { CardStyleBlock, CardStyleInline, CardStyleTweet }; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20230412121851_add_bookmarkdigest_style/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "BookmarkDigestStyle" AS ENUM ('BLOCK', 'INLINE'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "bookmark_digest" ADD COLUMN "style" "BookmarkDigestStyle" NOT NULL DEFAULT 'BLOCK'; 6 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const getDomainFromUrl = (url: string) => { 2 | const hostname = new URL(url).hostname; 3 | const domain = hostname.split('.').slice(-2).join('.'); 4 | 5 | return domain; 6 | }; 7 | 8 | export const isPdfUrl = (url: string) => url.indexOf('.pdf') > -1; 9 | -------------------------------------------------------------------------------- /src/app/(app)/logout/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { signOut } from 'next-auth/react'; 4 | import { useEffect } from 'react'; 5 | 6 | export default function Logout() { 7 | useEffect(() => { 8 | signOut({ callbackUrl: '/' }); 9 | }, []); 10 | 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "editorconfig": false, 3 | "singleQuote": true, 4 | "jsxSingleQuote": false, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "trailingComma": "es5", 8 | "printWidth": 80, 9 | "bracketSpacing": true, 10 | "arrowParens": "always", 11 | "quoteProps": "consistent" 12 | } 13 | -------------------------------------------------------------------------------- /prisma/migrations/20230530100155_update_subscription/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `expirationTime` on the `subscriptions` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "subscriptions" DROP COLUMN "expirationTime"; 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | 5 | "prettier.configPath": ".prettierrc", 6 | "prettier.useEditorConfig": false, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /prisma/migrations/20230210104913_add_default_team/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ADD COLUMN "defaultTeamId" INTEGER; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "users" ADD CONSTRAINT "users_defaultTeamId_fkey" FOREIGN KEY ("defaultTeamId") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /src/app/(app)/page.tsx: -------------------------------------------------------------------------------- 1 | import Homepage from '@/components/pages/Homepage'; 2 | import { getCurrentUser } from '@/lib/sessions'; 3 | export const dynamic = 'force-dynamic'; 4 | 5 | const Home = async () => { 6 | const user = await getCurrentUser(); 7 | 8 | return ; 9 | }; 10 | 11 | export default Home; 12 | -------------------------------------------------------------------------------- /src/components/heading/SectionTitle.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | const SectionTitle = ({ children }: { children: ReactNode }) => { 4 | return ( 5 |

6 | {children} 7 |

8 | ); 9 | }; 10 | 11 | export default SectionTitle; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230605160311_add_text_block_in_digest/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "DigestBlockType" AS ENUM ('BOOKMARK', 'TEXT'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "bookmark_digest" ADD COLUMN "DigestBlockType" "DigestBlockType" NOT NULL DEFAULT 'BOOKMARK', 6 | ADD COLUMN "text" TEXT, 7 | ALTER COLUMN "bookmarkId" DROP NOT NULL; 8 | -------------------------------------------------------------------------------- /prisma/migrations/20230321144114_add_slug_unique_constraint/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[slug,teamId]` on the table `digests` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "digests_slug_teamId_key" ON "digests"("slug", "teamId"); 9 | -------------------------------------------------------------------------------- /src/app/[...not_found]/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Next.js', 3 | description: 'Generated by Next.js', 4 | } 5 | 6 | export default function RootLayout({ 7 | children, 8 | }: { 9 | children: React.ReactNode 10 | }) { 11 | return ( 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/teams/BookmarkCountBadge.tsx: -------------------------------------------------------------------------------- 1 | const BookmarkCountBadge = ({ count }: { count: number }) => { 2 | return ( 3 | 4 | {count} bookmark 5 | {count === 1 ? '' : 's'} 6 | 7 | ); 8 | }; 9 | 10 | export default BookmarkCountBadge; 11 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | }; 6 | 7 | const Layout = ({ children }: Props) => { 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | }; 14 | 15 | export default Layout; 16 | -------------------------------------------------------------------------------- /src/services/database/subscription.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import db from '@/lib/db'; 4 | 5 | export const getTeamSubscriptions = async (teamSlug: string) => { 6 | const subscriptions = await db.subscription.findMany({ 7 | where: { 8 | team: { 9 | slug: teamSlug, 10 | }, 11 | }, 12 | }); 13 | return subscriptions; 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/open-graph-url.ts: -------------------------------------------------------------------------------- 1 | import { getEnvHost } from '@/lib/server'; 2 | 3 | export function generateTeamOGUrl(teamSlug: string) { 4 | return encodeURI(`${getEnvHost()}/api/team-og?team=${teamSlug}`); 5 | } 6 | 7 | export function generateDigestOGUrl(digestSlug: string) { 8 | return encodeURI(`${getEnvHost()}/api/digest-og?digest=${digestSlug}`); 9 | } 10 | -------------------------------------------------------------------------------- /prisma/migrations/20230316100407_add_unique_constraint_team_id/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[id,slackTeamId]` on the table `teams` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "teams_id_slackTeamId_key" ON "teams"("id", "slackTeamId"); 9 | -------------------------------------------------------------------------------- /prisma/migrations/20230524124748_rename_typefully_thread_id_to_thread_url/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `typefullyThreadId` on the `digests` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "digests" DROP COLUMN "typefullyThreadId", 9 | ADD COLUMN "typefullyThreadUrl" TEXT; 10 | -------------------------------------------------------------------------------- /src/components/layout/NavMenu/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Divider() { 4 | return ( 5 |
6 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRole } from '@prisma/client'; 2 | import NextAuth, { DefaultSession, DefaultUser } from 'next-auth'; 3 | 4 | declare module 'next-auth' { 5 | interface Session { 6 | user: DefaultSession['user'] & { id: string; role: GlobalRole }; 7 | } 8 | 9 | interface User extends DefaultUser { 10 | role: GlobalRole; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /prisma/migrations/20230322082759_set_digest_slug_non_nullable/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `slug` on table `digests` required. This step will fail if there are existing NULL values in that column. 5 | 6 | */ 7 | -- AlterTable 8 | UPDATE "digests" SET "slug" = CONCAT('digest-', id); 9 | ALTER TABLE "digests" ALTER COLUMN "slug" SET NOT NULL; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20230606094441_rename/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `DigestBlockType` on the `bookmark_digest` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "bookmark_digest" DROP COLUMN "DigestBlockType", 9 | ADD COLUMN "type" "DigestBlockType" NOT NULL DEFAULT 'BOOKMARK'; 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | 5 | yarn lint --quiet || 6 | ( 7 | echo '💥 ❌ ESLint Errors' 8 | false; 9 | ) 10 | ( 11 | echo '✨ ✅ ESLint !' 12 | true; 13 | ) 14 | 15 | 16 | yarn tsc --noEmit || 17 | ( 18 | echo '🙈 ❌ TS Errors' 19 | false; 20 | ) 21 | ( 22 | echo '👑 ✅ TypeScript !' 23 | true; 24 | ) -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next'; 2 | 3 | const siteUrl = process.env.VERCEL_URL || 'https://digest.club'; 4 | 5 | export default function robots(): MetadataRoute.Robots { 6 | return { 7 | rules: { 8 | userAgent: '*', 9 | disallow: ['/admin', '/unsubscribe', '/auth/login', '/teams'], 10 | }, 11 | sitemap: `${siteUrl}/sitemap.xml`, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/buttons/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useFormStatus } from 'react-dom'; 3 | import Button from '../Button'; 4 | 5 | const FormButton = () => { 6 | const { pending } = useFormStatus(); 7 | return ( 8 | 11 | ); 12 | }; 13 | 14 | export default FormButton; 15 | -------------------------------------------------------------------------------- /src/lib/server.ts: -------------------------------------------------------------------------------- 1 | export function getEnvHost() { 2 | if (process.env.VERCEL_ENV === 'preview') { 3 | return process.env.VERCEL_URL 4 | ? `https://${process.env.VERCEL_URL}` 5 | : `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`; 6 | } 7 | return ( 8 | process.env.NEXTAUTH_URL || 9 | process.env.NEXT_PUBLIC_PUBLIC_URL || 10 | 'http://localhost:3000' 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/emails/theme.ts: -------------------------------------------------------------------------------- 1 | const theme = { 2 | fontFamily: { 3 | heading: 'Helvetica, sans-serif', 4 | bodyPrimary: 'Helvetica, sans-serif', 5 | bodySecondary: 'Arial, sans-serif', 6 | }, 7 | colors: { 8 | black: '#000000', 9 | darkGray: '#333333', 10 | gray: '#666666', 11 | lightGray: '#5c5b5b', 12 | primary: '#6D28D9', 13 | }, 14 | } as const; 15 | 16 | export default theme; 17 | -------------------------------------------------------------------------------- /src/components/layout/BadgeOnline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const BadgeOnline = () => ( 4 | 5 | 6 | 7 | 8 | ); 9 | 10 | export default BadgeOnline; 11 | -------------------------------------------------------------------------------- /src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loader from './Loader'; 3 | 4 | interface ILoading { 5 | isLoading: boolean; 6 | children: React.ReactNode; 7 | fullPage?: boolean; 8 | } 9 | const Loading = ({ isLoading, children, fullPage = false }: ILoading) => { 10 | if (isLoading) return ; 11 | return <>{children}; 12 | }; 13 | 14 | export default Loading; 15 | -------------------------------------------------------------------------------- /src/utils/actionOnList.ts: -------------------------------------------------------------------------------- 1 | export const reorderList = (list: any[], from: number, to: number) => { 2 | const newList = [...list]; 3 | const [item] = newList.splice(from, 1); 4 | newList.splice(to, 0, item); 5 | return newList; 6 | }; 7 | 8 | export const insertInList = (list: any[], item: any, position: number) => { 9 | const newList = [...list]; 10 | newList.splice(position, 0, item); 11 | return newList; 12 | }; 13 | -------------------------------------------------------------------------------- /src/theme/app.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #__next { 4 | -webkit-font-smoothing: antialiased; 5 | font-family: 'Inter', sans-serif; 6 | min-height: 100vh; 7 | height: 100%; 8 | background-color: #f1f5f9; 9 | } 10 | 11 | ::-moz-selection { 12 | color: white; 13 | background-color: black; 14 | } 15 | 16 | ::selection { 17 | color: white; 18 | background-color: black; 19 | } 20 | 21 | * { 22 | -moz-osx-font-smoothing: grayscale; 23 | } 24 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | const nextJest = require('next/jest'); 3 | 4 | const createJestConfig = nextJest({ 5 | dir: './', 6 | }); 7 | const customJestConfig: Config.InitialOptions = { 8 | verbose: true, 9 | transform: { 10 | '^.+\\.tsx?$': 'ts-jest', 11 | }, 12 | moduleDirectories: ['node_modules', 'src/'], 13 | testEnvironment: 'jest-environment-jsdom', 14 | }; 15 | module.exports = createJestConfig(customJestConfig); 16 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from '@/lib/sessions'; 2 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 3 | import { redirect } from 'next/navigation'; 4 | 5 | export default async function Layout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | const user = await getCurrentUser(); 11 | 12 | if (!user) { 13 | redirect(authOptions.pages!.signIn!); 14 | } 15 | 16 | return <>{children}; 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/useCustomToast.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-hot-toast'; 2 | 3 | export const toastOptions = {}; 4 | 5 | const useCustomToast = () => { 6 | return { 7 | errorToast: (message: string) => { 8 | toast.error(message); 9 | }, 10 | successToast: (message: string) => { 11 | toast.success(message); 12 | }, 13 | infoToast: (message: string) => { 14 | toast(message); 15 | }, 16 | }; 17 | }; 18 | 19 | export default useCustomToast; 20 | -------------------------------------------------------------------------------- /src/hooks/useTransitionRefresh.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/navigation'; 2 | import { useTransition } from 'react'; 3 | 4 | const useTransitionRefresh = () => { 5 | const [isRefreshing, startTransition] = useTransition(); 6 | const { refresh } = useRouter(); 7 | 8 | return { 9 | isRefreshing, 10 | refresh: () => { 11 | startTransition(() => { 12 | refresh(); 13 | }); 14 | }, 15 | }; 16 | }; 17 | 18 | export default useTransitionRefresh; 19 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/[teamSlug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from '@/lib/sessions'; 2 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 3 | import { redirect } from 'next/navigation'; 4 | 5 | export default async function Layout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | const user = await getCurrentUser(); 11 | 12 | if (!user) { 13 | redirect(authOptions.pages!.signIn!); 14 | } 15 | 16 | return children as JSX.Element; 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/router.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import * as Sentry from '@sentry/nextjs'; 3 | 4 | export type AuthApiRequest = NextApiRequest & { 5 | membershipId?: string; 6 | teamId?: string; 7 | user?: { id: string; email: string }; 8 | }; 9 | 10 | export const errorHandler = ( 11 | err: unknown, 12 | req: AuthApiRequest, 13 | res: NextApiResponse 14 | ) => { 15 | Sentry.captureException(err); 16 | res.status(400).json({ error: JSON.stringify(err) }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/teams/form/settings/TeamIntegrations.tsx: -------------------------------------------------------------------------------- 1 | import SlackPanel from '../SlackPanel'; 2 | import TypefullyPanel from '../TypefullyPanel'; 3 | import TeamAPIKey from '../TeamAPIKey'; 4 | import { Team } from '@prisma/client'; 5 | 6 | const TeamIntegrations = ({ team }: { team: Team }) => { 7 | return ( 8 |
9 | 10 | 11 | 12 |
13 | ); 14 | }; 15 | 16 | export default TeamIntegrations; 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://saas:saas@localhost:5432/saas?schema=public" 2 | EMAIL_FROM="Saas app " 3 | SMTP_HOST=localhost 4 | SMTP_PORT=25 5 | SMTP_USER= 6 | SMTP_PASSWORD= 7 | NODE_TLS_REJECT_UNAUTHORIZED="0" 8 | NEXTAUTH_URL="http://localhost:3000" 9 | NEXTAUTH_SECRET=RANDOMEKEY 10 | NEXT_PUBLIC_SLACK_CLIENT_ID= 11 | SLACK_CLIENT_SECRET= 12 | NEXT_PUBLIC_SENTRY_DSN=https://xxx@oxxx.ingest.sentry.io/xxx 13 | TYPEFULLY_API_URL="https://api.typefully.com/v1" 14 | JWT_SECRET="secret" 15 | OPENAI_API_KEY='my apiKey' -------------------------------------------------------------------------------- /changelogs/changelog-009.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shortcut to add a bookmark 3 | publishedAt: 2024-06-14 4 | slug: changelog-009 5 | image: changelog-009.webp 6 | --- 7 | 8 | ### New Feature 9 | 10 | We've introduced 2 new ways to add a bookmark in your team or in your digest. 11 | 12 | - '_New bookmark_' button located beside the search bookmark bar. 13 | - Shortcut to add a bookmark by pressing `Ctrl + B` on your keyboard. 14 | 15 | ### Bug Fix 16 | - Fix an issue where metadata was incorrectly previewed on LinkedIn. 17 | 18 | 19 | --- 20 | -------------------------------------------------------------------------------- /src/utils/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | 3 | export const openAiCompletion = async ({ 4 | prompt, 5 | model = 'gpt-4o', 6 | }: { 7 | prompt: string; 8 | model?: 'gpt-4o' | 'gpt-4-turbo'; 9 | }) => { 10 | const openai = new OpenAI({ 11 | apiKey: process.env.OPENAI_API_KEY!, 12 | }); 13 | 14 | const chatCompletion = await openai.chat.completions.create({ 15 | messages: [{ role: 'user', content: prompt }], 16 | model, 17 | temperature: 0.5, 18 | }); 19 | 20 | return chatCompletion.choices; 21 | }; 22 | -------------------------------------------------------------------------------- /sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | import db from '@/lib/db'; 7 | 8 | Sentry.init({ 9 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, 10 | tracesSampleRate: 1.0, 11 | enabled: process.env.NODE_ENV !== 'development', 12 | integrations: [new Sentry.Integrations.Prisma({ client: db })], 13 | }); 14 | -------------------------------------------------------------------------------- /src/theme/shadows.ts: -------------------------------------------------------------------------------- 1 | export const shadows = { 2 | 'xs': '0px 0px 2px #cecece', 3 | 'sm': '0px 0px 4px #a5a5a5', 4 | 'md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', 5 | 'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', 6 | 'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', 7 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', 8 | 'inner': 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', 9 | 10 | 'none': 'none', 11 | }; 12 | 13 | export default shadows; 14 | -------------------------------------------------------------------------------- /src/components/layout/SectionContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IProps { 4 | title?: string; 5 | children: React.ReactNode; 6 | className?: string; 7 | } 8 | 9 | const SectionContainer = ({ title, children, className = '' }: IProps) => ( 10 |
13 | {/* {title &&

{title}

} */} 14 |
{children}
15 |
16 | ); 17 | 18 | export default SectionContainer; 19 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/[teamSlug]/(feed)/rss.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { rss } from '@/utils/feed'; 3 | import { getPublicTeam } from '@/services/database/team'; 4 | 5 | export async function GET( 6 | _request: NextRequest, 7 | { params: { teamSlug } }: { params: { teamSlug: string } } 8 | ) { 9 | const team = await getPublicTeam(teamSlug); 10 | return new NextResponse(rss(team, teamSlug), { 11 | headers: { 12 | 'content-type': 'application/rss+xml; charset=utf-8', 13 | }, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/charts/ChartsSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ChartsSkeleton() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/[teamSlug]/(feed)/atom.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { atom } from '@/utils/feed'; 3 | import { getPublicTeam } from '@/services/database/team'; 4 | 5 | export async function GET( 6 | _request: NextRequest, 7 | { params: { teamSlug } }: { params: { teamSlug: string } } 8 | ) { 9 | const team = await getPublicTeam(teamSlug); 10 | return new NextResponse(atom(team, teamSlug), { 11 | headers: { 12 | 'content-type': 'application/atom+xml; charset=utf-8', 13 | }, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import LoginForm from '@/components/auth/LoginForm'; 2 | import { routes } from '@/core/constants'; 3 | import { getSession } from '@/lib/sessions'; 4 | import { redirect } from 'next/navigation'; 5 | 6 | export const dynamic = 'force-dynamic'; 7 | export const metadata = { 8 | title: 'Login', 9 | }; 10 | 11 | const Login = async () => { 12 | const session = await getSession(); 13 | 14 | if (session) { 15 | redirect(routes.TEAMS); 16 | } 17 | 18 | return ; 19 | }; 20 | 21 | export default Login; 22 | -------------------------------------------------------------------------------- /src/components/layout/BrandIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | const BrandIcon = (props: SVGProps) => ( 4 | 11 | 16 | 17 | ); 18 | 19 | export default BrandIcon; 20 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, PrismaClient } from '@prisma/client'; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | const db = globalThis.prisma || new PrismaClient(); 8 | if (process.env.NODE_ENV !== 'production') globalThis.prisma = db; 9 | 10 | export default db; 11 | 12 | export const isUniqueConstraintError = (e: unknown) => 13 | e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002'; 14 | 15 | export const isNotFoundError = (e: unknown) => 16 | e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025'; 17 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { formatDistance, format, parseISO } from 'date-fns'; 2 | 3 | export const getRelativeDate = (date: string | Date) => { 4 | const formatDate = typeof date === 'string' ? new Date(date) : date; 5 | 6 | return formatDistance(formatDate, new Date(), { 7 | addSuffix: true, 8 | }); 9 | }; 10 | 11 | export const formatDate = ( 12 | date: string | Date, 13 | dateFormat = 'MMMM dd, yyyy' 14 | ) => { 15 | if (date instanceof Date) { 16 | return format(date, dateFormat); 17 | } 18 | 19 | return format(parseISO(date), dateFormat); 20 | }; 21 | -------------------------------------------------------------------------------- /sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). 2 | // The config you add here will be used whenever one of the edge features is loaded. 3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 5 | 6 | import * as Sentry from '@sentry/nextjs'; 7 | 8 | Sentry.init({ 9 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, 10 | tracesSampleRate: 1.0, 11 | enabled: process.env.NODE_ENV !== 'development', 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/(app)/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Toaster } from 'react-hot-toast'; 4 | import { SessionProvider } from 'next-auth/react'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | 7 | const locale = 'en'; 8 | 9 | const queryClient = new QueryClient(); 10 | 11 | export default function Providers({ children }: { children: React.ReactNode }) { 12 | return ( 13 | 14 | 15 | {children} 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /prisma/migrations/20230530095640_added_subscription/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "subscriptions" ( 3 | "id" TEXT NOT NULL, 4 | "teamId" TEXT NOT NULL, 5 | "email" TEXT NOT NULL, 6 | "expirationTime" TIMESTAMP(3), 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | 10 | CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- AddForeignKey 14 | ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; 15 | -------------------------------------------------------------------------------- /src/components/CounterTag.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLProps } from 'react'; 2 | 3 | export const CounterTag = ({ 4 | count, 5 | className, 6 | ...props 7 | }: HTMLProps & { count: number }) => { 8 | return ( 9 | <> 10 | {count > 0 && ( 11 | 17 | {count} 18 | 19 | )} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /prisma/migrations/20230609121534_update_digest_block_relationships/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "digest_blocks" RENAME CONSTRAINT "bookmark_digest_pkey" TO "digest_blocks_pkey"; 3 | 4 | -- RenameForeignKey 5 | ALTER TABLE "digest_blocks" RENAME CONSTRAINT "bookmark_digest_bookmarkId_fkey" TO "digest_blocks_bookmarkId_fkey"; 6 | 7 | -- RenameForeignKey 8 | ALTER TABLE "digest_blocks" RENAME CONSTRAINT "bookmark_digest_digestId_fkey" TO "digest_blocks_digestId_fkey"; 9 | 10 | -- RenameIndex 11 | ALTER INDEX "bookmark_digest_bookmarkId_digestId_key" RENAME TO "digest_blocks_bookmarkId_digestId_key"; 12 | -------------------------------------------------------------------------------- /src/contexts/TeamContext.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Team } from '@prisma/client'; 4 | import React, { createContext, ReactNode, useContext } from 'react'; 5 | 6 | type TeamProviderProps = { 7 | children: ReactNode; 8 | team: Team; 9 | }; 10 | 11 | const TeamContext = createContext({} as Team); 12 | 13 | export const TeamProvider = ({ children, team }: TeamProviderProps) => { 14 | return {children}; 15 | }; 16 | 17 | export const useTeam = () => { 18 | const teamContext = useContext(TeamContext); 19 | 20 | return teamContext; 21 | }; 22 | -------------------------------------------------------------------------------- /src/services/database/user.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import db from '@/lib/db'; 4 | 5 | export const getUserById = (userId: string) => 6 | db.user.findUnique({ 7 | where: { 8 | id: userId, 9 | }, 10 | }); 11 | 12 | export const checkUserTeamBySlug = (slug: string, userId: string) => 13 | db.team.findFirst({ 14 | where: { 15 | slug, 16 | memberships: { some: { user: { id: userId } } }, 17 | }, 18 | include: { 19 | memberships: { 20 | where: { NOT: { user: null } }, 21 | include: { user: { select: { email: true } } }, 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres:14 6 | restart: always 7 | environment: 8 | POSTGRES_USER: saas 9 | POSTGRES_PASSWORD: saas 10 | POSTGRES_DB: saas 11 | ports: 12 | - 5432:5432 13 | volumes: 14 | - postgresql:/var/lib/postgresql 15 | - postgresql_data:/var/lib/postgresql/data 16 | 17 | maildev: 18 | image: djfarrelly/maildev 19 | ports: 20 | - '1080:80' 21 | - '25:25' 22 | volumes: 23 | postgresql: 24 | postgresql_data: 25 | -------------------------------------------------------------------------------- /contentlayer.config.ts: -------------------------------------------------------------------------------- 1 | // contentlayer.config.ts 2 | import { defineDocumentType, makeSource } from '@contentlayer/source-files'; 3 | 4 | export const Changelog = defineDocumentType(() => ({ 5 | name: 'Changelog', 6 | filePathPattern: `**/*.md`, 7 | fields: { 8 | title: { type: 'string', required: true }, 9 | publishedAt: { type: 'date', required: true }, 10 | image: { 11 | type: 'string', 12 | required: false, 13 | }, 14 | slug: { type: 'string', required: true }, 15 | }, 16 | })); 17 | 18 | export default makeSource({ 19 | contentDirPath: 'changelogs', 20 | documentTypes: [Changelog], 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/slack.ts: -------------------------------------------------------------------------------- 1 | export type TBlock = { 2 | type: 'rich_text'; 3 | block_id: string; 4 | elements: { type: string; elements: { type: 'link'; url: string }[] }[]; 5 | }; 6 | 7 | export const extractLinksFromBlocks = (blocks: TBlock[]) => { 8 | const links: string[] = []; 9 | 10 | blocks 11 | ?.filter((block) => block.type === 'rich_text') 12 | .forEach((block) => { 13 | block.elements.forEach((element) => { 14 | element.elements.forEach((element) => { 15 | if (element.type === 'link') { 16 | links.push(element.url); 17 | } 18 | }); 19 | }); 20 | }); 21 | 22 | return links; 23 | }; 24 | -------------------------------------------------------------------------------- /prisma/migrations/20230316154959_add_delete_cascade_bookmark/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "bookmark_digest" DROP CONSTRAINT "bookmark_digest_bookmarkId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "bookmark_digest" DROP CONSTRAINT "bookmark_digest_digestId_fkey"; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "bookmark_digest" ADD CONSTRAINT "bookmark_digest_bookmarkId_fkey" FOREIGN KEY ("bookmarkId") REFERENCES "bookmarks"("id") ON DELETE CASCADE ON UPDATE CASCADE; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE "bookmark_digest" ADD CONSTRAINT "bookmark_digest_digestId_fkey" FOREIGN KEY ("digestId") REFERENCES "digests"("id") ON DELETE CASCADE ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /src/lib/sessions.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { Session } from 'next-auth'; 3 | import { getServerSession } from 'next-auth/next'; 4 | import { redirect } from 'next/navigation'; 5 | 6 | export async function getSession() { 7 | return await getServerSession(authOptions); 8 | } 9 | 10 | export async function getCurrentUser() { 11 | const session = await getSession(); 12 | 13 | return session?.user; 14 | } 15 | 16 | export async function getCurrentUserOrRedirect(): Promise { 17 | const user = await getCurrentUser(); 18 | if (!user) { 19 | redirect(authOptions.pages!.signIn!); 20 | } 21 | return user; 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/code-check.yml: -------------------------------------------------------------------------------- 1 | name: Check code 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check-migrations: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | 16 | with: 17 | fetch-depth: 0 18 | ref: ${{ github.event.pull_request.head.sha }} 19 | 20 | - name: Check Prisma Migrations 21 | uses: premieroctet/prisma-drop-migration-warning@main 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | with: 25 | main-branch: 'main' 26 | path: 'prisma' 27 | warning: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .mailing 39 | 40 | # Sentry 41 | .sentryclirc 42 | 43 | # Sentry 44 | next.config.original.js 45 | 46 | # Sentry Auth Token 47 | .sentryclirc 48 | 49 | #Content Layer 50 | .contentlayer 51 | -------------------------------------------------------------------------------- /src/components/layout/HeaderSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { routes } from '@/core/constants'; 2 | import Link from 'next/link'; 3 | import Logo from './Logo'; 4 | import NavList from './NavList'; 5 | 6 | export default function HeaderSkeleton() { 7 | return ( 8 |
9 |
10 |
11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/teams/TeamAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { Team } from '@prisma/client'; 2 | import clsx from 'clsx'; 3 | import { HTMLProps } from 'react'; 4 | 5 | const TeamAvatar = ({ 6 | team, 7 | className, 8 | }: { 9 | team: Partial; 10 | className?: HTMLProps['className']; 11 | }) => { 12 | const { name } = team; 13 | return ( 14 |
23 | {name ? name[0].toUpperCase() : 'Team'} 24 |
25 | ); 26 | }; 27 | 28 | export default TeamAvatar; 29 | -------------------------------------------------------------------------------- /src/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/nextjs'; 2 | import { NextPageContext } from 'next'; 3 | import NextErrorComponent from 'next/error'; 4 | 5 | const CustomErrorComponent = (props: any) => { 6 | return ; 7 | }; 8 | 9 | CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => { 10 | // In case this is running in a serverless function, await this in order to give Sentry 11 | // time to send the error before the lambda exits 12 | await Sentry.captureUnderscoreErrorException(contextData); 13 | 14 | // This will contain the status code of the response 15 | return NextErrorComponent.getInitialProps(contextData); 16 | }; 17 | 18 | export default CustomErrorComponent; 19 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/create/page.tsx: -------------------------------------------------------------------------------- 1 | import CreateTeam from '@/components/teams/form/CreateTeam'; 2 | export const dynamic = 'force-dynamic'; 3 | 4 | const CreatePage = () => { 5 | return ( 6 |
7 |
8 |
9 |

10 | Create New Team 11 |

12 |
13 |
14 | 15 |
16 |
17 |
18 | ); 19 | }; 20 | 21 | export default CreatePage; 22 | -------------------------------------------------------------------------------- /src/components/layout/PageContainer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React, { HTMLProps, PropsWithChildren } from 'react'; 3 | 4 | interface IProps { 5 | title?: string; 6 | breadCrumb?: React.ReactNode; 7 | } 8 | 9 | const PageContainer = ({ 10 | title, 11 | breadCrumb, 12 | children, 13 | className, 14 | ...props 15 | }: PropsWithChildren & IProps & HTMLProps) => ( 16 |
17 | {breadCrumb && breadCrumb} 18 |
19 |

{title}

20 |
{children}
21 |
22 |
23 | ); 24 | 25 | export default PageContainer; 26 | -------------------------------------------------------------------------------- /src/utils/page.ts: -------------------------------------------------------------------------------- 1 | import { TEAM_SETTINGS_ITEMS, TeamSettingsItemsId } from '@/core/constants'; 2 | 3 | export function getTeamSettingsPageInfo( 4 | id: TeamSettingsItemsId, 5 | teamSlug: string 6 | ) { 7 | const pageInfo = TEAM_SETTINGS_ITEMS.find((item) => item.id === id); 8 | if (!pageInfo) { 9 | throw new Error( 10 | `Page with id ${id} not implemented (see core/constants.tsx)` 11 | ); 12 | } 13 | const { title, subtitle, routePath } = pageInfo; 14 | const menuItems = TEAM_SETTINGS_ITEMS.map((item) => ({ 15 | ...item, 16 | href: item.routePath.replace(':slug', teamSlug), 17 | isActive: item.id === 'templates', 18 | })); 19 | return { 20 | title, 21 | subtitle, 22 | routePath: routePath.replace(':slug', teamSlug), 23 | menuItems, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineLoading3Quarters as LoadingIcon } from '@react-icons/all-files/ai/AiOutlineLoading3Quarters'; 2 | 3 | interface ILoader { 4 | text?: string; 5 | fullPage?: boolean; 6 | } 7 | const Loader = ({ text, fullPage }: ILoader) => { 8 | return ( 9 |
14 |
15 | {text &&

{text}

} 16 |
21 |
22 | ); 23 | }; 24 | 25 | export default Loader; 26 | -------------------------------------------------------------------------------- /src/services/database/membership.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import db from '@/lib/db'; 4 | 5 | export const getTeamMembershipById = (teamId: string, userId: string) => 6 | db.membership.findFirst({ 7 | select: { id: true, teamId: true }, 8 | where: { 9 | userId, 10 | teamId, 11 | }, 12 | }); 13 | 14 | export const getTeamMembers = (slug: string) => 15 | db.membership.findMany({ 16 | where: { 17 | team: { 18 | slug, 19 | }, 20 | user: { NOT: { id: undefined } }, 21 | }, 22 | include: { 23 | user: { 24 | select: { 25 | email: true, 26 | id: true, 27 | name: true, 28 | image: true, 29 | }, 30 | }, 31 | }, 32 | }); 33 | 34 | export type Member = Awaited>[number]; 35 | -------------------------------------------------------------------------------- /prisma/migrations/20230315222747_add_team/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `teamId` to the `bookmarks` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "bookmarks" DROP CONSTRAINT "bookmarks_membershipId_fkey"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "bookmarks" ADD COLUMN "teamId" INTEGER NOT NULL, 12 | ALTER COLUMN "membershipId" DROP NOT NULL; 13 | 14 | -- AddForeignKey 15 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; 16 | 17 | -- AddForeignKey 18 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 19 | -------------------------------------------------------------------------------- /src/components/layout/NoContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | 3 | interface IProps { 4 | title: string; 5 | icon?: ReactNode; 6 | subtitle?: string; 7 | } 8 | const NoContent = ({ icon, title, subtitle }: IProps) => ( 9 |
10 |
11 | {icon && ( 12 |
13 | {icon} 14 |
15 | )} 16 |

{title}

17 | {subtitle && ( 18 |

{subtitle}

19 | )} 20 |
21 |
22 | ); 23 | 24 | export default NoContent; 25 | -------------------------------------------------------------------------------- /src/pages/api/tags/index.ts: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { AuthApiRequest } from '@/lib/router'; 3 | import { NextApiResponse } from 'next'; 4 | import { createRouter } from 'next-connect'; 5 | 6 | export const router = createRouter(); 7 | 8 | router.get(async (req, res) => { 9 | try { 10 | /** Get all available tags */ 11 | const tags = await db.tag.findMany({ 12 | select: { 13 | id: true, 14 | name: true, 15 | slug: true, 16 | description: true, 17 | }, 18 | }); 19 | 20 | return res.status(200).json({ tags }); 21 | } catch (error) { 22 | // eslint-disable-next-line no-console 23 | console.log(error); 24 | return res.status(500).json({ error: 'Something went wrong' }); 25 | } 26 | }); 27 | 28 | export default router.handler(); 29 | -------------------------------------------------------------------------------- /src/app/(admin)/api/admin/[[...nextadmin]]/route.ts: -------------------------------------------------------------------------------- 1 | import client from '@/lib/db'; 2 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 3 | import { options } from '@/utils/nextadmin'; 4 | import { createHandler } from '@premieroctet/next-admin/appHandler'; 5 | import { getServerSession } from 'next-auth'; 6 | import { NextRequest, NextResponse } from 'next/server'; 7 | 8 | const { run } = createHandler({ 9 | apiBasePath: '/api/admin', 10 | prisma: client, 11 | options, 12 | onRequest: async (req: NextRequest) => { 13 | const session = await getServerSession(authOptions); 14 | const isAdmin = session?.user?.role === 'SUPERADMIN'; 15 | 16 | if (!isAdmin) { 17 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 18 | } 19 | }, 20 | }); 21 | 22 | export { run as DELETE, run as GET, run as POST }; 23 | -------------------------------------------------------------------------------- /src/components/layout/Logo.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import BrandIcon from './BrandIcon'; 4 | 5 | const Logo = (props: { className?: string; isWhite?: boolean }) => { 6 | return ( 7 | 8 | 17 | 18 | 24 | digest.club 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Logo; 31 | -------------------------------------------------------------------------------- /src/components/account/UserInvitations.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { UserInvitationsResults } from '@/services/database/invitation'; 4 | import SectionContainer from '../layout/SectionContainer'; 5 | import UserInvitationItem from './UserInvitationItem'; 6 | 7 | const UserInvitations = ({ 8 | invitations, 9 | }: { 10 | invitations: UserInvitationsResults; 11 | }) => { 12 | return ( 13 |
14 | {invitations?.length ? ( 15 |

Find all pending invitations below

16 | ) : ( 17 |

You have no pending invitations

18 | )} 19 |
20 | {invitations?.map((invitation) => ( 21 | 22 | ))} 23 |
24 |
25 | ); 26 | }; 27 | 28 | export default UserInvitations; 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'run tests' 2 | on: 3 | push: 4 | branches: 5 | - web-v2 6 | - main 7 | pull_request: 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | - name: 'Create env file' 19 | run: | 20 | touch .env 21 | echo NEXTAUTH_SECRET="RANDOMEKEY" >> .env 22 | echo JWT_SECRET="RANDOMEKEY" >> .env 23 | echo OPENAI_API_KEY="RANDOMEKEY" >> .env 24 | echo SKIP_SITEMAP_GENERATION="true" >> .env 25 | cat .env 26 | - name: Install dependencies 27 | run: yarn install 28 | - name: Run tests 29 | run: yarn test:build 30 | - name: Run build 31 | run: yarn build 32 | -------------------------------------------------------------------------------- /src/emails/index.ts: -------------------------------------------------------------------------------- 1 | import { render } from 'mjml-react'; 2 | import nodemailer from 'nodemailer'; 3 | import { ReactElement } from 'react'; 4 | 5 | export const EMAIL_SUBJECTS = { 6 | LOGIN: 'Sign in to your account', 7 | INVITATION: 'You have been invited to join a team', 8 | }; 9 | 10 | export const sendEmail = async ({ 11 | to, 12 | subject, 13 | component, 14 | }: { 15 | to: string; 16 | subject: string; 17 | component: ReactElement; 18 | }) => { 19 | const transporter = nodemailer.createTransport({ 20 | host: process.env.SMTP_HOST, 21 | port: +process.env.SMTP_PORT!, 22 | auth: { 23 | user: process.env.SMTP_USER!, 24 | pass: process.env.SMTP_PASSWORD!, 25 | }, 26 | }); 27 | 28 | const { html } = render(component); 29 | 30 | await transporter.sendMail({ 31 | from: process.env.EMAIL_FROM, 32 | to, 33 | subject, 34 | html, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/[teamSlug]/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from '@/lib/sessions'; 2 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 3 | import { notFound, redirect } from 'next/navigation'; 4 | import { TeamPageProps } from '../page'; 5 | import { routes } from '@/core/constants'; 6 | import { checkUserTeamBySlug } from '@/services/database/user'; 7 | 8 | const TeamSettingsPage = async ({ params }: TeamPageProps) => { 9 | const teamSlug = params.teamSlug; 10 | const user = await getCurrentUser(); 11 | if (!user) { 12 | return redirect(authOptions.pages!.signIn!); 13 | } 14 | 15 | const team = await checkUserTeamBySlug(teamSlug, user.id); 16 | 17 | if (!team) { 18 | redirect('/teams'); 19 | } 20 | 21 | if (!user?.id) return notFound(); 22 | 23 | redirect(routes.TEAM_EDIT_PROFILE.replace(':slug', teamSlug)); 24 | }; 25 | 26 | export default TeamSettingsPage; 27 | -------------------------------------------------------------------------------- /src/components/digests/block-card/BlockCard.tsx: -------------------------------------------------------------------------------- 1 | import { DigestBlockType } from '@prisma/client'; 2 | import React from 'react'; 3 | import { Props as PublicDigestListProps } from '../PublicDigestList'; 4 | import TextCard from './text-card/TextCard'; 5 | import BookmarkCard from './bookmark-card/BookmarkCard'; 6 | 7 | export interface Props { 8 | block: PublicDigestListProps['digest']['digestBlocks'][number]; 9 | isEditable?: boolean; 10 | index?: number; 11 | } 12 | 13 | export default function BlockCard({ block, isEditable = false, index }: Props) { 14 | if (block.type === DigestBlockType.TEXT) { 15 | return ; 16 | } else if (block.type === DigestBlockType.BOOKMARK) { 17 | return ; 18 | } 19 | throw new Error('BookmarkCard: bookmarkDigest has neither bookmark nor text'); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/home/Section.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ReactNode } from 'react'; 3 | import SectionTitle from '../heading/SectionTitle'; 4 | 5 | const Section = ({ 6 | children, 7 | title, 8 | caption, 9 | className, 10 | }: { 11 | children: ReactNode; 12 | title: ReactNode; 13 | caption?: ReactNode; 14 | className?: string; 15 | }) => { 16 | return ( 17 |
23 |
24 | {title} 25 | {Boolean(caption) && ( 26 |

27 | {caption} 28 |

29 | )} 30 |
31 | {children} 32 |
33 | ); 34 | }; 35 | 36 | export default Section; 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"], 20 | "contentlayer/generated": ["./.contentlayer/generated"] 21 | }, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": [ 29 | "next-env.d.ts", 30 | "types/**/*.ts", 31 | "**/*.ts", 32 | "**/*.tsx", 33 | ".next/types/**/*.ts", 34 | "**/*.test.ts" 35 | ], 36 | "exclude": ["node_modules"] 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions that checks if a string is empty, i.e. has no characters 3 | * @param {string} str - string to check 4 | * @returns {boolean} true if string is empty 5 | */ 6 | export function isStringEmpty(str: string): boolean { 7 | const trimmedStr = str.trim(); 8 | return trimmedStr.length === 0; 9 | } 10 | 11 | /** 12 | * Function that returns a hidden string, i.e. a string with all characters replaced by '*' 13 | */ 14 | export function makeHidden(str: string): string { 15 | return str.replace(/./g, '*'); 16 | } 17 | 18 | /** 19 | * Function that copies a string to the clipboard 20 | * @param {string} str - string to copy 21 | */ 22 | export function copyToClipboard(str: string): void { 23 | const textField = document.createElement('textarea'); 24 | textField.innerText = str; 25 | document.body.appendChild(textField); 26 | textField.select(); 27 | document.execCommand('copy'); 28 | textField.remove(); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/teams/settings-tabs/invitations/InvitationItem.tsx: -------------------------------------------------------------------------------- 1 | import { TeamInvitation } from '@/services/database/invitation'; 2 | import ListItem from '../TabsListItem'; 3 | 4 | type Props = { 5 | invitation: TeamInvitation; 6 | deleteInvitation: (invitation: TeamInvitation) => void; 7 | isLoading?: boolean; 8 | }; 9 | 10 | const InvitationItem = ({ invitation, deleteInvitation, isLoading }: Props) => { 11 | const membership = invitation.membership; 12 | const name = 13 | membership?.user?.name || 14 | membership.invitedName || 15 | membership.invitedEmail || 16 | 'Anonymous'; 17 | 18 | return ( 19 | { 28 | deleteInvitation(invitation); 29 | }} 30 | /> 31 | ); 32 | }; 33 | 34 | export default InvitationItem; 35 | -------------------------------------------------------------------------------- /changelogs/changelog-008.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: ﹟HelloBookmarkTags ! 3 | publishedAt: 2023-12-08 4 | slug: changelog-008 5 | image: changelog-008.webp 6 | --- 7 | 8 | ### New Features 9 | 10 | #### Bookmarks tags 11 | 12 | We've introduced a helpful new feature to improve discoverability and ease your technology tracking: **bookmark tags**. 13 | 14 | Now, when editing a bookmark, you can assign up to two tags from a provided list. Using AI, future bookmarks will automatically receive relevant tags, saving your time. 15 | 16 | On the Discover page, you'll spot trending tags. Click any tag to explore related bookmarks, a great way to stay updated on specific topics. Go to the [discover page](https://digest.club/discover) to try it out! 17 | 18 | To search for a specific tag, head to the dedicated tags page for your topic.. For instance, if you want to see all the bookmarks with the tag **javascript**, go to [https://digest.club/tags/javascript](https://digest.club/tags/javascript). 19 | 20 | --- 21 | -------------------------------------------------------------------------------- /prisma/migrations/20230307140817_add_digest_model_squashed_migrations/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "bookmarks" ADD COLUMN "digestId" TEXT; 3 | 4 | -- CreateTable 5 | CREATE TABLE "digests" ( 6 | "id" TEXT NOT NULL, 7 | "title" TEXT NOT NULL, 8 | "description" TEXT, 9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(3) NOT NULL, 11 | "publishedAt" TIMESTAMP(3), 12 | "teamId" INTEGER NOT NULL, 13 | 14 | CONSTRAINT "digests_pkey" PRIMARY KEY ("id") 15 | ); 16 | 17 | -- CreateIndex 18 | CREATE UNIQUE INDEX "digests_teamId_title_key" ON "digests"("teamId", "title"); 19 | 20 | -- AddForeignKey 21 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_digestId_fkey" FOREIGN KEY ("digestId") REFERENCES "digests"("id") ON DELETE SET NULL ON UPDATE CASCADE; 22 | 23 | -- AddForeignKey 24 | ALTER TABLE "digests" ADD CONSTRAINT "digests_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 25 | -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a hex color code to its RGBA equivalent with a given opacity. 3 | * @param {string} hex - The hex color code (e.g., '#ffffff') to be converted. 4 | * @param {number} opacity - The opacity percentage (0 to 100) for the RGBA color. 5 | * @returns {string} The RGBA color value as a string (e.g., 'rgba(255, 255, 255, 0.5)'). 6 | */ 7 | export function hexToRGBA(hex: string, opacity: number) { 8 | // Remove '#' from the hex color if present 9 | hex = hex.replace('#', ''); 10 | 11 | // Convert hex to RGB 12 | let r = parseInt(hex.substring(0, 2), 16); 13 | let g = parseInt(hex.substring(2, 4), 16); 14 | let b = parseInt(hex.substring(4, 6), 16); 15 | 16 | // Ensure opacity is within the valid range (0 to 100) 17 | opacity = Math.max(0, Math.min(100, opacity)); 18 | 19 | // Convert opacity percentage to a value between 0 and 1 20 | const alpha = opacity / 100; 21 | 22 | // Return the RGBA value 23 | return `rgba(${r}, ${g}, ${b}, ${alpha})`; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/home/HomeDigests.tsx: -------------------------------------------------------------------------------- 1 | import { getDiscoverDigests } from '@/services/database/digest'; 2 | import PublicDigestCard from '../teams/PublicDigestCard'; 3 | import Section from './Section'; 4 | 5 | const HomeDigests = async () => { 6 | const { digests } = await getDiscoverDigests({ 7 | page: 1, 8 | perPage: 3, 9 | }); 10 | 11 | return ( 12 |
17 |
18 |
19 | {digests.map((digest) => ( 20 | 25 | ))} 26 |
27 |
28 |
29 | ); 30 | }; 31 | 32 | export default HomeDigests; 33 | -------------------------------------------------------------------------------- /src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { HTMLProps, PropsWithChildren, ReactNode } from 'react'; 3 | 4 | export const Card = ({ 5 | children, 6 | header, 7 | footer, 8 | className, 9 | contentClassName, 10 | ...props 11 | }: PropsWithChildren & { 12 | header?: ReactNode; 13 | footer?: ReactNode; 14 | contentClassName?: string; 15 | } & HTMLProps) => { 16 | return ( 17 |
24 | {header &&
{header}
} 25 |
31 | {children} 32 |
33 | {footer &&
{footer}
} 34 |
35 | ); 36 | }; 37 | 38 | export default Card; 39 | -------------------------------------------------------------------------------- /changelogs/changelog-006.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 🌟 Weekly improvement 3 | publishedAt: 2023-09-19 4 | slug: changelog-006 5 | image: changelog-006.webp 6 | --- 7 | 8 | ### Enhancements 9 | 10 | - **Trending Bookmarks**: Links bookmarked multiple times in your team are now grouped. For easier access a dedicated badge now displays the number of team members which have saved the link. No more link duplication ! Get to the point and be sure not to publish a link twice anymore. 11 | 12 | - **Add Text Block**: Get faster access to Text Blocks from digest edition with a new quick add button displayed between blocks on hovering. 13 | 14 | - **Text Block Edition**: A typo in a text block ? No need to delete it anymore simply edit it with the new option button available. 15 | 16 | ### Bug Fixes 17 | 18 | - **Invitation Fix**: We've resolved the bug that was previously blocking the invitation links. 19 | 20 | - **Prevent link duplication**: Users can no longer bookmark the same link multiple times in the same Team. No more team spamming ! 21 | -------------------------------------------------------------------------------- /sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the client. 2 | // The config you add here will be used whenever a users loads a page in their browser. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | Sentry.init({ 8 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, 9 | tracesSampleRate: 1.0, 10 | enabled: process.env.NODE_ENV !== 'development', 11 | environment: process.env.VERCEL_ENV, 12 | debug: false, 13 | 14 | replaysOnErrorSampleRate: 1.0, 15 | 16 | // This sets the sample rate to be 10%. You may want this to be 100% while 17 | // in development and sample at a lower rate in production 18 | replaysSessionSampleRate: 0.1, 19 | 20 | // You can remove this option if you're not planning to use the Sentry Session Replay feature: 21 | integrations: [ 22 | new Sentry.Replay({ 23 | // Additional Replay configuration goes in here, for example: 24 | maskAllText: true, 25 | blockAllMedia: true, 26 | }), 27 | ], 28 | }); 29 | -------------------------------------------------------------------------------- /src/pages/api/user/default-team.ts: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { AuthApiRequest } from '@/lib/router'; 3 | import { NextApiResponse } from 'next'; 4 | import { createRouter } from 'next-connect'; 5 | 6 | export const router = createRouter(); 7 | 8 | router.get(async (req, res) => { 9 | const sessionToken = req.query.sessionToken as string; 10 | const teamSlug = req.query.teamSlug as string; 11 | if (!teamSlug || !sessionToken) return res.status(403).end(); 12 | 13 | const userSession = await db.session.findUnique({ 14 | select: { 15 | user: { select: { memberships: { select: { team: true } } } }, 16 | }, 17 | where: { 18 | sessionToken, 19 | }, 20 | }); 21 | const user = userSession?.user; 22 | const team = user?.memberships.find( 23 | (membership) => membership.team.slug === teamSlug 24 | )?.team; 25 | 26 | if (!team) return res.status(403).end(); 27 | 28 | return res.status(200).json({ defaultTeamSlug: team.slug }); 29 | }); 30 | 31 | export default router.handler(); 32 | -------------------------------------------------------------------------------- /src/components/teams/form/settings/TeamTemplates.tsx: -------------------------------------------------------------------------------- 1 | import TemplateItem from '@/components/digests/templates/TemplateItem'; 2 | import { Team } from '@prisma/client'; 3 | import NoContent from '@/components/layout/NoContent'; 4 | import { ViewColumnsIcon } from '@heroicons/react/24/outline'; 5 | import { TeamDigestsResult } from '@/services/database/digest'; 6 | 7 | const TeamTemplates = ({ 8 | team, 9 | templates, 10 | }: { 11 | team: Team; 12 | templates: TeamDigestsResult[]; 13 | }) => { 14 | return ( 15 |
16 | {templates?.map((template) => ( 17 | 18 | ))} 19 | {!templates?.length && ( 20 | } 22 | title="No templates" 23 | subtitle="Your team does not have templates yet, create one from one of your digest edition page" 24 | /> 25 | )} 26 |
27 | ); 28 | }; 29 | 30 | export default TeamTemplates; 31 | -------------------------------------------------------------------------------- /src/actions/update-team-info.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import db from '@/lib/db'; 3 | import * as Sentry from '@sentry/nextjs'; 4 | import { checkAuthAction, checkTeamAction, getErrorMessage } from './utils'; 5 | import { Team } from '@prisma/client'; 6 | 7 | interface UpdateTeamInfoResult { 8 | error?: { 9 | message: string; 10 | }; 11 | data?: { 12 | team: string; 13 | }; 14 | } 15 | 16 | export default async function updateTeamInfo( 17 | updatedTeamInfo: Partial, 18 | teamId: string 19 | ): Promise { 20 | try { 21 | await checkAuthAction(); 22 | await checkTeamAction(teamId); 23 | 24 | const updatedTeam = await db.team.update({ 25 | where: { id: teamId }, 26 | data: updatedTeamInfo, 27 | }); 28 | 29 | return { 30 | data: { 31 | team: JSON.stringify(updatedTeam), 32 | }, 33 | }; 34 | } catch (err: any) { 35 | Sentry.captureException(err); 36 | return { 37 | error: { 38 | message: getErrorMessage(err.message), 39 | }, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/actions/update-user.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import db from '@/lib/db'; 3 | import * as Sentry from '@sentry/nextjs'; 4 | import { revalidatePath } from 'next/cache'; 5 | import { checkAuthAction, getErrorMessage } from './utils'; 6 | 7 | interface UpdateUserResult { 8 | error?: { 9 | message: string; 10 | }; 11 | data?: { 12 | user: string; 13 | }; 14 | } 15 | 16 | export default async function updateUser( 17 | formData: FormData 18 | ): Promise { 19 | try { 20 | const user = await checkAuthAction(); 21 | const updatedUser = await db.user.update({ 22 | where: { 23 | id: user?.id, 24 | }, 25 | data: { name: formData?.get('name')?.toString() ?? '' }, 26 | }); 27 | 28 | revalidatePath('/account'); 29 | return { 30 | data: { 31 | user: JSON.stringify(updatedUser), 32 | }, 33 | }; 34 | } catch (err: any) { 35 | Sentry.captureException(err); 36 | return { 37 | error: { 38 | message: getErrorMessage(err.message), 39 | }, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/bookmark/[bookmarkId]/index.ts: -------------------------------------------------------------------------------- 1 | import client from '@/lib/db'; 2 | import { checkTeam } from '@/lib/middleware'; 3 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 4 | import { Bookmark } from '@prisma/client'; 5 | import { NextApiResponse } from 'next'; 6 | import { createRouter } from 'next-connect'; 7 | 8 | export type ApiBookmarkResponseSuccess = Bookmark; 9 | 10 | export const router = createRouter(); 11 | 12 | router.use(checkTeam).delete(async (req, res) => { 13 | const bookmarkId = req.query.bookmarkId as string; 14 | const teamId = req.query.teamId as string; 15 | 16 | const bookmark = await client.bookmark.findFirstOrThrow({ 17 | where: { 18 | id: bookmarkId, 19 | teamId, 20 | }, 21 | }); 22 | 23 | const deletedBookmark = await client.bookmark.delete({ 24 | where: { 25 | id: bookmark.id, 26 | }, 27 | }); 28 | 29 | return res.status(201).json(deletedBookmark); 30 | }); 31 | 32 | export default router.handler({ 33 | onError: errorHandler, 34 | }); 35 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, ReactElement } from 'react'; 2 | import { 3 | Arrow, 4 | Content, 5 | Portal, 6 | Provider, 7 | Root, 8 | TooltipProps, 9 | Trigger, 10 | } from '@radix-ui/react-tooltip'; 11 | 12 | interface IProps { 13 | trigger: ReactElement; 14 | side?: 'bottom' | 'top' | 'right' | 'left'; 15 | asChild?: boolean; 16 | } 17 | 18 | export const Tooltip = ({ 19 | trigger, 20 | children, 21 | side, 22 | asChild = false, 23 | ...tooltipProps 24 | }: IProps & PropsWithChildren & TooltipProps) => { 25 | return ( 26 | 27 | 28 | {trigger} 29 | 30 | 35 | {children} 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/teams/settings-tabs/subscribers/List.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Subscription } from '@prisma/client'; 4 | import ListItem from '../TabsListItem'; 5 | import NoContent from '@/components/layout/NoContent'; 6 | import { EnvelopeIcon } from '@heroicons/react/24/solid'; 7 | 8 | type Props = { 9 | subscriptions: Subscription[]; 10 | }; 11 | 12 | const SubscribersList = ({ subscriptions }: Props) => { 13 | return ( 14 |
15 | {subscriptions.length === 0 ? ( 16 | } 18 | title="No subscribers" 19 | subtitle="Your team's newsletter has no subscribers yet." 20 | /> 21 | ) : ( 22 | <> 23 | {subscriptions.map((subscription) => ( 24 | 29 | ))} 30 | 31 | )} 32 |
33 | ); 34 | }; 35 | 36 | export default SubscribersList; 37 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/invitations/[invitationId]/delete.tsx: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { checkAuth } from '@/lib/middleware'; 3 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 4 | import { NextApiResponse } from 'next'; 5 | import { createRouter } from 'next-connect'; 6 | 7 | export const router = createRouter(); 8 | 9 | router.use(checkAuth).delete(async (req, res) => { 10 | const invitationId = req.query.invitationId as string; 11 | const teamId = req.query.teamId as string; 12 | 13 | const userMembership = await db.membership.findFirst({ 14 | where: { 15 | userId: req.user!.id, 16 | teamId: teamId, 17 | }, 18 | }); 19 | 20 | // Delete invitation 21 | const invitation = await db.invitation.delete({ 22 | where: { 23 | id: invitationId, 24 | }, 25 | }); 26 | 27 | await db.membership.delete({ 28 | where: { 29 | id: invitation.membershipId, 30 | }, 31 | }); 32 | 33 | return res.status(200).json(invitation); 34 | }); 35 | 36 | export default router.handler({ 37 | onError: errorHandler, 38 | }); 39 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/unsubscribe/page.tsx: -------------------------------------------------------------------------------- 1 | import UnsubscribeConfirmation from '@/components/newsletter/UnsubscribeConfirmation'; 2 | import db from '@/lib/db'; 3 | import { notFound } from 'next/navigation'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function Page({ 8 | searchParams, 9 | }: { 10 | searchParams: { [key: string]: string | undefined }; 11 | }) { 12 | if (!searchParams.email || !searchParams.teamId) notFound(); 13 | const { email, teamId } = searchParams; 14 | 15 | const team = await db.team.findUnique({ 16 | where: { 17 | id: teamId, 18 | }, 19 | select: { 20 | name: true, 21 | }, 22 | }); 23 | if (!team) notFound(); 24 | 25 | return ( 26 |
27 |
28 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/api/teams/connect/typefully.ts: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { checkTeam } from '@/lib/middleware'; 3 | import { AuthApiRequest } from '@/lib/router'; 4 | import { NextApiResponse } from 'next'; 5 | import { createRouter } from 'next-connect'; 6 | 7 | export const router = createRouter(); 8 | 9 | router 10 | .use(checkTeam) 11 | .post(async (req, res) => { 12 | const apiKey = req.body.apiKey as string; 13 | if (apiKey !== '') { 14 | const team = await db.team.update({ 15 | where: { id: req.teamId }, 16 | data: { 17 | typefullyToken: apiKey, 18 | }, 19 | }); 20 | 21 | return res.status(302).redirect(`/teams/${team.slug}/settings`); 22 | } 23 | 24 | return res.status(400).json({ error: true }); 25 | }) 26 | .delete(async (req, res) => { 27 | const team = await db.team.update({ 28 | where: { id: req.teamId }, 29 | data: { 30 | typefullyToken: null, 31 | }, 32 | }); 33 | 34 | return res.status(200).json({ team }); 35 | }); 36 | 37 | export default router.handler({}); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Premier Octet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/digests/block-card/bookmark-card/card-style/Tweet.tsx: -------------------------------------------------------------------------------- 1 | import { getTweetId } from '@/utils/link'; 2 | import clsx from 'clsx'; 3 | import { Tweet } from 'react-tweet'; 4 | import * as Sentry from '@sentry/nextjs'; 5 | import styles from './Tweet.module.css'; 6 | 7 | function CardStyleTweet({ 8 | url, 9 | panelSlot, 10 | }: { 11 | url: string; 12 | panelSlot: React.ReactNode; 13 | }) { 14 | const tweetId = getTweetId(url); 15 | if (!tweetId) { 16 | Sentry.captureMessage( 17 | `BlockBookmarkCard: Tweet Embed style but no tweetId found in url: ${url}` 18 | ); 19 | return null; 20 | } 21 | 22 | const hasPanel = Boolean(panelSlot); 23 | 24 | return ( 25 |
26 |
33 | 34 |
35 | 36 | {hasPanel && panelSlot} 37 |
38 | ); 39 | } 40 | 41 | export default CardStyleTweet; 42 | -------------------------------------------------------------------------------- /src/utils/apiError.tsx: -------------------------------------------------------------------------------- 1 | export const ApiErrorMessages = { 2 | INTERNAL_SERVER_ERROR: 'Internal Server Error', 3 | UNAUTHORIZED: 'Unauthorized', 4 | RATE_LIMIT_EXCEEDED: 'Rate Limit Exceeded', 5 | MISSING_PARAMETERS: 'Missing Parameters', 6 | } as const; 7 | 8 | type ApiErrorMessagesType = 9 | (typeof ApiErrorMessages)[keyof typeof ApiErrorMessages]; 10 | 11 | export class ApiError extends Error { 12 | constructor(message: ApiErrorMessagesType, public status: number) { 13 | super(message); 14 | } 15 | } 16 | 17 | export class UnauthorizedError extends ApiError { 18 | constructor() { 19 | super(ApiErrorMessages.UNAUTHORIZED, 401); 20 | } 21 | } 22 | 23 | export class MissingParametersError extends ApiError { 24 | constructor() { 25 | super(ApiErrorMessages.MISSING_PARAMETERS, 400); 26 | } 27 | } 28 | 29 | export class RateLimitExceedError extends ApiError { 30 | constructor() { 31 | super(ApiErrorMessages.RATE_LIMIT_EXCEEDED, 429); 32 | } 33 | } 34 | 35 | export class InternalServerError extends ApiError { 36 | constructor() { 37 | super(ApiErrorMessages.INTERNAL_SERVER_ERROR, 500); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/index.tsx: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { checkTeam } from '@/lib/middleware'; 3 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 4 | import { NextApiResponse } from 'next'; 5 | import { createRouter } from 'next-connect'; 6 | 7 | export const router = createRouter(); 8 | 9 | router 10 | .use(checkTeam) 11 | .delete(async (req, res) => { 12 | const team = await db.team.delete({ where: { id: req.teamId } }); 13 | 14 | return res.status(201).json({ team }); 15 | }) 16 | .patch(async (req, res) => { 17 | const { name, website, twitter, github, bio } = req.body; 18 | 19 | const team = await db.team.update({ 20 | where: { id: req.teamId }, 21 | data: { 22 | ...(name && { name }), 23 | ...(bio && { bio: bio.substring(0, 160) }), 24 | ...(website && { website }), 25 | ...(twitter && { twitter }), 26 | ...(github && { github }), 27 | }, 28 | }); 29 | 30 | return res.status(200).json({ team }); 31 | }); 32 | 33 | export default router.handler({ 34 | onError: errorHandler, 35 | }); 36 | -------------------------------------------------------------------------------- /src/actions/delete-invitation.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import db from '@/lib/db'; 3 | import * as Sentry from '@sentry/nextjs'; 4 | import { checkAuthAction, getErrorMessage } from './utils'; 5 | import { revalidatePath } from 'next/cache'; 6 | 7 | interface DeleteInvitationResult { 8 | error?: { 9 | message: string; 10 | }; 11 | data?: { 12 | invitationId: string; 13 | }; 14 | } 15 | 16 | export default async function deleteInvitation( 17 | invitationId: string 18 | ): Promise { 19 | try { 20 | await checkAuthAction(); 21 | 22 | // Delete invitation 23 | const invitation = await db.invitation.delete({ 24 | where: { 25 | id: invitationId, 26 | }, 27 | }); 28 | 29 | await db.membership.delete({ 30 | where: { 31 | id: invitation.membershipId, 32 | }, 33 | }); 34 | 35 | revalidatePath('/teams/[teamSlug]/settings'); 36 | return { data: { invitationId } }; 37 | } catch (err: any) { 38 | Sentry.captureException(err); 39 | return { 40 | error: { 41 | message: getErrorMessage(err.message), 42 | }, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /changelogs/changelog-005.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 🐦 Tweet / X Embeds 3 | slug: /changelog-005 4 | publishedAt: 2023-08-21 5 | image: changelog-005.webp 6 | --- 7 | 8 | ### New Feature 9 | 10 | - **Tweet / X Embeds**: We are introducing the ability to display tweets in an "embed" format within a Digest! When editing a Digest, click on the ellipsis icon of the tweet's block and select the "Tweet" option from the block style. This feature is only available for tweet bookmark. 11 | 12 | ### Enhancements 13 | 14 | - **Default Image for URLs**: Bookmarks without open-graph images will now display a default image with the site's title and favicon. This enhancement ensures a visually consistent experience for your content. 15 | 16 | - **Performance Improvements**: We've implemented several performance enhancements on the page displaying a team's Digests, ensuring faster load times. 17 | 18 | ### Bug Fixes 19 | 20 | - **Twitter Link Import Bug Fix**: We've resolved the bug that was previously blocking the import of Twitter links. 21 | 22 | - **Various Bug Fixes**: We've addressed various bugs and issues throughout the application, ensuring a more stable and reliable experience. 23 | -------------------------------------------------------------------------------- /prisma/migrations/20230315134428_add_bookmark_digest/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `digestId` on the `bookmarks` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "bookmarks" DROP CONSTRAINT "bookmarks_digestId_fkey"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "bookmarks" DROP COLUMN "digestId"; 12 | 13 | -- CreateTable 14 | CREATE TABLE "bookmark_digest" ( 15 | "id" TEXT NOT NULL, 16 | "bookmarkId" TEXT NOT NULL, 17 | "digestId" TEXT NOT NULL, 18 | "order" INTEGER NOT NULL, 19 | 20 | CONSTRAINT "bookmark_digest_pkey" PRIMARY KEY ("id") 21 | ); 22 | 23 | -- CreateIndex 24 | CREATE UNIQUE INDEX "bookmark_digest_bookmarkId_digestId_key" ON "bookmark_digest"("bookmarkId", "digestId"); 25 | 26 | -- AddForeignKey 27 | ALTER TABLE "bookmark_digest" ADD CONSTRAINT "bookmark_digest_bookmarkId_fkey" FOREIGN KEY ("bookmarkId") REFERENCES "bookmarks"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 28 | 29 | -- AddForeignKey 30 | ALTER TABLE "bookmark_digest" ADD CONSTRAINT "bookmark_digest_digestId_fkey" FOREIGN KEY ("digestId") REFERENCES "digests"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 31 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/key/new.tsx: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { checkTeam } from '@/lib/middleware'; 3 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 4 | import { Bookmark } from '@prisma/client'; 5 | import { NextApiResponse } from 'next'; 6 | import { createRouter } from 'next-connect'; 7 | import * as Sentry from '@sentry/nextjs'; 8 | import jwt from 'jsonwebtoken'; 9 | 10 | export type ApiBookmarkResponseSuccess = Bookmark; 11 | 12 | export const router = createRouter(); 13 | 14 | router.use(checkTeam).get(async (req, res) => { 15 | if (!process.env.JWT_SECRET) return res.status(500); 16 | try { 17 | const token = jwt.sign({ teamId: req.teamId }, process.env.JWT_SECRET); 18 | 19 | const team = await db.team.update({ 20 | where: { id: req.teamId }, 21 | data: { 22 | apiKey: token, 23 | }, 24 | }); 25 | 26 | return res.status(200).json({ team }); 27 | } catch (err) { 28 | Sentry.captureException(err); 29 | return res.status(500).json({ error: 'Something went wrong' }); 30 | } 31 | }); 32 | 33 | export default router.handler({ 34 | onError: errorHandler, 35 | }); 36 | -------------------------------------------------------------------------------- /src/actions/utils.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from '@/lib/sessions'; 2 | import { getTeamMembershipById } from '@/services/database/membership'; 3 | 4 | export const CUSTOM_ERROR_MESSAGES = { 5 | unauthenticated: 'Unauthenticated user', 6 | wrong_team: 'User is not a team member', 7 | missing_params: 'Missing query parameters', 8 | }; 9 | 10 | export const getErrorMessage = (message: string) => message; 11 | // Object.values(CUSTOM_ERROR_MESSAGES)?.includes(message) 12 | // ? message 13 | // : 'Something went wrong...'; 14 | 15 | export const checkAuthAction = async () => { 16 | const session = await getSession(); 17 | 18 | if (!session?.user) { 19 | throw new Error(CUSTOM_ERROR_MESSAGES.unauthenticated); 20 | } 21 | return session?.user; 22 | }; 23 | 24 | export const checkTeamAction = async (teamId: string) => { 25 | const session = await getSession(); 26 | 27 | if (!session && !teamId) { 28 | throw new Error(CUSTOM_ERROR_MESSAGES.missing_params); 29 | } 30 | 31 | const membership = await getTeamMembershipById(teamId, session!.user?.id); 32 | 33 | if (!membership) { 34 | throw new Error(CUSTOM_ERROR_MESSAGES.wrong_team); 35 | } 36 | return membership; 37 | }; 38 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].tsx: -------------------------------------------------------------------------------- 1 | import { routes } from '@/core/constants'; 2 | import { EMAIL_SUBJECTS, sendEmail } from '@/emails'; 3 | import LoginEmail from '@/emails/templates/LoginEmail'; 4 | import prisma from '@/lib/db'; 5 | import { PrismaAdapter } from '@next-auth/prisma-adapter'; 6 | import NextAuth, { NextAuthOptions } from 'next-auth'; 7 | import EmailProvider from 'next-auth/providers/email'; 8 | 9 | export const authOptions: NextAuthOptions = { 10 | adapter: PrismaAdapter(prisma), 11 | providers: [ 12 | EmailProvider({ 13 | async sendVerificationRequest({ identifier: email, url }) { 14 | await sendEmail({ 15 | to: email, 16 | subject: EMAIL_SUBJECTS.LOGIN, 17 | component: , 18 | }); 19 | }, 20 | }), 21 | ], 22 | callbacks: { 23 | session: async ({ session, user }) => { 24 | if (user) { 25 | session.user.id = user.id; 26 | 27 | if (user.role) { 28 | session.user.role = user.role; 29 | } 30 | } 31 | 32 | return session; 33 | }, 34 | }, 35 | pages: { 36 | signIn: routes.LOGIN, 37 | }, 38 | }; 39 | 40 | export default NextAuth(authOptions); 41 | -------------------------------------------------------------------------------- /src/components/pages/Homepage.tsx: -------------------------------------------------------------------------------- 1 | import Hero from '@/components/home/Hero'; 2 | import { Session } from 'next-auth'; 3 | import HomeDigests from '../home/HomeDigests'; 4 | import HomeFeatures from '../home/HomeFeatures'; 5 | import HomeFooter from '../home/HomeFooter'; 6 | import HomeOpenSource from '../home/HomeOpenSource'; 7 | import HomeSteps from '../home/HomeSteps'; 8 | 9 | const Homepage = ({ user }: { user?: Session['user'] }) => { 10 | return ( 11 |
12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 | 28 |
29 | ); 30 | }; 31 | 32 | export default Homepage; 33 | -------------------------------------------------------------------------------- /src/app/(app)/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | 5 | export default function InternalErrorPage() { 6 | return ( 7 | <> 8 |
9 |
10 |

500

11 |

12 | Something went wrong! 13 |

14 |

15 | Sorry for the inconvenience, we are working on it. 16 |

17 |
18 | 22 | Go back home 23 | 24 |
25 |
26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/invitations/[invitationId]/accept/page.tsx: -------------------------------------------------------------------------------- 1 | import Invitation from '@/components/pages/Invitation'; 2 | import db from '@/lib/db'; 3 | import { getCurrentUser } from '@/lib/sessions'; 4 | import { notFound } from 'next/navigation'; 5 | 6 | interface InvitationPageProps { 7 | params: { invitationId: string }; 8 | } 9 | 10 | export const metadata = { 11 | title: 'Invitation', 12 | }; 13 | 14 | const InvitationPage = async ({ params }: InvitationPageProps) => { 15 | const { invitationId } = params; 16 | const user = await getCurrentUser(); 17 | 18 | const invitation = await db.invitation.findUnique({ 19 | select: { 20 | id: true, 21 | membershipId: true, 22 | membership: { 23 | include: { 24 | team: true, 25 | }, 26 | }, 27 | }, 28 | where: { 29 | id: invitationId!.toString(), 30 | }, 31 | }); 32 | 33 | if (!invitation) { 34 | return notFound(); 35 | } 36 | 37 | return ( 38 | 44 | ); 45 | }; 46 | 47 | export default InvitationPage; 48 | -------------------------------------------------------------------------------- /src/actions/generate-api-key.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import db from '@/lib/db'; 3 | import * as Sentry from '@sentry/nextjs'; 4 | import jwt from 'jsonwebtoken'; 5 | 6 | interface APIKeyGenerationResult { 7 | error?: { 8 | message: string; 9 | }; 10 | data?: { 11 | key: string; 12 | }; 13 | } 14 | 15 | /** 16 | * @description Generates an write to the database a new API key for the team 17 | * @param teamId a string representing the team id 18 | * @returns 19 | */ 20 | export default async function generateAPIKey( 21 | teamId: string 22 | ): Promise { 23 | if (!process.env.JWT_SECRET) { 24 | return { 25 | error: { 26 | message: 'Internal server error', 27 | }, 28 | }; 29 | } 30 | try { 31 | const token = jwt.sign({ teamId }, process.env.JWT_SECRET); 32 | 33 | await db.team.update({ 34 | where: { id: teamId }, 35 | data: { 36 | apiKey: token, 37 | }, 38 | }); 39 | 40 | return { 41 | data: { 42 | key: token, 43 | }, 44 | }; 45 | } catch (err) { 46 | Sentry.captureException(err); 47 | return { 48 | error: { 49 | message: 'Internal server error', 50 | }, 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/(app)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | export const dynamic = 'force-dynamic'; 4 | 5 | export default function NotFound() { 6 | return ( 7 | <> 8 |
9 |
10 |

404

11 |

12 | Page not found 13 |

14 |

15 | Sorry, we couldn’t find the page you’re looking for. 16 |

17 |
18 | 22 | Go back home 23 | 24 |
25 |
26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/page.tsx: -------------------------------------------------------------------------------- 1 | import { COOKIES, routes } from '@/core/constants'; 2 | import { getSession } from '@/lib/sessions'; 3 | import { getUserTeams } from '@/services/database/team'; 4 | import { cookies } from 'next/headers'; 5 | import { redirect } from 'next/navigation'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | 9 | export const metadata = { 10 | title: 'Team', 11 | }; 12 | 13 | const AppPage = async () => { 14 | const session = await getSession(); 15 | 16 | if (!session) { 17 | return redirect(routes.LOGIN); 18 | } 19 | const cookieStore = cookies(); 20 | 21 | const defaultTeam = cookieStore.get(COOKIES.DEFAULT_TEAM)?.value; 22 | if (!defaultTeam) { 23 | // If the user has no default team (cookie), we need to get the teams from the database and redirect them to the first team 24 | const teams = await getUserTeams(session.user.id); 25 | if (teams.length === 0) { 26 | redirect(routes.TEAMS_CREATE); 27 | } else { 28 | redirect(routes.TEAM.replace(':slug', teams[0].slug)); 29 | } 30 | } else { 31 | // If the user has a default team (cookie), we redirect them to that team 32 | redirect(routes.TEAM.replace(':slug', defaultTeam)); 33 | } 34 | }; 35 | 36 | export default AppPage; 37 | -------------------------------------------------------------------------------- /src/components/admin/widgets/LinksOverTime.tsx: -------------------------------------------------------------------------------- 1 | import { linksByDay } from '@/lib/adminQueries'; 2 | import { Card, Title, AreaChart } from '@tremor/react'; 3 | import { useMemo } from 'react'; 4 | 5 | type Props = { 6 | data: { 7 | linksByDay: Awaited>; 8 | }; 9 | }; 10 | 11 | const LinksOverTime = ({ data }: Props) => { 12 | const formattedData = useMemo( 13 | () => 14 | // @ts-expect-error 15 | [...Array(new Date().getDate() + 1).keys()].map((day) => { 16 | const date = new Date(); 17 | date.setDate(day); 18 | return { 19 | day: date.toLocaleString('default', { dateStyle: 'short' }), 20 | Liens: 21 | data.linksByDay.find( 22 | (link) => new Date(link.createdat).getDate() === day 23 | )?.count || 0, 24 | }; 25 | }), 26 | [data] 27 | ); 28 | 29 | return ( 30 | 31 | Liens 32 | 40 | 41 | ); 42 | }; 43 | 44 | export default LinksOverTime; 45 | -------------------------------------------------------------------------------- /prisma/migrations/20230213142412_create_links_and_bookmarks/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Provider" AS ENUM ('WEB'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "links" ( 6 | "id" TEXT NOT NULL, 7 | "url" TEXT NOT NULL, 8 | "image" TEXT, 9 | "blurHash" TEXT, 10 | "title" TEXT, 11 | "description" TEXT, 12 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "updatedAt" TIMESTAMP(3) NOT NULL, 14 | 15 | CONSTRAINT "links_pkey" PRIMARY KEY ("id") 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "bookmarks" ( 20 | "id" TEXT NOT NULL, 21 | "linkId" TEXT NOT NULL, 22 | "membershipId" INTEGER NOT NULL, 23 | "provider" "Provider" NOT NULL DEFAULT 'WEB', 24 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | "updatedAt" TIMESTAMP(3) NOT NULL, 26 | 27 | CONSTRAINT "bookmarks_pkey" PRIMARY KEY ("id") 28 | ); 29 | 30 | -- AddForeignKey 31 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "links"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 32 | 33 | -- AddForeignKey 34 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 35 | -------------------------------------------------------------------------------- /src/components/layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import { routes } from '@/core/constants'; 2 | import { Team } from '@prisma/client'; 3 | import { Session } from 'next-auth'; 4 | import Link from 'next/link'; 5 | import Button from '../Button'; 6 | import Logo from './Logo'; 7 | import NavList from './NavList'; 8 | import { NavMenu } from './NavMenu/NavMenu'; 9 | 10 | type Props = { teams?: Team[]; user?: Session['user'] }; 11 | 12 | export default function Header({ teams, user }: Props) { 13 | return ( 14 |
15 |
16 |
17 | 18 | 19 | 20 | 21 |
22 |
23 | {user && teams && } 24 | {!user && ( 25 | 26 | 29 | 30 | )} 31 |
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/services/database/invitation.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import db from '@/lib/db'; 4 | 5 | export const getUserInvitations = (email: string) => 6 | db.invitation.findMany({ 7 | select: { 8 | id: true, 9 | membership: { 10 | select: { team: { select: { name: true, id: true, slug: true } } }, 11 | }, 12 | }, 13 | where: { 14 | membership: { invitedEmail: email }, 15 | expiredAt: { gte: new Date() }, 16 | }, 17 | }); 18 | 19 | export const getTeamInvitations = (slug: string) => 20 | db.invitation.findMany({ 21 | where: { 22 | membership: { 23 | team: { 24 | slug, 25 | }, 26 | }, 27 | AND: { 28 | validatedAt: null, 29 | }, 30 | }, 31 | include: { 32 | membership: { 33 | select: { 34 | invitedEmail: true, 35 | invitedName: true, 36 | user: true, 37 | teamId: true, 38 | }, 39 | }, 40 | }, 41 | }); 42 | 43 | export type TeamInvitation = Awaited< 44 | ReturnType 45 | >[number]; 46 | 47 | export type UserInvitationsResults = Awaited< 48 | ReturnType 49 | >; 50 | 51 | export type UserInvitationItem = UserInvitationsResults[number]; 52 | -------------------------------------------------------------------------------- /public/slack.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/digests/block-card/bookmark-card/card-style/Tweet.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | This module is used to style the overwrite the default styles of the react-tweet library 3 | https://react-tweet.vercel.app/ 4 | */ 5 | 6 | .tweet :global(.react-tweet-theme) { 7 | --tweet-body-font-size: theme('fontSize.base'); 8 | --tweet-border: 0px; 9 | max-width: 100%; 10 | 11 | --tweet-color-blue-secondary: theme('colors.black'); 12 | --tweet-color-blue-secondary-hover: theme('colors.green.100'); 13 | --tweet-twitter-icon-color: theme('colors.gray.100'); 14 | --tweet-color-red-primary: theme('colors.black'); 15 | --tweet-color-red-primary-hover: theme('colors.green.100'); 16 | --tweet-color-blue-primary: theme('colors.black'); 17 | --tweet-color-blue-primary-hover: theme('colors.green.100'); 18 | --tweet-color-green-primary: theme('colors.black'); 19 | --tweet-color-green-primary-hover: theme('colors.green.100'); 20 | --tweet-font-color-secondary: theme('colors.black'); 21 | } 22 | 23 | /* hide the tweet actions buttons (retweet, like, share) */ 24 | .tweet div[class^='tweet-actions'] { 25 | display: none; 26 | } 27 | 28 | /* hide the tweet replies */ 29 | .tweet div[class^='tweet-replies'] { 30 | display: none; 31 | } 32 | 33 | .tweet [class^='tweet-header_twitterIcon'] { 34 | display: none; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/template.ts: -------------------------------------------------------------------------------- 1 | import { CreateBlockData } from '@/pages/api/teams/[teamId]/digests/[digestId]/block'; 2 | import { getDigest } from '@/services/database/digest'; 3 | import { DigestBlock, DigestBlockType } from '@prisma/client'; 4 | 5 | type DigestBlocks = NonNullable< 6 | Awaited> 7 | >['digestBlocks']; 8 | 9 | /** 10 | * Function to convert digest blocks to template blocks and transform them to CreateBlockData (API format) 11 | * @param blocks 12 | * @returns 13 | */ 14 | export function digestBlockToTemplateBlocks( 15 | blocks: DigestBlocks 16 | ): CreateBlockData[] { 17 | let position = 0; // start at 0 because we increment before adding the block 18 | const AUTHORIZED_TEMPLATE_BLOCK_TYPES: Array = [ 19 | DigestBlockType.TEXT, 20 | ]; 21 | 22 | const authorizedBlocks = blocks 23 | .filter((block) => AUTHORIZED_TEMPLATE_BLOCK_TYPES.includes(block.type)) 24 | .map((block) => { 25 | position++; 26 | return { 27 | ...(block.bookmarkId && { bookmarkId: block.bookmarkId }), 28 | ...(block.text && { text: block.text }), 29 | position: position, 30 | type: block.type, 31 | style: block.style, 32 | isTemplate: block.isTemplate, 33 | }; 34 | }); 35 | 36 | return authorizedBlocks; 37 | } 38 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { CheckCircleIcon } from '@heroicons/react/24/solid'; 3 | 4 | type Props = { 5 | children: ReactNode; 6 | }; 7 | 8 | export default function Layout({ children }: Props) { 9 | const features = [ 10 | 'Collect links', 11 | 'Design your digest', 12 | 'Share your digests with tech folks!', 13 | ]; 14 | return ( 15 |
16 |
17 |
18 | Join the Club 👋 19 | 20 | Digest Club helps your team to share the knowledge! 21 | 22 |
    23 | {features.map((feature, index) => ( 24 |
  • 25 | 26 | {feature} 27 |
  • 28 | ))} 29 |
30 |
31 |
32 |
{children}
33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Tag.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from '@heroicons/react/24/outline'; 2 | import clsx from 'clsx'; 3 | import { Tag as TagModel } from '@prisma/client'; 4 | 5 | export type ITag = Pick; 6 | 7 | const Tag = ({ 8 | tag, 9 | onCloseClick, 10 | size = 'default', 11 | active = false, 12 | }: { 13 | tag: ITag; 14 | onCloseClick?: (tag: ITag) => void; 15 | size?: 'default' | 'small' | 'large'; 16 | active?: boolean; 17 | }) => { 18 | return ( 19 |
32 | #{tag.name} 33 | {onCloseClick && ( 34 | onCloseClick(tag)} 37 | /> 38 | )} 39 |
40 | ); 41 | }; 42 | 43 | export default Tag; 44 | -------------------------------------------------------------------------------- /src/components/charts/ChartsTooltip.tsx: -------------------------------------------------------------------------------- 1 | const MONTH_LONG_NAMES = [ 2 | 'January', 3 | 'February', 4 | 'March', 5 | 'April', 6 | 'May', 7 | 'June', 8 | 'July', 9 | 'August', 10 | 'September', 11 | 'October', 12 | 'November', 13 | 'December', 14 | ] as const; 15 | 16 | type MonthLongName = (typeof MONTH_LONG_NAMES)[number]; 17 | 18 | const getMonthFullName = (index: number): MonthLongName => { 19 | return MONTH_LONG_NAMES[index]; 20 | }; 21 | 22 | const ChartsTooltip = ({ 23 | active, 24 | payload, 25 | label, 26 | }: { 27 | active?: boolean; 28 | payload?: Array<{ 29 | value: number; 30 | }>; 31 | label?: any; 32 | }) => { 33 | const year = new Date().getFullYear(); 34 | if (active && payload && payload.length) { 35 | return ( 36 |
37 |

{`${getMonthFullName( 38 | label 39 | )} ${year}`}

40 |
41 | 42 | {payload[0].value} bookmarked 43 |
44 |
45 | ); 46 | } 47 | }; 48 | 49 | export default ChartsTooltip; 50 | -------------------------------------------------------------------------------- /src/components/layout/PublicPageTemplate.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import PublicDigestHeader from '../digests/PublicDigestHeader'; 3 | import SubscribeToNewsLetter from '../digests/SubscribeToNewsletter'; 4 | import { PublicTeamResult } from '@/services/database/team'; 5 | 6 | interface Props { 7 | // @ts-ignore 8 | team: NonNullable | NonNullable['team']; 9 | } 10 | 11 | const PublicPageTemplate = ({ children, team }: PropsWithChildren & Props) => { 12 | return ( 13 |
14 |
{children}
15 |
16 | 27 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default PublicPageTemplate; 34 | -------------------------------------------------------------------------------- /src/components/bookmark/CreateBookmarkButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { PlusIcon } from '@heroicons/react/24/solid'; 4 | import { Team } from '@prisma/client'; 5 | import { useState } from 'react'; 6 | import Button from '../Button'; 7 | import { Dialog, DialogContent, DialogTrigger } from '../Dialog'; 8 | import { BookmarkModal } from './BookmarkModal'; 9 | 10 | interface Props { 11 | team: Team; 12 | } 13 | 14 | export default function CreateBookmarkButton({ team }: Props) { 15 | const [isDialogOpen, setIsDialogOpen] = useState(false); 16 | return ( 17 | 18 | 19 | 29 | 30 | 36 | setIsDialogOpen(false)} team={team} /> 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/api/user/[userId]/index.ts: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { checkAuth } from '@/lib/middleware'; 3 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 4 | import { NextApiResponse } from 'next'; 5 | import { createRouter } from 'next-connect'; 6 | 7 | export const router = createRouter(); 8 | 9 | router 10 | .use(checkAuth) 11 | .put(async (req, res) => { 12 | const userId = req.query.userId as string; 13 | 14 | if (req.user!.id !== userId) { 15 | return res.status(403).end(); 16 | } 17 | 18 | const updatedUser = await db.user.update({ 19 | where: { 20 | id: userId, 21 | }, 22 | data: req.body, 23 | }); 24 | 25 | return res.status(200).json(updatedUser); 26 | }) 27 | .delete(async (req, res) => { 28 | const userId = req.query.userId as string; 29 | 30 | if (req.user!.id !== userId) { 31 | return res.status(403).end(); 32 | } 33 | 34 | const account = await db.user.delete({ 35 | where: { 36 | id: userId, 37 | }, 38 | }); 39 | 40 | await db.team.deleteMany({ 41 | where: { 42 | memberships: { 43 | none: {}, 44 | }, 45 | }, 46 | }); 47 | 48 | return res.status(201).json(account); 49 | }); 50 | 51 | export default router.handler({ 52 | onError: errorHandler, 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/pages/DigestPublicPage.tsx: -------------------------------------------------------------------------------- 1 | import { PublicDigestResult } from '@/services/database/digest'; 2 | import PublicDigestHeader from '../digests/PublicDigestHeader'; 3 | import PublicDigestList from '../digests/PublicDigestList'; 4 | import SubscribeToNewsLetter from '../digests/SubscribeToNewsletter'; 5 | 6 | export interface Props { 7 | digest: NonNullable; 8 | } 9 | const DigestPublicPage = ({ digest }: Props) => { 10 | const { team } = digest; 11 | 12 | return ( 13 |
14 |
15 | 16 |
17 |
18 | 29 | 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default DigestPublicPage; 36 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/[teamSlug]/[digestSlug]/preview/page.tsx: -------------------------------------------------------------------------------- 1 | import DigestPublicPage from '@/components/pages/DigestPublicPage'; 2 | import { getSession } from '@/lib/sessions'; 3 | import { getPublicDigest } from '@/services/database/digest'; 4 | import { getUserTeams } from '@/services/database/team'; 5 | import { redirect } from 'next/navigation'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | export const revalidate = 0; 9 | 10 | interface PageProps { 11 | params: { teamSlug: string; digestSlug: string }; 12 | } 13 | 14 | const PreviewDigestPage = async ({ params }: PageProps) => { 15 | const session = await getSession(); 16 | const teams = await getUserTeams(session?.user.id); 17 | 18 | if (!teams?.find((team) => team?.slug === params.teamSlug)) { 19 | redirect('/'); 20 | } 21 | 22 | const digest = await getPublicDigest( 23 | params.digestSlug, 24 | params.teamSlug, 25 | true 26 | ); 27 | 28 | if (!digest) { 29 | redirect('/'); 30 | } 31 | 32 | return ( 33 | <> 34 |
35 | 36 | Digest Preview - {params.digestSlug} 👀 37 | 38 |
39 | 40 | 41 | ); 42 | }; 43 | 44 | export default PreviewDigestPage; 45 | -------------------------------------------------------------------------------- /src/utils/rateLimit.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from 'next'; 2 | import { LRUCache } from 'lru-cache'; 3 | import { RateLimitExceedError } from './apiError'; 4 | 5 | type Options = { 6 | uniqueTokenPerInterval?: number; 7 | interval?: number; 8 | }; 9 | 10 | // from : https://github.com/vercel/next.js/blob/canary/examples/api-routes-rate-limit/utils/rate-limit.ts 11 | /** 12 | * Rate limit a request by a given key and a given time window 13 | */ 14 | export default function rateLimit(options?: Options) { 15 | const tokenCache = new LRUCache({ 16 | max: options?.uniqueTokenPerInterval || 500, 17 | ttl: options?.interval || 60000, 18 | }); 19 | 20 | return { 21 | check: (res: NextApiResponse, limit: number, token: string) => 22 | new Promise((resolve, reject) => { 23 | const tokenCount = (tokenCache.get(token) as number[]) || [0]; 24 | if (tokenCount[0] === 0) { 25 | tokenCache.set(token, tokenCount); 26 | } 27 | tokenCount[0] += 1; 28 | 29 | const currentUsage = tokenCount[0]; 30 | const isRateLimited = currentUsage >= limit; 31 | res.setHeader('X-RateLimit-Limit', limit); 32 | res.setHeader( 33 | 'X-RateLimit-Remaining', 34 | isRateLimited ? 0 : limit - currentUsage 35 | ); 36 | 37 | return isRateLimited ? reject(new RateLimitExceedError()) : resolve(); 38 | }), 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/app/(admin)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getEnvHost } from '@/lib/server'; 2 | import { Metadata } from 'next'; 3 | import '@/theme/admin.css'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | type Props = { 8 | children: React.ReactNode; 9 | }; 10 | 11 | const description = 12 | 'The Frontpage of Teams Knowledge. Save and share your team’s curation.'; 13 | const title = `Digest.club`; 14 | 15 | export const metadata: Metadata = { 16 | icons: { icon: '/favicon.ico' }, 17 | title: { 18 | default: 'Digest.club - The Frontpage of Teams Knowledge', 19 | template: '%s | Digest.club', 20 | }, 21 | description, 22 | twitter: { 23 | card: 'summary_large_image', 24 | title, 25 | description, 26 | images: [`${getEnvHost()}/og-cover.png`], 27 | }, 28 | openGraph: { 29 | title, 30 | description, 31 | url: process.env.NEXTAUTH_URL, 32 | siteName: title, 33 | images: [ 34 | { 35 | url: `${getEnvHost()}/og-cover.png`, 36 | width: 2400, 37 | height: 1200, 38 | }, 39 | ], 40 | locale: 'en-GB', 41 | type: 'website', 42 | }, 43 | }; 44 | 45 | export default async function RootLayout({ children }: Props) { 46 | return ( 47 | 48 | 49 | {children} 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/ActiveTeams.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import TeamAvatar from './teams/TeamAvatar'; 3 | 4 | interface Props { 5 | teams: { 6 | name: string; 7 | color: string | null; 8 | slug: string; 9 | }[]; 10 | } 11 | export default function ActiveTeams({ teams }: Props) { 12 | if (teams.length === 0) { 13 | return <>; 14 | } 15 | 16 | return ( 17 |
18 |

Active Teams

19 |

20 | Most active teams on digest.club 21 |

22 |
23 | {teams.map((team) => ( 24 |
25 | 26 | 27 | 28 | {team?.name} 29 | 35 | Browse all digests 36 | 37 | 38 | 39 |
40 | ))} 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/digests/AddTextBlockButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { PlusCircleIcon } from '@heroicons/react/24/solid'; 3 | import AddTextBlockDialog from './dialog/AddTextBlockDialog'; 4 | import { useParams } from 'next/navigation'; 5 | import { useTeam } from '@/contexts/TeamContext'; 6 | 7 | const AddTextBlockButton = ({ position }: { position: number }) => { 8 | const params = useParams(); 9 | const [isAddTextDialogOpen, setIsAddTextDialogOpen] = useState(false); 10 | const { id: teamId } = useTeam(); 11 | 12 | return ( 13 |
14 |
15 |
16 |
17 |
setIsAddTextDialogOpen(true)} 20 | > 21 |
22 | 23 |
24 |
25 |
26 |
27 | 28 | 35 |
36 | ); 37 | }; 38 | 39 | export default AddTextBlockButton; 40 | -------------------------------------------------------------------------------- /src/app/(app)/updates/page.tsx: -------------------------------------------------------------------------------- 1 | import ChangelogPost from '@/components/changelog/ChangelogPost'; 2 | import { Metadata } from 'next'; 3 | import { allChangelogs, Changelog } from 'contentlayer/generated'; 4 | import HomeFooter from '@/components/home/HomeFooter'; 5 | 6 | export const dynamic = 'force-static'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'Changelog', 10 | description: 11 | 'All the latest updates, improvements, and fixes to Digest.club.', 12 | }; 13 | 14 | export default async function Updates() { 15 | return ( 16 |
17 |
18 |
19 |

20 | Updates 21 |

22 |
23 | {allChangelogs 24 | .sort((a, b) => { 25 | if (new Date(a.publishedAt) > new Date(b.publishedAt)) { 26 | return -1; 27 | } 28 | return 1; 29 | }) 30 | .map((changelog, i) => ( 31 |
32 | 33 |
34 | ))} 35 |
36 |
37 |
38 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/components/bookmark/BookmarksListControls.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { PropsWithChildren, useState } from 'react'; 3 | import Pagination from '../list/Pagination'; 4 | import clsx from 'clsx'; 5 | import { useSearchParams, useRouter, usePathname } from 'next/navigation'; 6 | import { useTransition } from 'react'; 7 | import { Switch } from '../Input'; 8 | 9 | export const BookmarksListControls = ({ 10 | linkCount, 11 | }: { linkCount: number } & PropsWithChildren) => { 12 | const searchParams = useSearchParams(); 13 | const params = new URLSearchParams(searchParams?.toString()); 14 | const path = usePathname(); 15 | let [isPending, startTransition] = useTransition(); 16 | const { replace } = useRouter(); 17 | 18 | const handleCheckboxChange = () => { 19 | if (!searchParams) return; 20 | 21 | if (params.get('all') === 'true') { 22 | params.delete('all'); 23 | } else { 24 | params.set('all', 'true'); 25 | params.delete('page'); 26 | } 27 | startTransition(() => { 28 | replace(path + `?${params.toString()}`); 29 | }); 30 | }; 31 | 32 | if (params?.get('search') && !linkCount) return null; 33 | 34 | return ( 35 |
36 | 41 | 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/pages/api/bookmark-og.tsx: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { createOGBookmarkSVG } from '@/utils/open-graph'; 3 | import { Resvg } from '@resvg/resvg-js'; 4 | import { captureException } from '@sentry/nextjs'; 5 | import { NextApiRequest, NextApiResponse } from 'next'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | try { 12 | const hasId = req.query.bookmark; 13 | 14 | if (!hasId) return res.status(404).end(); 15 | 16 | const bookmark = await db.bookmark.findUnique({ 17 | where: { 18 | id: req.query.bookmark as string, 19 | }, 20 | include: { 21 | link: true, 22 | }, 23 | }); 24 | 25 | if (!bookmark) { 26 | res.status(404).end(); 27 | return undefined; 28 | } 29 | 30 | const { 31 | link: { title }, 32 | } = bookmark; 33 | 34 | const svg = await createOGBookmarkSVG({ 35 | title, 36 | favicon: bookmark.link.logo, 37 | }); 38 | const resvg = new Resvg(svg); 39 | const pngData = resvg.render(); 40 | const png = pngData.asPng(); 41 | 42 | res.setHeader('Content-Type', 'image/png'); 43 | res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); 44 | res.status(200).end(png); 45 | } catch (e: any) { 46 | captureException(e); 47 | // eslint-disable-next-line no-console 48 | console.log(e); 49 | res.status(500).json({ error: 'Internal server error' }); 50 | } finally { 51 | res.end(); 52 | return undefined; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/TagsList.tsx: -------------------------------------------------------------------------------- 1 | import { routes } from '@/core/constants'; 2 | import Link from 'next/link'; 3 | import Tag, { ITag } from './Tag'; 4 | 5 | interface Props { 6 | tags: ITag[]; 7 | currentTag?: ITag; 8 | title: string; 9 | description: string; 10 | } 11 | 12 | export default function TagsList({ 13 | tags, 14 | currentTag, 15 | title, 16 | description, 17 | }: Props) { 18 | if (tags.length === 0) return <>; 19 | return ( 20 |
21 |

{title}

22 |

{description}

23 |
24 | {tags.map(({ id, name, slug, description }) => { 25 | const isActive = currentTag?.id === id; 26 | return ( 27 | 33 | 34 | 44 | 45 | 46 | ); 47 | })} 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/digests/index.ts: -------------------------------------------------------------------------------- 1 | import { isUniqueConstraintError } from '@/lib/db'; 2 | import { checkTeam } from '@/lib/middleware'; 3 | import { Digest } from '@prisma/client'; 4 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 5 | import { NextApiResponse } from 'next'; 6 | import { createRouter } from 'next-connect'; 7 | import { 8 | createDigest, 9 | createDigestWithTemplate, 10 | } from '@/services/database/digest-block'; 11 | 12 | export type ApiDigestResponseSuccess = Digest; 13 | interface PostBody { 14 | title: string; 15 | isTemplate: boolean; 16 | templateId?: string; 17 | } 18 | 19 | export const router = createRouter(); 20 | router.use(checkTeam).post(async (req, res) => { 21 | try { 22 | const { title, templateId } = req.body as PostBody; 23 | const teamId = req.teamId!; 24 | 25 | if (templateId) { 26 | const newDigest = await createDigestWithTemplate({ 27 | title, 28 | templateId, 29 | teamId, 30 | }); 31 | return res.status(201).json(newDigest); 32 | } else { 33 | const newDigest = await createDigest({ title, teamId }); 34 | return res.status(201).json(newDigest); 35 | } 36 | } catch (e) { 37 | // eslint-disable-next-line no-console 38 | console.log(e); 39 | return res.status(400).json( 40 | isUniqueConstraintError(e) && { 41 | error: 'This digest name already exists', 42 | } 43 | ); 44 | } 45 | }); 46 | 47 | export default router.handler({ 48 | onError: errorHandler, 49 | }); 50 | -------------------------------------------------------------------------------- /src/components/digests/templates/SelectTemplateModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Button from '@/components/Button'; 3 | import { Dialog, DialogTrigger, DialogContent } from '@/components/Dialog'; 4 | import { Digest, Team } from '@prisma/client'; 5 | import { useState } from 'react'; 6 | import { DigestCreateInput } from '../DigestCreateInput'; 7 | import { TeamDigestsResult } from '@/services/database/digest'; 8 | 9 | const SelectTemplateModal = ({ 10 | team, 11 | templates, 12 | predictedDigestTitle, 13 | }: { 14 | templates: TeamDigestsResult[]; 15 | team: Team; 16 | predictedDigestTitle: string | null; 17 | }) => { 18 | const [isDialogOpen, setIsDialogOpen] = useState(false); 19 | 20 | return ( 21 |
22 | 23 | 24 | 25 | 26 | 32 |
33 | 38 |
39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default SelectTemplateModal; 46 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/bookmark/index.ts: -------------------------------------------------------------------------------- 1 | import { checkTeam } from '@/lib/middleware'; 2 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 3 | import { Bookmark } from '@prisma/client'; 4 | import { NextApiResponse } from 'next'; 5 | import { createRouter } from 'next-connect'; 6 | import * as Sentry from '@sentry/nextjs'; 7 | import messages from '@/messages/en'; 8 | import { saveBookmark } from '@/services/database/bookmark'; 9 | 10 | export type ApiBookmarkResponseSuccess = Bookmark; 11 | 12 | export const router = createRouter(); 13 | 14 | router.use(checkTeam).post(async (req, res) => { 15 | const { link: linkUrl } = req.body; 16 | 17 | if (!linkUrl) { 18 | return res.status(400).end(); 19 | } 20 | 21 | try { 22 | const bookmark = await saveBookmark(linkUrl, req.teamId!, req.membershipId); 23 | return res.status(201).json(bookmark); 24 | } catch (error: unknown) { 25 | // eslint-disable-next-line no-console 26 | console.log(error); 27 | const error_code = (error as TypeError) 28 | .message as keyof typeof messages.bookmark.create.error; 29 | 30 | Sentry.captureMessage( 31 | `Failed to save bookmark. Cause: ${ 32 | messages.bookmark.create.error[error_code] ?? 33 | (error as TypeError).message 34 | } (${linkUrl})` 35 | ); 36 | 37 | return res.status(400).json({ 38 | error: 39 | messages.bookmark.create.error[error_code] ?? messages['default_error'], 40 | }); 41 | } 42 | }); 43 | 44 | export default router.handler({ 45 | onError: errorHandler, 46 | }); 47 | -------------------------------------------------------------------------------- /src/utils/bookmark.ts: -------------------------------------------------------------------------------- 1 | import Metascraper, { Metadata } from 'metascraper'; 2 | import MetascraperTwitter from 'metascraper-twitter'; 3 | import MetascraperTitle from 'metascraper-title'; 4 | import MetascraperDescription from 'metascraper-description'; 5 | import MetascraperImage from 'metascraper-image'; 6 | import MetascrapperLogoFavicon from 'metascraper-logo-favicon'; 7 | 8 | const metascraper = Metascraper([ 9 | MetascraperTwitter(), 10 | MetascraperTitle(), 11 | MetascraperDescription(), 12 | MetascraperImage(), 13 | MetascrapperLogoFavicon(), 14 | ]); 15 | 16 | const getHtml = async (url: string) => { 17 | const controller = new AbortController(); 18 | const timeoutId = setTimeout(() => controller.abort(), 5000); // timeout if it takes longer than 5 seconds 19 | 20 | return await fetch(url, { 21 | signal: controller.signal, 22 | headers: { 23 | 'User-Agent': 'digestclub-bot/1.0', 24 | }, 25 | }).then((res) => { 26 | clearTimeout(timeoutId); 27 | return res.text(); 28 | }); 29 | }; 30 | 31 | export const extractMetadata = async (url: string) => { 32 | const html = await getHtml(url); 33 | const metadata = (await metascraper({ 34 | html, 35 | url, 36 | })) as any as Metadata & { logo: string }; 37 | /* the metascraper types are wrong, logo isn't in Metadata interface (cf. https://github.com/microlinkhq/metascraper/issues/657) */ 38 | 39 | if (!metadata) { 40 | return null; 41 | } 42 | 43 | return { 44 | title: metadata.title, 45 | description: metadata.description, 46 | image: metadata.image, 47 | logo: metadata.logo, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/invitations/[invitationId]/accept.tsx: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { checkAuth } from '@/lib/middleware'; 3 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 4 | import { NextApiResponse } from 'next'; 5 | import { createRouter } from 'next-connect'; 6 | 7 | export const router = createRouter(); 8 | 9 | router.use(checkAuth).get(async (req, res) => { 10 | const invitationId = req.query.invitationId as string; 11 | 12 | const invitation = await db.invitation.findUnique({ 13 | where: { 14 | id: invitationId, 15 | }, 16 | include: { 17 | membership: { 18 | include: { 19 | team: true, 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | if (!invitation) { 26 | return res.status(400).json({ error: 'Invitation not found' }); 27 | } 28 | 29 | // Set user id to membership 30 | const membership = await db.membership.updateMany({ 31 | where: { 32 | invitedEmail: req.user!.email, 33 | }, 34 | data: { 35 | userId: req.user!.id, 36 | }, 37 | }); 38 | 39 | // Delete invitation 40 | await db.invitation.delete({ 41 | where: { 42 | id: invitationId, 43 | }, 44 | }); 45 | 46 | // Set default team for user 47 | await db.user.update({ 48 | data: { 49 | defaultTeamId: invitation.membership.teamId, 50 | }, 51 | where: { 52 | id: req.user?.id, 53 | }, 54 | }); 55 | 56 | return res.status(200).json({ 57 | membership, 58 | }); 59 | }); 60 | 61 | export default router.handler({ 62 | onError: errorHandler, 63 | }); 64 | -------------------------------------------------------------------------------- /src/components/admin/widgets/LinksByWebsite.tsx: -------------------------------------------------------------------------------- 1 | import { linksByDomain } from '@/lib/adminQueries'; 2 | import { BarList, Card, Title, Bold, Flex, Text } from '@tremor/react'; 3 | 4 | type Props = { 5 | data: Awaited>; 6 | }; 7 | 8 | const LinksByWebsite = ({ data }: Props) => ( 9 | 10 | Links by website 11 | 12 | 13 | Source 14 | 15 | 16 | 17 | 18 | 19 | ({ 21 | name: domain.url_domain, 22 | value: domain.count, 23 | href: `https://${domain.url_domain}`, 24 | // icon: function YouTubeIcon() { 25 | // return ( 26 | // 33 | // 34 | // 35 | // 36 | // ); 37 | // }, 38 | }))} 39 | className="mt-2" 40 | /> 41 | 42 | ); 43 | 44 | export default LinksByWebsite; 45 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/account/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getCurrentUser } from '@/lib/sessions'; 3 | import AccountForm from '@/components/account/AccountForm'; 4 | import { notFound } from 'next/navigation'; 5 | import UserInvitations from '@/components/account/UserInvitations'; 6 | import SettingsPageLayout from '@/components/teams/form/settings/SettingsPageLayout'; 7 | import { UserCircleIcon } from '@heroicons/react/24/outline'; 8 | import { getUserInvitations } from '@/services/database/invitation'; 9 | 10 | export const dynamic = 'force-dynamic'; 11 | export const metadata = { 12 | title: 'My account', 13 | }; 14 | 15 | const AccountPage = async () => { 16 | const user = await getCurrentUser(); 17 | if (!user) return notFound(); 18 | const invitations = await getUserInvitations(user!.email as string); 19 | 20 | return ( 21 | , 30 | isActive: true, 31 | }, 32 | ]} 33 | breadcrumbItems={[ 34 | { 35 | name: 'Settings', 36 | }, 37 | { 38 | name: 'Account', 39 | }, 40 | ]} 41 | > 42 |
43 | 44 | 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default AccountPage; 51 | -------------------------------------------------------------------------------- /src/components/admin/widgets/DataOverTime.tsx: -------------------------------------------------------------------------------- 1 | import { newDigestByMonth, newUsersByMonth } from '@/lib/adminQueries'; 2 | import { Card, Title, AreaChart } from '@tremor/react'; 3 | import { useMemo } from 'react'; 4 | 5 | type Props = { 6 | data: { 7 | newUsersByMonth: Awaited>; 8 | newDigestByMonth: Awaited>; 9 | }; 10 | }; 11 | 12 | const DataOverTime = ({ data }: Props) => { 13 | const formattedData = useMemo( 14 | () => 15 | // @ts-expect-error 16 | [...Array(new Date().getMonth() + 1).keys()].map((month) => { 17 | const date = new Date(); 18 | date.setMonth(month - 1); 19 | 20 | return { 21 | 'month': date.toLocaleString('default', { month: 'long' }), 22 | 'Nouveaux utilisateurs': 23 | data.newUsersByMonth.find( 24 | (newUser) => new Date(newUser.createdat).getMonth() === month 25 | )?.count || 0, 26 | 'Nouveaux digests': 27 | data.newDigestByMonth.find( 28 | (newDigest) => new Date(newDigest.createdat).getMonth() === month 29 | )?.count || 0, 30 | }; 31 | }), 32 | [data] 33 | ); 34 | 35 | return ( 36 | 37 | Acquisition 38 | 46 | 47 | ); 48 | }; 49 | 50 | export default DataOverTime; 51 | -------------------------------------------------------------------------------- /src/components/teams/settings-tabs/members/List.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { InformationCircleIcon } from '@heroicons/react/24/solid'; 3 | import { Session } from 'next-auth'; 4 | import { UserRoles } from '@/core/constants'; 5 | import Item from './Item'; 6 | import { Member } from '@/services/database/membership'; 7 | 8 | interface Props { 9 | memberships: Member[]; 10 | currentUser: Session['user']; 11 | } 12 | 13 | const MembersList = ({ memberships, currentUser }: Props) => { 14 | if (memberships.length === 0) { 15 | return ( 16 | <> 17 | 18 | 19 | 20 | 21 |

22 | No user found, click on the{' '} 23 | Invitations tab to 24 | invite users 25 |

26 |
27 | 28 | ); 29 | } 30 | 31 | const currentUserIsAdmin = memberships.some( 32 | (membership) => 33 | membership.user?.id === currentUser?.id && 34 | membership.role === UserRoles.ADMIN 35 | ); 36 | 37 | return ( 38 |
39 | {memberships.map((membership) => { 40 | const isOwner = membership.user?.id === currentUser?.id; 41 | return ( 42 | 47 | ); 48 | })} 49 |
50 | ); 51 | }; 52 | 53 | export default MembersList; 54 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/digests/[digestId]/typefully.ts: -------------------------------------------------------------------------------- 1 | import api from '@/lib/api'; 2 | import db from '@/lib/db'; 3 | import { checkDigest, checkTeam } from '@/lib/middleware'; 4 | import { AuthApiRequest } from '@/lib/router'; 5 | import { getDigestDataForTypefully } from '@/services/database/digest'; 6 | import { createTypefullyDraft } from '@/utils/typefully'; 7 | import { NextApiResponse } from 'next'; 8 | import { createRouter } from 'next-connect'; 9 | 10 | export const router = createRouter(); 11 | 12 | router 13 | .use(checkTeam) 14 | .use(checkDigest) 15 | .get(async (req, res) => { 16 | const teamId = req.query.teamId as string; 17 | const digestId = req.query.digestId as string; 18 | 19 | const team = await db.team.findUnique({ 20 | where: { id: teamId }, 21 | select: { typefullyToken: true, slug: true }, 22 | }); 23 | 24 | if (!team?.typefullyToken) 25 | return res 26 | .status(400) 27 | .json({ error: 'No Typefully token found for this team' }); 28 | 29 | const digest = await getDigestDataForTypefully(digestId, teamId); 30 | 31 | if (!digest) return res.status(404).json({ error: 'Digest not found' }); 32 | 33 | const { threadUrl } = await createTypefullyDraft( 34 | digest, 35 | team.typefullyToken 36 | ); 37 | if (!threadUrl) 38 | return res.status(500).json({ error: 'Error creating thread' }); 39 | await db.digest.update({ 40 | where: { id: digestId }, 41 | data: { typefullyThreadUrl: threadUrl }, 42 | }); 43 | return res.status(200).json({ threadUrl: threadUrl }); 44 | }); 45 | 46 | export default router.handler({}); 47 | -------------------------------------------------------------------------------- /src/components/home/HomeOpenSource.tsx: -------------------------------------------------------------------------------- 1 | import Section from './Section'; 2 | 3 | const HomeOpenSource = () => { 4 | return ( 5 |
10 | 15 | View on GitHub 16 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | export default HomeOpenSource; 33 | -------------------------------------------------------------------------------- /src/lib/adminQueries.ts: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { Prisma } from '@prisma/client'; 3 | 4 | export const newUsersByMonth = async () => { 5 | return await db.$queryRaw<{ createdat: string; count: number }[]>( 6 | Prisma.sql`SELECT DATE_TRUNC('month', u."createdAt") AS createdAt, COUNT(id)::integer AS count 7 | FROM users as u 8 | WHERE DATE_PART('year', u."createdAt") = DATE_PART('year', CURRENT_DATE) 9 | GROUP BY DATE_TRUNC('month', u."createdAt")` 10 | ); 11 | }; 12 | 13 | export const newDigestByMonth = async () => { 14 | return await db.$queryRaw<{ createdat: string; count: number }[]>( 15 | Prisma.sql`SELECT DATE_TRUNC('month', d."createdAt") AS createdAt, COUNT(id)::integer AS count 16 | FROM digests as d 17 | WHERE DATE_PART('year', d."createdAt") = DATE_PART('year', CURRENT_DATE) 18 | GROUP BY DATE_TRUNC('month', d."createdAt")` 19 | ); 20 | }; 21 | 22 | export const linksByDomain = async () => { 23 | return await db.$queryRaw<{ url_domain: string; count: number }[]>( 24 | Prisma.sql`SELECT 25 | substring(l.url from '(?:.*://)?(?:www\.)?([^/?]*)') AS url_domain, 26 | count(l.id)::integer AS count 27 | FROM links AS l 28 | GROUP BY url_domain 29 | ORDER BY count DESC 30 | LIMIT 10;` 31 | ); 32 | }; 33 | 34 | export const linksByDay = async () => { 35 | return await db.$queryRaw<{ createdat: string; count: number }[]>( 36 | Prisma.sql`SELECT DATE_TRUNC('day', l."createdAt") AS createdAt, count(l.id)::integer AS count 37 | FROM links AS l 38 | WHERE DATE_PART('month', l."createdAt") = DATE_PART('month', CURRENT_DATE) 39 | GROUP BY DATE_TRUNC('day', l."createdAt");` 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /prisma/migrations/20230316171352_add_cascade_behaviours/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "bookmarks" DROP CONSTRAINT "bookmarks_linkId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "bookmarks" DROP CONSTRAINT "bookmarks_teamId_fkey"; 6 | 7 | -- DropForeignKey 8 | ALTER TABLE "digests" DROP CONSTRAINT "digests_teamId_fkey"; 9 | 10 | -- DropForeignKey 11 | ALTER TABLE "invitations" DROP CONSTRAINT "invitations_membershipId_fkey"; 12 | 13 | -- DropForeignKey 14 | ALTER TABLE "memberships" DROP CONSTRAINT "memberships_teamId_fkey"; 15 | 16 | -- DropForeignKey 17 | ALTER TABLE "memberships" DROP CONSTRAINT "memberships_userId_fkey"; 18 | 19 | -- AddForeignKey 20 | ALTER TABLE "memberships" ADD CONSTRAINT "memberships_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; 21 | 22 | -- AddForeignKey 23 | ALTER TABLE "memberships" ADD CONSTRAINT "memberships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 24 | 25 | -- AddForeignKey 26 | ALTER TABLE "invitations" ADD CONSTRAINT "invitations_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE; 27 | 28 | -- AddForeignKey 29 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "links"("id") ON DELETE CASCADE ON UPDATE CASCADE; 30 | 31 | -- AddForeignKey 32 | ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; 33 | 34 | -- AddForeignKey 35 | ALTER TABLE "digests" ADD CONSTRAINT "digests_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; 36 | -------------------------------------------------------------------------------- /changelogs/changelog-001.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 🎉 Introducing Typefully Integration 3 | publishedAt: 2023-05-26 4 | slug: changelog-001 5 | image: changelog-001.webp 6 | --- 7 | 8 | ### New Features 9 | 10 | - **Typefully Integration**: We are excited to announce our latest feature - _Typefully_ integration! With just a single click, you can now transform your Digest into a Twitter thread (draft) using the [Typefully](https://typefully.com/) service. Simply provide us with your _Typefully_ API token, and effortlessly share your curated links with your audience on Twitter. 11 | 12 | - **Deleting members of a Team**: Team's administrators can now delete members, by clicking on the _Delete_ button next to the member's name. 13 | 14 | ### Enhancements 15 | 16 | - **Improved User Interface**: We have refined the user interface with an updated navigation 17 | - **'Updates' page**: We have added a [new page](https://digest.club/updates) to keep you up-to-date with the latest changes and improvements to Digest.club. 18 | 19 | ### Get Started with Typefully Integration 20 | 21 | To take advantage of the Typefully integration, follow these simple steps: 22 | 23 | 1. Sign up for a Typefully account at [typefully.com](https://typefully.com) and obtain your API token. 24 | 2. Navigate to the 'Settings' page of your team and enter your Typefully API token in the designated field. 25 | 3. Once your API token is saved, you will find a new 'Create Twitter Thread' option when viewing your Digest. Click on it to effortlessly transform your Digest into a draft Twitter thread. 26 | 27 | We hope you enjoy this new features and find it valuable for sharing your technological discoveries with the world! 28 | 29 | Thank you for choosing Digest.club, and happy digesting! 🚀 30 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/[teamSlug]/settings/integrations/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TeamPageProps } from '../../page'; 3 | import { getCurrentUser } from '@/lib/sessions'; 4 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 5 | import { redirect } from 'next/navigation'; 6 | import TeamIntegrations from '@/components/teams/form/settings/TeamIntegrations'; 7 | import SettingsPageLayout from '@/components/teams/form/settings/SettingsPageLayout'; 8 | import { routes } from '@/core/constants'; 9 | import { getTeamSettingsPageInfo } from '@/utils/page'; 10 | import { checkUserTeamBySlug } from '@/services/database/user'; 11 | 12 | export default async function Page({ params }: TeamPageProps) { 13 | const teamSlug = params.teamSlug; 14 | const user = await getCurrentUser(); 15 | if (!user) { 16 | return redirect(authOptions.pages!.signIn!); 17 | } 18 | const team = await checkUserTeamBySlug(teamSlug, user.id); 19 | 20 | if (!team) { 21 | redirect('/teams'); 22 | } 23 | 24 | const pageInfo = getTeamSettingsPageInfo('integrations', team.slug); 25 | const { title, subtitle, menuItems, routePath } = pageInfo; 26 | 27 | return ( 28 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/bookmark/HeaderCreateBookmarkButton.tsx: -------------------------------------------------------------------------------- 1 | import { PlusIcon } from '@heroicons/react/24/solid'; 2 | import { Team } from '@prisma/client'; 3 | import { useEffect, useState } from 'react'; 4 | import { Dialog, DialogContent, DialogTrigger } from '../Dialog'; 5 | import { BookmarkModal } from './BookmarkModal'; 6 | 7 | type Props = { team: Team }; 8 | 9 | const HeaderCreateBookmarkButton = ({ team }: Props) => { 10 | const [isDialogOpen, setIsDialogOpen] = useState(false); 11 | 12 | function onKeyDown(event: KeyboardEvent) { 13 | if (event.key === 'b' && event.ctrlKey) { 14 | setIsDialogOpen(true); 15 | } 16 | } 17 | 18 | useEffect(() => { 19 | window.addEventListener('keydown', onKeyDown); 20 | return () => { 21 | window.removeEventListener('keydown', onKeyDown); 22 | }; 23 | }, []); 24 | 25 | return ( 26 | 27 | 28 | 38 | 39 | 45 | setIsDialogOpen(false)} team={team} /> 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default HeaderCreateBookmarkButton; 52 | -------------------------------------------------------------------------------- /src/components/pages/DigestEditVisit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DocumentCheckIcon } from '@heroicons/react/24/solid'; 3 | import { ChevronRightIcon } from '@heroicons/react/24/solid'; 4 | import Link from 'next/link'; 5 | 6 | interface Props { 7 | href: string; 8 | relativeDate: string; 9 | } 10 | export default function DigestEditVisit({ href, relativeDate }: Props) { 11 | return ( 12 |
13 |
14 | 15 | 16 | 17 |
18 |
19 |
20 | 21 |
28 |

29 | Published about {relativeDate} 30 |

31 |
32 |
33 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/api/team-og.tsx: -------------------------------------------------------------------------------- 1 | import { captureException } from '@sentry/nextjs'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { createOGTeamSVG } from '@/utils/open-graph'; 4 | import db from '@/lib/db'; 5 | import { Resvg } from '@resvg/resvg-js'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | res.setHeader('Content-Type', 'image/png'); 12 | 13 | try { 14 | const hasSlug = req.query.team; 15 | 16 | if (!hasSlug) return res.status(404).end(); 17 | 18 | const team = await db.team.findUnique({ 19 | where: { 20 | slug: req.query.team as string, 21 | }, 22 | select: { 23 | name: true, 24 | github: true, 25 | twitter: true, 26 | website: true, 27 | bio: true, 28 | Digest: { 29 | select: { 30 | title: true, 31 | }, 32 | }, 33 | }, 34 | }); 35 | 36 | if (!team) return res.status(404).end(); 37 | 38 | const svg = await createOGTeamSVG({ 39 | name: team.name, 40 | github: team.github, 41 | twitter: team.twitter, 42 | bio: team.bio, 43 | nbOfDigest: team.Digest.length, 44 | }); 45 | 46 | const resvg = new Resvg(svg); 47 | const pngData = resvg.render(); 48 | const png = pngData.asPng(); 49 | res.setHeader('Content-Type', 'image/png'); 50 | res.setHeader( 51 | 'Cache-Control', 52 | 'public, max-age=86400, immutable' 53 | ); /* 1 day */ 54 | res.status(200).end(png); 55 | } catch (e: any) { 56 | captureException(e); 57 | // eslint-disable-next-line no-console 58 | console.log(e); 59 | return res.status(500).json({ error: e.message }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/feed.ts: -------------------------------------------------------------------------------- 1 | import { getEnvHost } from '@/lib/server'; 2 | import { parseISO } from 'date-fns'; 3 | import { Feed } from 'feed'; 4 | import { generateTeamOGUrl } from './open-graph-url'; 5 | import { PublicTeamResult } from '@/services/database/team'; 6 | 7 | export const createFeed = (team: PublicTeamResult, teamSlug: string) => { 8 | const date = new Date(); 9 | const ogImage = generateTeamOGUrl(team?.slug || ''); 10 | const feed = new Feed({ 11 | title: team!.name, 12 | description: team?.bio || undefined, 13 | id: `${getEnvHost()}/${teamSlug}`, 14 | copyright: `All rights reserved ${date.getFullYear()}, Digest.club`, 15 | updated: date, 16 | link: `${getEnvHost()}/${teamSlug}`, 17 | language: 'en', 18 | image: ogImage, 19 | favicon: `${getEnvHost()}/favicon.ico`, 20 | feedLinks: { 21 | rss2: `${getEnvHost()}/${teamSlug}/rss.xml`, 22 | atom: `${getEnvHost()}/${teamSlug}/atom.xml`, 23 | }, 24 | }); 25 | 26 | team?.Digest.forEach((digest) => { 27 | feed.addItem({ 28 | title: digest.title, 29 | id: `${getEnvHost()}/${teamSlug}/${digest.slug}`, 30 | link: `${getEnvHost()}/${teamSlug}/${digest.slug}`, 31 | description: `${digest.description ? digest.description + ' - ' : ''} ${ 32 | digest.digestBlocks?.length 33 | } bookmarks`, 34 | date: parseISO(digest.publishedAt!.toString()), 35 | }); 36 | }); 37 | 38 | return feed; 39 | }; 40 | 41 | export const rss = (team: PublicTeamResult, teamSlug: string) => { 42 | const feed = createFeed(team, teamSlug); 43 | return feed.rss2(); 44 | }; 45 | 46 | export const atom = (team: PublicTeamResult, teamSlug: string) => { 47 | const feed = createFeed(team, teamSlug); 48 | return feed.atom1(); 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/charts/ChartsServer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Charts from './Charts'; 3 | import db from '@/lib/db'; 4 | 5 | interface Props { 6 | linkCount: number; 7 | teamId: string; 8 | } 9 | 10 | async function getTeamLinksCountByMonth(teamId: string) { 11 | // Safe from SQL injection --> https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#queryraw 12 | const result = await db.$queryRaw`SELECT 13 | CAST(EXTRACT(MONTH FROM b."createdAt") AS INTEGER) AS month, 14 | CAST(COUNT(link."id") AS INTEGER) AS link_count 15 | FROM 16 | bookmarks b 17 | JOIN 18 | links link ON b."linkId" = link."id" 19 | JOIN 20 | teams t ON b."teamId" = t."id" 21 | WHERE 22 | t."id" = ${teamId} 23 | AND EXTRACT(YEAR FROM b."createdAt") = EXTRACT(YEAR FROM CURRENT_DATE) 24 | GROUP BY 25 | month;`; 26 | 27 | return result as Array<{ 28 | month: number; 29 | link_count: number; 30 | }>; 31 | } 32 | 33 | export default async function ChartsServer({ linkCount, teamId }: Props) { 34 | const teamLinkCountByMonth = await getTeamLinksCountByMonth(teamId); 35 | const emptyCount = teamLinkCountByMonth.length === 0; 36 | const text = linkCount > 1 ? 'bookmarks' : 'bookmark'; 37 | if (emptyCount) { 38 | return null; 39 | } 40 | 41 | return ( 42 |
43 |

44 | {linkCount} 45 | 46 | {text} 47 | 48 |

49 |
50 | 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /prisma/migrations/20231206160616_add_tag_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "tags" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "slug" TEXT NOT NULL, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL, 8 | "description" TEXT, 9 | 10 | CONSTRAINT "tags_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "_links_to_tags" ( 15 | "A" TEXT NOT NULL, 16 | "B" TEXT NOT NULL 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "_digestblocks_to_tags" ( 21 | "A" TEXT NOT NULL, 22 | "B" TEXT NOT NULL 23 | ); 24 | 25 | -- CreateIndex 26 | CREATE UNIQUE INDEX "_links_to_tags_AB_unique" ON "_links_to_tags"("A", "B"); 27 | 28 | -- CreateIndex 29 | CREATE INDEX "_links_to_tags_B_index" ON "_links_to_tags"("B"); 30 | 31 | -- CreateIndex 32 | CREATE UNIQUE INDEX "_digestblocks_to_tags_AB_unique" ON "_digestblocks_to_tags"("A", "B"); 33 | 34 | -- CreateIndex 35 | CREATE INDEX "_digestblocks_to_tags_B_index" ON "_digestblocks_to_tags"("B"); 36 | 37 | -- AddForeignKey 38 | ALTER TABLE "_links_to_tags" ADD CONSTRAINT "_links_to_tags_A_fkey" FOREIGN KEY ("A") REFERENCES "links"("id") ON DELETE CASCADE ON UPDATE CASCADE; 39 | 40 | -- AddForeignKey 41 | ALTER TABLE "_links_to_tags" ADD CONSTRAINT "_links_to_tags_B_fkey" FOREIGN KEY ("B") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE; 42 | 43 | -- AddForeignKey 44 | ALTER TABLE "_digestblocks_to_tags" ADD CONSTRAINT "_digestblocks_to_tags_A_fkey" FOREIGN KEY ("A") REFERENCES "digest_blocks"("id") ON DELETE CASCADE ON UPDATE CASCADE; 45 | 46 | -- AddForeignKey 47 | ALTER TABLE "_digestblocks_to_tags" ADD CONSTRAINT "_digestblocks_to_tags_B_fkey" FOREIGN KEY ("B") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE; 48 | -------------------------------------------------------------------------------- /src/utils/link/index.ts: -------------------------------------------------------------------------------- 1 | export async function isLinkValid(url: string): Promise { 2 | return fetch(url, { 3 | headers: { 4 | 'User-Agent': 'digestclub-bot/1.0', 5 | }, 6 | }) 7 | .then((response) => { 8 | // eslint-disable-next-line no-console 9 | console.log('response', response); 10 | if (!response.ok) { 11 | // eslint-disable-next-line no-console 12 | console.error(response); 13 | throw new TypeError('invalid_link'); 14 | } 15 | return response; 16 | }) 17 | .catch((e) => { 18 | // eslint-disable-next-line no-console 19 | console.error(e); 20 | throw new TypeError('invalid_link'); 21 | }); 22 | } 23 | 24 | /** 25 | * @description Function that checks if a given URL is a Twitter / X link 26 | * @param url a string representing a URL 27 | * @returns a boolean indicating if the URL is a twitter link 28 | */ 29 | export function isTwitterLink(url: string): boolean { 30 | const twitterPattern = 31 | /^(?:https?:\/\/(?:www\.)?)?(?:twitter\.com|x\.com)\/.*$/; 32 | 33 | return twitterPattern.test(url); 34 | } 35 | 36 | /** 37 | * @description Function that returns the id of a tweet from a given twitter URL (ex: https://twitter.com/elonmusk/status/1427847987038444544) 38 | * @param url a string representing a URL 39 | * @returns a string representing the id of the tweet 40 | * @returns false if the URL is not a valid tweet link 41 | */ 42 | export function getTweetId(url: string): false | string { 43 | const twitterPattern = 44 | /^(?:https?:\/\/(?:www\.)?)?(?:twitter\.com|x\.com)\/.*\/status\/(\d+)(?:\?.*)?$/; 45 | 46 | const match = url.match(twitterPattern); 47 | 48 | if (match && match[1]) { 49 | return match[1]; 50 | } else { 51 | return false; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/charts/Charts.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | 4 | import { Tooltip, ResponsiveContainer, Line, LineChart } from 'recharts'; 5 | 6 | import ChartsTooltip from './ChartsTooltip'; 7 | interface Props { 8 | teamLinksByMonth: Array<{ 9 | month: number; 10 | link_count: number; 11 | }>; 12 | } 13 | 14 | type GraphData = Array<{ 15 | month: (typeof MONTH_SHORT_NAMES)[number]; 16 | amt: number; 17 | }>; 18 | 19 | const MONTH_SHORT_NAMES = [ 20 | 'Jan', 21 | 'Feb', 22 | 'Mar', 23 | 'Apr', 24 | 'May', 25 | 'Jun', 26 | 'Jul', 27 | 'Aug', 28 | 'Sept', 29 | 'Oct', 30 | 'Nov', 31 | 'Dec', 32 | ] as const; 33 | 34 | function refinePropToData(props: Props['teamLinksByMonth']): GraphData { 35 | return MONTH_SHORT_NAMES.map((month, index) => { 36 | const monthData = props.find((d) => d.month === index + 1); 37 | return { 38 | month, 39 | amt: monthData ? monthData.link_count : 0, 40 | }; 41 | }); 42 | } 43 | 44 | export default function ClientCharts({ teamLinksByMonth }: Props) { 45 | const data = refinePropToData(teamLinksByMonth); 46 | return ( 47 | 48 | 52 | } /> 53 | 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/[teamSlug]/digests/[digestId]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { TemplateEdit } from '@/components/digests/templates/TemplateEdit'; 2 | import { DigestEditPage } from '@/components/pages/DigestEditPage'; 3 | import { TeamProvider } from '@/contexts/TeamContext'; 4 | import { getCurrentUserOrRedirect } from '@/lib/sessions'; 5 | import { getDigest } from '@/services/database/digest'; 6 | import { getTeamLinks } from '@/services/database/link'; 7 | import { checkUserTeamBySlug } from '@/services/database/user'; 8 | import { redirect } from 'next/navigation'; 9 | 10 | export interface TeamPageProps { 11 | params: { teamSlug: string; digestId: string }; 12 | searchParams?: { [key: string]: string | undefined }; 13 | } 14 | 15 | const page = async ({ params, searchParams }: TeamPageProps) => { 16 | const user = await getCurrentUserOrRedirect(); 17 | const team = await checkUserTeamBySlug(params.teamSlug, user.id); 18 | 19 | if (!team) { 20 | redirect('/teams'); 21 | } 22 | 23 | const digest = await getDigest(params.digestId); 24 | 25 | if (!digest || digest.teamId !== team.id) { 26 | redirect(`/teams/${team.slug}/digests/${params.digestId}`); 27 | } 28 | 29 | const page = Number(searchParams?.page || 1); 30 | const search = searchParams?.search || ''; 31 | const teamLinksData = await getTeamLinks(team.id, { 32 | page, 33 | onlyNotInDigest: true, 34 | search, 35 | }); 36 | 37 | return ( 38 | 39 | {digest?.isTemplate ? ( 40 | 41 | ) : ( 42 | 47 | )} 48 | 49 | ); 50 | }; 51 | 52 | export default page; 53 | -------------------------------------------------------------------------------- /src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import React from 'react'; 3 | 4 | interface IProps { 5 | size: 'xs' | 'sm' | 'md' | 'lg'; 6 | name?: string; 7 | src?: string; 8 | } 9 | 10 | export default function Avatar({ size = 'md', src, name }: IProps) { 11 | const sizeClass: Record = { 12 | xs: 'h-4 w-4', 13 | sm: 'h-6 w-6', 14 | md: 'h-8 w-8', 15 | lg: 'h-10 w-10', 16 | }; 17 | 18 | const sizePixels: Record = { 19 | xs: 4, 20 | sm: 6, 21 | md: 8, 22 | lg: 10, 23 | }; 24 | 25 | if (src !== undefined) { 26 | return ( 27 | avatar 34 | ); 35 | } 36 | 37 | if (name !== undefined) { 38 | return ( 39 | 42 | 43 | {name 44 | .split(' ') 45 | .splice(0, 2) 46 | .map((n) => n[0]) 47 | .join('')} 48 | 49 | 50 | ); 51 | } 52 | 53 | return ( 54 | 57 | 62 | 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/components/home/Hero.tsx: -------------------------------------------------------------------------------- 1 | import { routes } from '@/core/constants'; 2 | import Image from 'next/image'; 3 | import Link from 'next/link'; 4 | import Button from '../Button'; 5 | 6 | export default function Hero({ isConnected }: { isConnected: boolean }) { 7 | return ( 8 |
9 |
10 |

11 | The Frontpage of Teams Knowledge 12 |

13 |

14 | {"Save and share your team's curation"} 15 |

16 |
17 | {isConnected ? ( 18 | 19 | 22 | 23 | ) : ( 24 | 25 | 28 | 29 | )} 30 | 31 | 34 | 35 |
36 |
37 |
38 | Hero svg 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/theme/globals.css: -------------------------------------------------------------------------------- 1 | @config "./../../tailwind.config.js"; 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | .btn { 7 | @apply rounded-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2; 8 | } 9 | 10 | .btn-primary { 11 | @apply rounded-md bg-violet-600 py-2 px-5 text-base font-semibold text-white shadow-sm hover:bg-violet-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 active:bg-violet-900; 12 | } 13 | 14 | .btn-lg { 15 | @apply rounded-md bg-violet-600 py-2.5 px-6 text-lg font-semibold text-white shadow-sm hover:bg-violet-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 active:bg-violet-900; 16 | } 17 | 18 | .btn-xl { 19 | @apply rounded-md bg-violet-600 py-3 px-8 text-xl font-semibold text-white shadow-sm hover:bg-violet-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 active:bg-violet-900; 20 | } 21 | 22 | .btn-secondary { 23 | @apply rounded-md bg-transparent py-2 px-5 text-base font-semibold text-violet-600 hover:bg-violet-100 active:bg-violet-300; 24 | } 25 | 26 | .btn-add-link { 27 | @apply p-3 md:py-2 md:px-3 shadow-md text-white bg-gradient-to-tl from-[#06b6d4] to-[#4ade80] hover:from-[#4ade80] hover:to-[#06b6d4] font-semibold rounded-lg text-xs 2xl:text-sm flex items-center justify-center gap-2; 28 | text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.5); 29 | } 30 | 31 | .TooltipContent { 32 | transform-origin: var(--radix-tooltip-content-transform-origin); 33 | animation: scaleIn 0.05s ease-out; 34 | } 35 | 36 | @keyframes scaleIn { 37 | from { 38 | opacity: 0; 39 | transform: scale(0); 40 | } 41 | to { 42 | opacity: 1; 43 | transform: scale(1); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/teams/settings-tabs/invitations/InvitationList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import useCustomToast from '@/hooks/useCustomToast'; 4 | import { useTransition } from 'react'; 5 | import message from '@/messages/en'; 6 | import InvitationItem from './InvitationItem'; 7 | import deleteInvitation from '@/actions/delete-invitation'; 8 | import { Invitation } from '@prisma/client'; 9 | import NoContent from '@/components/layout/NoContent'; 10 | import { UserIcon } from '@heroicons/react/24/solid'; 11 | import { TeamInvitation } from '@/services/database/invitation'; 12 | 13 | type Props = { 14 | invitations: TeamInvitation[]; 15 | }; 16 | 17 | const InvitationList = ({ invitations }: Props) => { 18 | const { successToast, errorToast } = useCustomToast(); 19 | const [isPending, startTransition] = useTransition(); 20 | 21 | const handleDeleteInvitation = async (invitation: TeamInvitation) => { 22 | startTransition(async () => { 23 | const { error } = await deleteInvitation(invitation.id); 24 | if (error) { 25 | errorToast(error.message); 26 | return; 27 | } else { 28 | successToast(message.invitation.delete.success); 29 | } 30 | }); 31 | }; 32 | 33 | return ( 34 |
35 | {invitations.map((invitation: TeamInvitation) => ( 36 | 42 | ))} 43 | {!invitations?.length && ( 44 | } 46 | title="No invitations" 47 | subtitle="There is no pending invitation for your team." 48 | /> 49 | )} 50 |
51 | ); 52 | }; 53 | 54 | export default InvitationList; 55 | -------------------------------------------------------------------------------- /changelogs/changelog-004.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 🚀 Introducing API for bookmarking 3 | slug: changelog-004 4 | publishedAt: 2023-06-15 5 | image: changelog-004.webp 6 | --- 7 | 8 | ### New Features 9 | 10 | - **API for Bookmarking**: We are happy to announce the introduction of our first API endpoint dedicated to adding bookmarks. Now, you can seamlessly integrate bookmarking functionality into your workflows using our API with your **API key**. 11 | 12 | ### Getting your Team's API Key 13 | 14 | To generate an **API key**, follow these simple steps: 15 | 16 | 1. Go to the "Settings" page of your team in [digest.club](https://digest.club/). 17 | 2. Click on "Create New" to generate a new API key. 18 | 3. The API key will be generated, and you can copy it to your clipboard by clicking on the icon in the input. 19 | 20 | ### Adding a Bookmark via API 21 | 22 | To add a bookmark via the API, make an HTTP **POST** request to this endpoint: _/api/bookmark_. 23 | 24 | Include the bookmark URL in the request body under the **linkUrl** field. Additionally, to authenticate yourself with the API, pass the API key in the _"Authorization"_ header with the _"Bearer "_ prefix. 25 | 26 | ``` 27 | Method: POST 28 | Endpoint: https://www.digest.club/api/bookmark 29 | Headers: 30 | - Authorization: Bearer YOUR_API_KEY 31 | 32 | Body: 33 | { 34 | "linkUrl": "https://www.example.com" 35 | } 36 | ``` 37 | 38 | Here's an example using cURL: 39 | 40 | ```bash 41 | curl -X POST https://www.digest.club/api/bookmark \ 42 | -H "Authorization: Bearer YOUR_API_KEY" \ 43 | -d "linkUrl=https://www.example.com" 44 | ``` 45 | 46 | ## Integration Possibilities 47 | 48 | The API for Bookmarking opens up possibilities for integrating Digest.club into your workflows. Whether you want to automate bookmarking or sync Digests with other platforms, the API provides the flexibility you need. 49 | 50 | Happy digesting! 🚀 51 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/teams/[teamSlug]/settings/templates/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TeamPageProps } from '../../page'; 3 | import { getCurrentUser } from '@/lib/sessions'; 4 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 5 | import { redirect } from 'next/navigation'; 6 | import TeamTemplates from '@/components/teams/form/settings/TeamTemplates'; 7 | import SettingsPageLayout from '@/components/teams/form/settings/SettingsPageLayout'; 8 | import { routes } from '@/core/constants'; 9 | import { getTeamSettingsPageInfo } from '@/utils/page'; 10 | import { getTeamDigests } from '@/services/database/digest'; 11 | import { checkUserTeamBySlug } from '@/services/database/user'; 12 | 13 | export default async function Page({ params }: TeamPageProps) { 14 | const teamSlug = params.teamSlug; 15 | const user = await getCurrentUser(); 16 | if (!user) { 17 | return redirect(authOptions.pages!.signIn!); 18 | } 19 | const team = await checkUserTeamBySlug(teamSlug, user.id); 20 | 21 | if (!team) { 22 | redirect('/teams'); 23 | } 24 | const { digests: templates } = await getTeamDigests(team.id, 1, 30, true); 25 | 26 | const pageInfo = getTeamSettingsPageInfo('templates', team.slug); 27 | const { title, subtitle, menuItems, routePath } = pageInfo; 28 | return ( 29 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/pages/api/digest-og.tsx: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { createOGDigestSVG } from '@/utils/open-graph'; 3 | import { DigestBlockType } from '@prisma/client'; 4 | import { Resvg } from '@resvg/resvg-js'; 5 | import { captureException } from '@sentry/nextjs'; 6 | import { NextApiRequest, NextApiResponse } from 'next'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.setHeader('Content-Type', 'image/png'); 13 | try { 14 | const hasSlug = req.query.digest; 15 | if (!hasSlug) return res.status(404).end(); 16 | const digest = await db.digest.findFirst({ 17 | where: { 18 | slug: req.query.digest as string, 19 | publishedAt: { 20 | not: null, 21 | }, 22 | }, 23 | select: { 24 | title: true, 25 | team: { 26 | select: { 27 | name: true, 28 | }, 29 | }, 30 | digestBlocks: { 31 | select: { 32 | id: true, 33 | }, 34 | where: { 35 | type: DigestBlockType?.BOOKMARK, 36 | }, 37 | }, 38 | }, 39 | }); 40 | 41 | if (!digest) return res.status(404).end(); 42 | 43 | const { title, team } = digest; 44 | const nbOfLink = digest.digestBlocks.length; 45 | 46 | const svg = await createOGDigestSVG({ 47 | title, 48 | team: team.name, 49 | nbOfLink, 50 | }); 51 | const resvg = new Resvg(svg); 52 | const pngData = resvg.render(); 53 | const png = pngData.asPng(); 54 | 55 | res.setHeader('Content-Type', 'image/png'); 56 | res.setHeader( 57 | 'Cache-Control', 58 | 'public, max-age=86400, immutable' 59 | ); /* 1 day */ 60 | res.status(200).end(png); 61 | } catch (e: any) { 62 | captureException(e); 63 | res.status(500).json({ error: 'Internal server error' }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/RssButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { RssIcon } from '@heroicons/react/24/solid'; 3 | import { 4 | Arrow, 5 | Content, 6 | Portal, 7 | Provider, 8 | Root, 9 | Trigger, 10 | } from '@radix-ui/react-tooltip'; 11 | import clsx from 'clsx'; 12 | import { HTMLProps, forwardRef, useState } from 'react'; 13 | 14 | type Props = { 15 | copyText: string; 16 | } & HTMLProps; 17 | 18 | const RssButton = forwardRef((props, ref) => { 19 | const { className, copyText, ...rest } = props; 20 | const copyToClipboard = () => { 21 | navigator.clipboard.writeText(copyText); 22 | setIsOpen(true); 23 | setTimeout(() => { 24 | setIsOpen(false); 25 | }, 1000); 26 | }; 27 | 28 | const [isOpen, setIsOpen] = useState(false); 29 | 30 | return ( 31 | 32 | 33 | 34 |
42 | 43 |
44 |
45 | 46 | 51 | Copied RSS feed to clipboard ! 52 | 53 | 54 | 55 |
56 |
57 | ); 58 | }); 59 | 60 | RssButton.displayName = 'RssButton'; 61 | export default RssButton; 62 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // This file sets a custom webpack configuration to use your Next.js app 2 | // with Sentry. 3 | // https://nextjs.org/docs/api-reference/next.config.js/introduction 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ 5 | const { withSentryConfig } = require('@sentry/nextjs'); 6 | const { withContentlayer } = require('next-contentlayer'); 7 | 8 | /** @type {import('next').NextConfig} */ 9 | const nextConfig = { 10 | output: 'standalone', 11 | experimental: { 12 | serverActions: true, 13 | serverComponentsExternalPackages: ['mjml', 'mjml-react'], 14 | }, 15 | reactStrictMode: false, 16 | }; 17 | 18 | module.exports = nextConfig; 19 | 20 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 21 | enabled: process.env.ANALYZE === 'true', 22 | }); 23 | 24 | module.exports = withBundleAnalyzer( 25 | withContentlayer( 26 | withSentryConfig( 27 | module.exports, 28 | { 29 | // For all available options, see: 30 | // https://github.com/getsentry/sentry-webpack-plugin#options 31 | 32 | // Suppresses source map uploading logs during build 33 | silent: true, 34 | 35 | org: 'premier-octet-z6', 36 | project: 'digestclub', 37 | }, 38 | { 39 | // For all available options, see: 40 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ 41 | 42 | // Upload a larger set of source maps for prettier stack traces (increases build time) 43 | widenClientFileUpload: true, 44 | 45 | // Transpiles SDK to be compatible with IE11 (increases bundle size) 46 | transpileClientSDK: true, 47 | 48 | // Hides source maps from generated client bundles 49 | hideSourceMaps: true, 50 | 51 | // Automatically tree-shake Sentry logger statements to reduce bundle size 52 | disableLogger: true, 53 | } 54 | ) 55 | ) 56 | ); 57 | -------------------------------------------------------------------------------- /src/pages/api/webhooks/slack/shortcuts.ts: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 3 | import { saveBookmark } from '@/services/database/bookmark'; 4 | import { extractLinksFromBlocks, TBlock } from '@/utils/slack'; 5 | import axios from 'axios'; 6 | import type { NextApiResponse } from 'next'; 7 | import { createRouter } from 'next-connect'; 8 | 9 | interface SlackPayload { 10 | type: 'message_action'; 11 | team: { id: string }; 12 | user: { id: string }; 13 | channel: { id: string }; 14 | response_url: string; 15 | message: { 16 | blocks: TBlock[]; 17 | }; 18 | } 19 | 20 | export const router = createRouter(); 21 | 22 | router.post(async (req, res) => { 23 | if (!req.body.payload) { 24 | return res.status(200).end(); 25 | } 26 | 27 | const payload = JSON.parse(req.body.payload) as SlackPayload; 28 | if (payload.type === 'message_action') { 29 | const links = payload.message.blocks 30 | ? extractLinksFromBlocks(payload.message.blocks) 31 | : []; 32 | 33 | const team = await db.team.findFirstOrThrow({ 34 | where: { slackTeamId: payload.team.id }, 35 | }); 36 | 37 | const bookmarks = await Promise.all( 38 | links.map((url) => 39 | saveBookmark(url, team.id, undefined, { 40 | slackUserId: payload.user.id, 41 | slackChannelId: payload.channel.id, 42 | }) 43 | ) 44 | ); 45 | 46 | // Send response to Slack 47 | await axios.post(payload.response_url, { 48 | text: `:pushpin: ${links.join(',')} has been added to your team feed *${ 49 | team.name 50 | }*`, 51 | response_type: 'ephemeral', 52 | }); 53 | 54 | return res.status(200).json({ bookmarks }); 55 | } 56 | return res.status(200).json({ error: 'no_handler' }); 57 | }); 58 | 59 | export default router.handler({ 60 | onError: errorHandler, 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/teams/Breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { ChevronRightIcon } from '@heroicons/react/24/solid'; 3 | import clsx from 'clsx'; 4 | 5 | export type Props = { 6 | paths?: { 7 | name: string; 8 | href?: string; 9 | }[]; 10 | }; 11 | 12 | export const Breadcrumb = ({ paths }: Props) => { 13 | return ( 14 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/digests/dialog/SummaryButton.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@/components/Button'; 2 | import { useTeam } from '@/contexts/TeamContext'; 3 | import useCustomToast from '@/hooks/useCustomToast'; 4 | import api from '@/lib/api'; 5 | import { LightBulbIcon } from '@heroicons/react/24/outline'; 6 | import { AxiosError, AxiosResponse } from 'axios'; 7 | import { useMutation } from 'react-query'; 8 | 9 | const SummaryButton = ({ 10 | url, 11 | handleSuccess, 12 | hasAccess, 13 | }: { 14 | url: string; 15 | handleSuccess: (text: string) => void; 16 | hasAccess: boolean; 17 | }) => { 18 | const { successToast, errorToast } = useCustomToast(); 19 | const { id: teamId } = useTeam(); 20 | 21 | const { mutate: generateSummary, isLoading } = useMutation< 22 | AxiosResponse, 23 | AxiosError, 24 | { url: string } 25 | >( 26 | 'generate-bookmark-summary', 27 | ({ url }) => { 28 | return api.post(`/teams/${teamId}/bookmark/summary`, { 29 | url, 30 | }); 31 | }, 32 | { 33 | onSuccess: (response) => { 34 | successToast('Summary generated'); 35 | handleSuccess(response.data); 36 | }, 37 | onError: (error: AxiosError) => { 38 | errorToast(error.response?.data.error || 'Something went wrong'); 39 | }, 40 | } 41 | ); 42 | 43 | if (!hasAccess) return null; 44 | 45 | return ( 46 |
47 |
48 | 61 |
62 |
63 | ); 64 | }; 65 | 66 | export default SummaryButton; 67 | -------------------------------------------------------------------------------- /src/pages/api/teams/[teamId]/members/[memberId].tsx: -------------------------------------------------------------------------------- 1 | import db from '@/lib/db'; 2 | import { checkAuth } from '@/lib/middleware'; 3 | import { AuthApiRequest, errorHandler } from '@/lib/router'; 4 | import { NextApiResponse } from 'next'; 5 | import { createRouter } from 'next-connect'; 6 | import { UserRoles } from '@/core/constants'; 7 | import { Membership } from '@prisma/client'; 8 | 9 | export const router = createRouter(); 10 | 11 | export type ApiDeleteMemberResponse = { 12 | membership: Membership; 13 | }; 14 | 15 | router.use(checkAuth).delete(async (req, res) => { 16 | const memberId = req.query.memberId as string; 17 | const user = req.user; 18 | const teamId = req.query.teamId as string; 19 | 20 | const team = await db.team.findUnique({ 21 | where: { 22 | id: teamId, 23 | }, 24 | include: { 25 | memberships: true, 26 | }, 27 | }); 28 | if (!team) return res.status(404).json({ error: 'Team not found' }); 29 | 30 | const currentUserMembership = team.memberships.find( 31 | (m) => m.userId === user?.id 32 | ); 33 | if (!currentUserMembership) 34 | return res.status(404).json({ error: 'Membership not found' }); 35 | 36 | const isAdmin = currentUserMembership.role === UserRoles.ADMIN; 37 | if (!isAdmin) return res.send(403); 38 | 39 | const isDeletingSelf = currentUserMembership.id === memberId; 40 | if (isDeletingSelf) return res.send(403); 41 | 42 | const targetMembership = await db.membership.findUnique({ 43 | where: { 44 | id: memberId, 45 | }, 46 | }); 47 | if (!targetMembership) return res.send(404); 48 | if (targetMembership.teamId !== teamId) return res.status(404); 49 | 50 | await db.membership.delete({ 51 | where: { 52 | id: memberId, 53 | }, 54 | }); 55 | return res.status(200).json({ 56 | membership: targetMembership, 57 | }); 58 | }); 59 | 60 | export default router.handler({ 61 | onError: errorHandler, 62 | }); 63 | -------------------------------------------------------------------------------- /src/components/bookmark/BookmarkListDnd.tsx: -------------------------------------------------------------------------------- 1 | import { getDigest } from '@/services/database/digest'; 2 | import { TeamLinks } from '@/services/database/link'; 3 | import { getTeamBySlug } from '@/services/database/team'; 4 | import { Draggable, Droppable } from 'react-beautiful-dnd'; 5 | import { BookmarkItem } from './BookmarkItem'; 6 | 7 | export type BookmarkListDndProps = { 8 | digest: NonNullable>>; 9 | team: Awaited>; 10 | teamLinks: TeamLinks; 11 | }; 12 | 13 | const BookmarkListDnd = ({ teamLinks, team, digest }: BookmarkListDndProps) => { 14 | return ( 15 | 16 | {(provided) => ( 17 |
    22 | {teamLinks?.map((teamLink, index) => { 23 | return ( 24 | 29 | {(provided) => ( 30 |
  • 35 | 43 |
  • 44 | )} 45 |
    46 | ); 47 | })} 48 | {provided.placeholder} 49 |
50 | )} 51 |
52 | ); 53 | }; 54 | 55 | export default BookmarkListDnd; 56 | -------------------------------------------------------------------------------- /src/emails/templates/InvitationEmail.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Mjml, 3 | MjmlBody, 4 | MjmlSection, 5 | MjmlColumn, 6 | MjmlText, 7 | MjmlWrapper, 8 | MjmlImage, 9 | MjmlButton, 10 | } from 'mjml-react'; 11 | 12 | const InvitationEmail = ({ 13 | url, 14 | teamName, 15 | }: { 16 | url: string; 17 | teamName: string; 18 | }) => ( 19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | You have been invited to join the {teamName} team! 31 | 32 | 40 | Join {teamName} 41 | 42 | 43 | {`If you're on a mobile device, you can also copy the link below 44 | and paste it into the browser of your choice.`} 45 | 46 | 47 | 54 | {url.replace(/^https?:\/\//, '')} 55 | 56 | 57 | 58 | If you did not request this email, you can safely ignore it. 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | 67 | export default InvitationEmail; 68 | -------------------------------------------------------------------------------- /src/app/(app)/(routes)/[teamSlug]/[digestSlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import DigestPublicPage from '@/components/pages/DigestPublicPage'; 2 | import { 3 | getPublicDigest, 4 | incrementDigestView, 5 | } from '@/services/database/digest'; 6 | import { generateDigestOGUrl } from '@/utils/open-graph-url'; 7 | import * as Sentry from '@sentry/nextjs'; 8 | import { Metadata } from 'next'; 9 | import { redirect } from 'next/navigation'; 10 | 11 | interface PageProps { 12 | params: { teamSlug: string; digestSlug: string }; 13 | } 14 | 15 | export async function generateMetadata({ 16 | params, 17 | }: PageProps): Promise { 18 | try { 19 | const digest = await getPublicDigest(params.digestSlug, params.teamSlug); 20 | const url = generateDigestOGUrl(params.digestSlug); 21 | 22 | return { 23 | title: `${digest?.title} by ${digest?.team.name}`, 24 | twitter: { 25 | card: 'summary_large_image', 26 | title: `${digest?.title}`, 27 | description: digest?.description || digest?.team.name, 28 | images: [url], 29 | }, 30 | openGraph: { 31 | type: 'article', 32 | title: `${digest?.title}`, 33 | description: digest?.description || digest?.team.name, 34 | siteName: 'digest.club', 35 | url: `${process.env.NEXT_PUBLIC_PUBLIC_URL}/${params.teamSlug}/${params.digestSlug}`, 36 | images: [ 37 | { 38 | url, 39 | width: 1200, 40 | height: 600, 41 | }, 42 | ], 43 | }, 44 | }; 45 | } catch (error) { 46 | Sentry.captureException(error); 47 | return {}; 48 | } 49 | } 50 | 51 | const PublicDigestPage = async ({ params }: PageProps) => { 52 | const digest = await getPublicDigest(params.digestSlug, params.teamSlug); 53 | 54 | if (!digest) { 55 | redirect('/'); 56 | } 57 | 58 | incrementDigestView(digest.id); 59 | 60 | return ; 61 | }; 62 | 63 | export default PublicDigestPage; 64 | --------------------------------------------------------------------------------